claudehub-hooks 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pandiyaraj Karuppasamy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: claudehub-hooks
3
+ Version: 0.1.0
4
+ Summary: Claude Code hook dispatcher — forwards lifecycle events to Claude Monitor
5
+ Author-email: Pandiyaraj Karuppasamy <pandiyarajk@live.com>
6
+ License-Expression: MIT
7
+ Keywords: claude,claude-code,claudehub,hooks,monitor,dispatcher
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # claudehub-hooks
24
+
25
+ Pip-installable [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hook dispatcher.
26
+ Install once, then wire any repository to a Claude Monitor HTTP
27
+ endpoint in one command — no manual script copying or JSON editing.
28
+
29
+ Events flow: **Claude Code → claudehub-hooks dispatcher → Claude Monitor**.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install claudehub-hooks
35
+ ```
36
+
37
+ Requires Python 3.10+. No third-party runtime dependencies (stdlib only).
38
+
39
+ ## Quick start
40
+
41
+ ```bash
42
+ claudehub-hooks install
43
+ ```
44
+
45
+ Run from a repository root (or pass `--repo`). The command is **idempotent** — safe to re-run.
46
+
47
+ ```bash
48
+ claudehub-hooks install --repo /path/to/repo
49
+ claudehub-hooks install --url http://192.168.1.50:7070/event
50
+ claudehub-hooks install --dry-run
51
+ ```
52
+
53
+ ### What it writes
54
+
55
+ | File | Purpose |
56
+ |------|---------|
57
+ | `.claude/hooks/claude_hook.py` | Hook dispatcher (invoked by Claude Code) |
58
+ | `.claude/hooks/monitor_config.json` | Transport config with the monitor URL |
59
+ | `.claude/settings.json` | Nine hook entries merged in (existing entries kept) |
60
+
61
+ ## Change the monitor URL
62
+
63
+ Edit `.claude/hooks/monitor_config.json` in the repo:
64
+
65
+ ```json
66
+ {
67
+ "transport": {
68
+ "http": { "url": "http://192.168.1.50:7070/event" }
69
+ }
70
+ }
71
+ ```
72
+
73
+ Or set an environment variable (overrides the file):
74
+
75
+ ```bash
76
+ export CLAUDE_HUB_URL="http://192.168.1.50:7070/event"
77
+ ```
78
+
79
+ On Windows (persistent):
80
+
81
+ ```bat
82
+ setx CLAUDE_HUB_URL "http://192.168.1.50:7070/event"
83
+ ```
84
+
85
+ ## Test connectivity
86
+
87
+ ```bash
88
+ python .claude/hooks/claude_hook.py --test
89
+ ```
90
+
91
+ ## Hooks installed
92
+
93
+ | Event | Trigger |
94
+ |-------|---------|
95
+ | `SessionStart` | Claude session begins |
96
+ | `UserPromptSubmit` | User sends a message |
97
+ | `Notification` | Claude needs input |
98
+ | `PostToolUse` | File read/edited (Edit · Write · NotebookEdit · MultiEdit · Read) |
99
+ | `Stop` | Turn completes |
100
+ | `StopFailure` | API error |
101
+ | `PermissionRequest` | Allow/deny dialog shown |
102
+ | `PermissionDenied` | User denied a tool |
103
+ | `PreToolUse` | Tool about to run (resolves permission) |
104
+
105
+ ## Configuration
106
+
107
+ | Variable | Default | Meaning |
108
+ |----------|---------|---------|
109
+ | `CLAUDE_HUB_URL` | `http://127.0.0.1:7070/event` | Monitor endpoint |
110
+ | `CLAUDE_HUB_TIMEOUT` | `2.0` | HTTP timeout (seconds) |
111
+ | `CLAUDE_HUB_HTTP_ENABLED` | `true` | Set to `false` to silence all hooks |
112
+ | `CLAUDE_HOOK_LOG_LEVEL` | `INFO` | Dispatcher log level |
113
+ | `CLAUDE_HOOK_LOG_FILE` | `~/.claude/claude_hook.log` | Override log path |
114
+ | `CLAUDE_HUB_CONFIG` | — | Path to a `monitor_config.json` override |
115
+
116
+ ## License
117
+
118
+ MIT — see [LICENSE](LICENSE).
119
+
120
+ **Author:** Pandiyaraj Karuppasamy · pandiyarajk@live.com
@@ -0,0 +1,98 @@
1
+ # claudehub-hooks
2
+
3
+ Pip-installable [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hook dispatcher.
4
+ Install once, then wire any repository to a Claude Monitor HTTP
5
+ endpoint in one command — no manual script copying or JSON editing.
6
+
7
+ Events flow: **Claude Code → claudehub-hooks dispatcher → Claude Monitor**.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install claudehub-hooks
13
+ ```
14
+
15
+ Requires Python 3.10+. No third-party runtime dependencies (stdlib only).
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ claudehub-hooks install
21
+ ```
22
+
23
+ Run from a repository root (or pass `--repo`). The command is **idempotent** — safe to re-run.
24
+
25
+ ```bash
26
+ claudehub-hooks install --repo /path/to/repo
27
+ claudehub-hooks install --url http://192.168.1.50:7070/event
28
+ claudehub-hooks install --dry-run
29
+ ```
30
+
31
+ ### What it writes
32
+
33
+ | File | Purpose |
34
+ |------|---------|
35
+ | `.claude/hooks/claude_hook.py` | Hook dispatcher (invoked by Claude Code) |
36
+ | `.claude/hooks/monitor_config.json` | Transport config with the monitor URL |
37
+ | `.claude/settings.json` | Nine hook entries merged in (existing entries kept) |
38
+
39
+ ## Change the monitor URL
40
+
41
+ Edit `.claude/hooks/monitor_config.json` in the repo:
42
+
43
+ ```json
44
+ {
45
+ "transport": {
46
+ "http": { "url": "http://192.168.1.50:7070/event" }
47
+ }
48
+ }
49
+ ```
50
+
51
+ Or set an environment variable (overrides the file):
52
+
53
+ ```bash
54
+ export CLAUDE_HUB_URL="http://192.168.1.50:7070/event"
55
+ ```
56
+
57
+ On Windows (persistent):
58
+
59
+ ```bat
60
+ setx CLAUDE_HUB_URL "http://192.168.1.50:7070/event"
61
+ ```
62
+
63
+ ## Test connectivity
64
+
65
+ ```bash
66
+ python .claude/hooks/claude_hook.py --test
67
+ ```
68
+
69
+ ## Hooks installed
70
+
71
+ | Event | Trigger |
72
+ |-------|---------|
73
+ | `SessionStart` | Claude session begins |
74
+ | `UserPromptSubmit` | User sends a message |
75
+ | `Notification` | Claude needs input |
76
+ | `PostToolUse` | File read/edited (Edit · Write · NotebookEdit · MultiEdit · Read) |
77
+ | `Stop` | Turn completes |
78
+ | `StopFailure` | API error |
79
+ | `PermissionRequest` | Allow/deny dialog shown |
80
+ | `PermissionDenied` | User denied a tool |
81
+ | `PreToolUse` | Tool about to run (resolves permission) |
82
+
83
+ ## Configuration
84
+
85
+ | Variable | Default | Meaning |
86
+ |----------|---------|---------|
87
+ | `CLAUDE_HUB_URL` | `http://127.0.0.1:7070/event` | Monitor endpoint |
88
+ | `CLAUDE_HUB_TIMEOUT` | `2.0` | HTTP timeout (seconds) |
89
+ | `CLAUDE_HUB_HTTP_ENABLED` | `true` | Set to `false` to silence all hooks |
90
+ | `CLAUDE_HOOK_LOG_LEVEL` | `INFO` | Dispatcher log level |
91
+ | `CLAUDE_HOOK_LOG_FILE` | `~/.claude/claude_hook.log` | Override log path |
92
+ | `CLAUDE_HUB_CONFIG` | — | Path to a `monitor_config.json` override |
93
+
94
+ ## License
95
+
96
+ MIT — see [LICENSE](LICENSE).
97
+
98
+ **Author:** Pandiyaraj Karuppasamy · pandiyarajk@live.com
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "claudehub-hooks"
7
+ dynamic = ["version"]
8
+ description = "Claude Code hook dispatcher — forwards lifecycle events to Claude Monitor"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Pandiyaraj Karuppasamy", email = "pandiyarajk@live.com"}]
14
+ keywords = ["claude", "claude-code", "claudehub", "hooks", "monitor", "dispatcher"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Libraries",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.scripts]
30
+ claudehub-hooks = "claudehub_hooks.cli:main"
31
+
32
+ [tool.setuptools.dynamic]
33
+ version = {attr = "claudehub_hooks.__version__"}
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """Claude Code hook dispatcher package."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,63 @@
1
+ """settings.json read / merge / atomic-write helpers."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ def read_settings(path: Path) -> dict:
10
+ """Load a JSON settings file; return {} on missing or malformed file."""
11
+ try:
12
+ with path.open(encoding="utf-8") as fh:
13
+ data = json.load(fh)
14
+ return data if isinstance(data, dict) else {}
15
+ except (OSError, ValueError):
16
+ return {}
17
+
18
+
19
+ def merge_hooks(existing: dict, new_block: dict) -> tuple[dict, list[str], list[str]]:
20
+ """Merge *new_block* into *existing["hooks"]*, skipping entries that already
21
+ have a ``claude_hook.py`` entry for the same event.
22
+
23
+ Returns ``(updated_dict, added_events, skipped_events)``.
24
+ """
25
+ hooks = existing.setdefault("hooks", {})
26
+ added: list[str] = []
27
+ skipped: list[str] = []
28
+
29
+ for event, entries in new_block.items():
30
+ if event in hooks and _has_claude_hook_entry(hooks[event]):
31
+ skipped.append(event)
32
+ continue
33
+ hooks[event] = entries
34
+ added.append(event)
35
+
36
+ return existing, added, skipped
37
+
38
+
39
+ def _has_claude_hook_entry(entries: list) -> bool:
40
+ """Return True if any entry in the list references ``claude_hook.py``."""
41
+ for entry in entries:
42
+ for hook in entry.get("hooks", []):
43
+ args = hook.get("args", [])
44
+ if any("claude_hook.py" in str(a) for a in args):
45
+ return True
46
+ return False
47
+
48
+
49
+ def write_settings(path: Path, data: dict) -> None:
50
+ """Atomically write *data* as JSON to *path* (write temp → rename)."""
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".settings_", suffix=".json")
53
+ try:
54
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
55
+ json.dump(data, fh, indent=2, ensure_ascii=False)
56
+ fh.write("\n")
57
+ os.replace(tmp, path)
58
+ except Exception:
59
+ try:
60
+ os.unlink(tmp)
61
+ except OSError:
62
+ pass
63
+ raise
@@ -0,0 +1,189 @@
1
+ """claudehub-hooks CLI — install the hook dispatcher into any repo."""
2
+
3
+ import argparse
4
+ import json
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from ._settings import merge_hooks, read_settings, write_settings
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Hook block — the 9 entries written into .claude/settings.json
14
+ # ---------------------------------------------------------------------------
15
+
16
+ def _entry(event: str) -> list:
17
+ """Standard hook entry for an event (no matcher)."""
18
+ return [{"hooks": [{"type": "command", "command": "python",
19
+ "args": [f"${{CLAUDE_PROJECT_DIR}}/.claude/hooks/claude_hook.py", event]}]}]
20
+
21
+
22
+ def _entry_with_matcher(event: str, matcher: str) -> list:
23
+ """Hook entry with a tool matcher (PostToolUse only)."""
24
+ return [{"matcher": matcher, "hooks": [{"type": "command", "command": "python",
25
+ "args": [f"${{CLAUDE_PROJECT_DIR}}/.claude/hooks/claude_hook.py", event]}]}]
26
+
27
+
28
+ _HOOKS_BLOCK: dict = {
29
+ "SessionStart": _entry("SessionStart"),
30
+ "UserPromptSubmit": _entry("UserPromptSubmit"),
31
+ "Notification": _entry("Notification"),
32
+ "PostToolUse": _entry_with_matcher("PostToolUse", "Edit|Write|NotebookEdit|MultiEdit|Read"),
33
+ "Stop": _entry("Stop"),
34
+ "StopFailure": _entry("StopFailure"),
35
+ "PermissionRequest": _entry("PermissionRequest"),
36
+ "PermissionDenied": _entry("PermissionDenied"),
37
+ "PreToolUse": _entry("PreToolUse"),
38
+ }
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Default monitor_config.json written next to hook.py
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def _monitor_config(url: str) -> dict:
45
+ return {
46
+ "transport": {
47
+ "http": {
48
+ "enabled": True,
49
+ "url": url,
50
+ "timeout_s": 2.0,
51
+ }
52
+ },
53
+ "logging": {
54
+ "level": "INFO",
55
+ "file": "",
56
+ "max_bytes": 524288,
57
+ "backup_count": 3,
58
+ },
59
+ }
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Install command
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def cmd_install(args: argparse.Namespace) -> int:
67
+ repo = Path(args.repo).resolve()
68
+ url = args.url
69
+ dry = args.dry_run
70
+
71
+ # Paths
72
+ hooks_dir = repo / ".claude" / "hooks"
73
+ hook_dst = hooks_dir / "claude_hook.py"
74
+ config_dst = hooks_dir / "monitor_config.json"
75
+ settings_path = repo / ".claude" / "settings.json"
76
+
77
+ # Warn if not a git repo
78
+ if not (repo / ".git").exists():
79
+ print(f" [warn] {repo} has no .git directory -- continuing anyway")
80
+
81
+ print(f" repo: {repo}")
82
+ print(f" monitor: {url}")
83
+ print()
84
+
85
+ # --- 1. Copy hook.py ---
86
+ hook_src = Path(__file__).parent / "hook.py"
87
+ if hook_dst.exists():
88
+ print(f" [skip] {hook_dst.relative_to(repo)} (already exists)")
89
+ else:
90
+ print(f" [write] {hook_dst.relative_to(repo)}")
91
+ if not dry:
92
+ hooks_dir.mkdir(parents=True, exist_ok=True)
93
+ shutil.copy2(hook_src, hook_dst)
94
+
95
+ # --- 2. Write monitor_config.json ---
96
+ if config_dst.exists():
97
+ print(f" [skip] {config_dst.relative_to(repo)} (already exists)")
98
+ else:
99
+ print(f" [write] {config_dst.relative_to(repo)}")
100
+ if not dry:
101
+ hooks_dir.mkdir(parents=True, exist_ok=True)
102
+ config_dst.write_text(
103
+ json.dumps(_monitor_config(url), indent=2) + "\n",
104
+ encoding="utf-8",
105
+ )
106
+
107
+ # --- 3. Merge hooks into settings.json ---
108
+ existing = read_settings(settings_path)
109
+ updated, added, skipped = merge_hooks(existing, _HOOKS_BLOCK)
110
+
111
+ if added:
112
+ print(f" [merge] {settings_path.relative_to(repo)}")
113
+ for ev in added:
114
+ print(f" + {ev}")
115
+ if not dry:
116
+ write_settings(settings_path, updated)
117
+ else:
118
+ print(f" [skip] {settings_path.relative_to(repo)} (all hooks already present)")
119
+
120
+ if skipped:
121
+ print(f" [skip] hooks already present: {', '.join(skipped)}")
122
+
123
+ print()
124
+ if dry:
125
+ print(" (dry-run -- nothing written)")
126
+ else:
127
+ print(" Done. Start claude_monitor.py, then open Claude Code in this repo.")
128
+ print(f" To change the monitor URL, edit: {config_dst.relative_to(repo)}")
129
+ print(f" Or set: CLAUDE_HUB_URL={url}")
130
+
131
+ return 0
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Argument parser
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def build_parser() -> argparse.ArgumentParser:
139
+ p = argparse.ArgumentParser(
140
+ prog="claudehub-hooks",
141
+ description="Install Claude Code hook dispatcher into a repository.",
142
+ )
143
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
144
+
145
+ sub = p.add_subparsers(dest="command", metavar="<command>")
146
+ sub.required = True
147
+
148
+ install = sub.add_parser(
149
+ "install",
150
+ help="Install the hook dispatcher into a repo.",
151
+ description=(
152
+ "Copies claude_hook.py into <repo>/.claude/hooks/, writes "
153
+ "monitor_config.json, and merges hook entries into "
154
+ "<repo>/.claude/settings.json."
155
+ ),
156
+ )
157
+ install.add_argument(
158
+ "--repo",
159
+ default=".",
160
+ metavar="PATH",
161
+ help="Target repository root (default: current directory).",
162
+ )
163
+ install.add_argument(
164
+ "--url",
165
+ default="http://127.0.0.1:7070/event",
166
+ metavar="URL",
167
+ help="Claude Monitor HTTP endpoint (default: http://127.0.0.1:7070/event).",
168
+ )
169
+ install.add_argument(
170
+ "--dry-run",
171
+ action="store_true",
172
+ help="Print what would change without writing anything.",
173
+ )
174
+ return p
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Entry point
179
+ # ---------------------------------------------------------------------------
180
+
181
+ def main() -> None:
182
+ parser = build_parser()
183
+ args = parser.parse_args()
184
+
185
+ if args.command == "install":
186
+ sys.exit(cmd_install(args))
187
+ else:
188
+ parser.print_help()
189
+ sys.exit(1)
@@ -0,0 +1,333 @@
1
+ """Claude Code hook -> Claude Monitor dispatcher.
2
+
3
+ Reads the hook payload from stdin, builds a structured event, and transmits it
4
+ to the Claude Monitor over HTTP (TCP).
5
+
6
+ When installed via ``claudehub-hooks install``, this file is copied to:
7
+ <repo>/.claude/hooks/claude_hook.py
8
+
9
+ and invoked by Claude Code as:
10
+ python .claude/hooks/claude_hook.py <EventName>
11
+
12
+ Self-test (verify connectivity):
13
+ python claude_hook.py --test
14
+
15
+ The script is fail-safe: transport errors are logged and printed to stderr,
16
+ but the script always exits 0 so it never blocks the Claude session.
17
+
18
+ Config search order:
19
+ 1. CLAUDE_HUB_CONFIG env var path
20
+ 2. Same directory as this script (monitor_config.json)
21
+ 3. ~/.claude/monitor_config.json
22
+
23
+ All config keys are overridable with CLAUDE_HUB_* env vars.
24
+
25
+ Payload schema:
26
+ {
27
+ "event": "SessionStart" | "UserPromptSubmit" | ... | "Unknown",
28
+ "datetime": "<ISO-8601 UTC>",
29
+ "session_id": "<str>",
30
+ "host": "<hostname>",
31
+ "cwd": "<working dir>", # when available
32
+ "files": ["<path>", ...], # PostToolUse only
33
+ "prompt": "<first 500 chars>", # UserPromptSubmit only
34
+ "tool_name": "<tool>", # PostToolUse / PermissionRequest
35
+ "notification_type":"<type>", # Notification only
36
+ "command": "<cmd>", # PermissionRequest/Denied/PreToolUse
37
+ "error_type": "<type>", # StopFailure only
38
+ "error_message": "<msg>", # StopFailure only
39
+ }
40
+ """
41
+
42
+ import json
43
+ import logging
44
+ import logging.handlers
45
+ import os
46
+ import socket
47
+ import sys
48
+ import time
49
+ import urllib.request
50
+ from datetime import datetime, timezone
51
+ from pathlib import Path
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Config
55
+ # ---------------------------------------------------------------------------
56
+
57
+ _DEFAULTS: dict = {
58
+ "transport": {
59
+ "http": {"enabled": True, "url": "http://127.0.0.1:7070/event", "timeout_s": 2.0},
60
+ },
61
+ "monitor": {
62
+ "http_host": "0.0.0.0", "http_port": 7070,
63
+ "history_max": 300,
64
+ },
65
+ "logging": {
66
+ "level": "INFO",
67
+ "file": "", # empty → ~/.claude/claude_hook.log
68
+ "max_bytes": 524288, # 512 KB
69
+ "backup_count": 3,
70
+ },
71
+ }
72
+
73
+
74
+ def _deep_merge(base: dict, override: dict) -> dict:
75
+ """Recursively merge *override* into a copy of *base*."""
76
+ result = base.copy()
77
+ for k, v in override.items():
78
+ if isinstance(v, dict) and isinstance(result.get(k), dict):
79
+ result[k] = _deep_merge(result[k], v)
80
+ else:
81
+ result[k] = v
82
+ return result
83
+
84
+
85
+ def load_config() -> dict:
86
+ """Load config from JSON file then apply env var overrides.
87
+
88
+ Search order for the config file:
89
+ 1. CLAUDE_HUB_CONFIG env var path
90
+ 2. Same directory as this script
91
+ 3. ~/.claude/monitor_config.json
92
+ Falls back to built-in defaults if no file is found.
93
+ """
94
+ cfg = _deep_merge({}, _DEFAULTS)
95
+
96
+ candidates: list[Path] = []
97
+ if os.environ.get("CLAUDE_HUB_CONFIG"):
98
+ candidates.append(Path(os.environ["CLAUDE_HUB_CONFIG"]))
99
+ candidates.append(Path(__file__).parent / "monitor_config.json")
100
+ candidates.append(Path.home() / ".claude" / "monitor_config.json")
101
+
102
+ for path in candidates:
103
+ try:
104
+ with path.open(encoding="utf-8") as fh:
105
+ cfg = _deep_merge(cfg, json.load(fh))
106
+ break
107
+ except (OSError, ValueError):
108
+ continue
109
+
110
+ def _bool(v: str) -> bool:
111
+ return v.strip().lower() not in ("0", "false", "no", "off")
112
+
113
+ ev = os.environ
114
+ if "CLAUDE_HUB_HTTP_ENABLED" in ev: cfg["transport"]["http"]["enabled"] = _bool(ev["CLAUDE_HUB_HTTP_ENABLED"]) # noqa: E701
115
+ if "CLAUDE_HUB_URL" in ev: cfg["transport"]["http"]["url"] = ev["CLAUDE_HUB_URL"] # noqa: E701
116
+ if "CLAUDE_HUB_TIMEOUT" in ev: cfg["transport"]["http"]["timeout_s"] = float(ev["CLAUDE_HUB_TIMEOUT"]) # noqa: E701
117
+ if "CLAUDE_HOOK_LOG_LEVEL" in ev: cfg["logging"]["level"] = ev["CLAUDE_HOOK_LOG_LEVEL"].upper() # noqa: E701
118
+ if "CLAUDE_HOOK_LOG_FILE" in ev: cfg["logging"]["file"] = ev["CLAUDE_HOOK_LOG_FILE"] # noqa: E701
119
+
120
+ return cfg
121
+
122
+
123
+ def _setup_logging(cfg: dict) -> logging.Logger:
124
+ """Configure a rotating-file logger. Never raises (falls back to NullHandler)."""
125
+ lcfg = cfg.get("logging", {})
126
+ level = getattr(logging, lcfg.get("level", "INFO"), logging.INFO)
127
+
128
+ log_path_str = lcfg.get("file") or ""
129
+ log_path = Path(log_path_str) if log_path_str else Path.home() / ".claude" / "claude_hook.log"
130
+
131
+ logger = logging.getLogger("claude_hook")
132
+ logger.setLevel(level)
133
+
134
+ if logger.handlers:
135
+ return logger # already configured
136
+
137
+ fmt = logging.Formatter(
138
+ "%(asctime)s %(levelname)-8s %(message)s",
139
+ datefmt="%Y-%m-%dT%H:%M:%S",
140
+ )
141
+ try:
142
+ log_path.parent.mkdir(parents=True, exist_ok=True)
143
+ fh = logging.handlers.RotatingFileHandler(
144
+ log_path,
145
+ maxBytes=int(lcfg.get("max_bytes", 524288)),
146
+ backupCount=int(lcfg.get("backup_count", 3)),
147
+ encoding="utf-8",
148
+ )
149
+ fh.setFormatter(fmt)
150
+ logger.addHandler(fh)
151
+ except OSError:
152
+ logger.addHandler(logging.NullHandler())
153
+
154
+ return logger
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Payload helpers
159
+ # ---------------------------------------------------------------------------
160
+
161
+ def _extract_files(label: str, payload: dict) -> list[str]:
162
+ """Extract file paths from a PostToolUse payload."""
163
+ if label != "PostToolUse":
164
+ return []
165
+ tool_input = payload.get("tool_input") or {}
166
+ files: list[str] = []
167
+ for key in ("file_path", "notebook_path", "path"):
168
+ val = tool_input.get(key)
169
+ if val and val not in files:
170
+ files.append(val)
171
+ return files
172
+
173
+
174
+ def _build_body(label: str, payload: dict) -> dict:
175
+ """Assemble the outgoing event dict.
176
+
177
+ Color is intentionally omitted — the monitor derives it client-side from
178
+ the event name.
179
+ """
180
+ body: dict = {
181
+ "event": label,
182
+ "datetime": datetime.now(timezone.utc).isoformat(),
183
+ "session_id": payload.get("session_id"),
184
+ "host": socket.gethostname(),
185
+ }
186
+
187
+ if payload.get("cwd"):
188
+ body["cwd"] = payload["cwd"]
189
+
190
+ files = _extract_files(label, payload)
191
+ if files:
192
+ body["files"] = files
193
+
194
+ if label == "UserPromptSubmit" and payload.get("prompt"):
195
+ body["prompt"] = payload["prompt"][:500]
196
+
197
+ if label == "PostToolUse" and payload.get("tool_name"):
198
+ body["tool_name"] = payload["tool_name"]
199
+
200
+ if label == "Notification" and payload.get("notification_type"):
201
+ body["notification_type"] = payload["notification_type"]
202
+
203
+ if label in ("PermissionRequest", "PermissionDenied", "PreToolUse"):
204
+ if payload.get("tool_name"):
205
+ body["tool_name"] = payload["tool_name"]
206
+ tool_input = payload.get("tool_input") or {}
207
+ requested = (
208
+ tool_input.get("command")
209
+ or tool_input.get("file_path")
210
+ or tool_input.get("notebook_path")
211
+ )
212
+ if requested:
213
+ body["command"] = requested[:300]
214
+
215
+ if label == "StopFailure":
216
+ if payload.get("error_type"):
217
+ body["error_type"] = payload["error_type"]
218
+ if payload.get("error_message"):
219
+ body["error_message"] = payload["error_message"][:500]
220
+
221
+ return {k: v for k, v in body.items() if v is not None}
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Transport
226
+ # ---------------------------------------------------------------------------
227
+
228
+ def _send_http(data: bytes, transport_cfg: dict, log: logging.Logger) -> bool:
229
+ """POST the event as JSON over HTTP. Returns True on success."""
230
+ http = transport_cfg["http"]
231
+ req = urllib.request.Request(
232
+ http["url"], data=data,
233
+ headers={"Content-Type": "application/json"}, method="POST",
234
+ )
235
+ try:
236
+ urllib.request.urlopen(req, timeout=float(http["timeout_s"])).read()
237
+ log.debug("HTTP sent to %s", http["url"])
238
+ return True
239
+ except Exception as exc:
240
+ log.error("HTTP send failed: %s", exc)
241
+ print(f"[claude_hook] HTTP: {exc}", file=sys.stderr)
242
+ return False
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Self-test
247
+ # ---------------------------------------------------------------------------
248
+
249
+ def _run_test(cfg: dict, log: logging.Logger) -> int:
250
+ """Send a synthetic __test__ event and report pass/fail with timing."""
251
+ t = cfg["transport"]
252
+ test_body = _build_body("__test__", {
253
+ "session_id": "test",
254
+ "cwd": str(Path.cwd()),
255
+ "tool_name": "claudehub-hooks --test",
256
+ })
257
+ test_body["test"] = True
258
+ data = json.dumps(test_body).encode("utf-8")
259
+
260
+ results: list[str] = []
261
+ ok = True
262
+
263
+ if t["http"]["enabled"]:
264
+ url = t["http"]["url"]
265
+ t0 = time.monotonic()
266
+ success = _send_http(data, t, log)
267
+ ms = int((time.monotonic() - t0) * 1000)
268
+ status = "OK" if success else "FAIL"
269
+ results.append(f" HTTP {url:<40} {status} {ms}ms")
270
+ if not success:
271
+ ok = False
272
+
273
+ if not results:
274
+ results.append(" (no transports enabled)")
275
+ ok = False
276
+
277
+ print("claude_hook transport test")
278
+ print(f" event: __test__")
279
+ print(f" host: {socket.gethostname()}")
280
+ for line in results:
281
+ print(line)
282
+ print(f"\n{'PASS' if ok else 'FAIL'}")
283
+ return 0 if ok else 1
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Entry point
288
+ # ---------------------------------------------------------------------------
289
+
290
+ def main() -> int:
291
+ """Main dispatcher — invoked by Claude Code hooks."""
292
+ cfg = load_config()
293
+ log = _setup_logging(cfg)
294
+
295
+ if len(sys.argv) > 1 and sys.argv[1] == "--test":
296
+ return _run_test(cfg, log)
297
+
298
+ label = sys.argv[1] if len(sys.argv) > 1 else "Unknown"
299
+
300
+ raw = sys.stdin.read() if not sys.stdin.isatty() else ""
301
+ try:
302
+ payload = json.loads(raw) if raw.strip() else {}
303
+ except (ValueError, TypeError):
304
+ payload = {}
305
+
306
+ body = _build_body(label, payload)
307
+ data = json.dumps(body).encode("utf-8")
308
+
309
+ files_note = f" files={body['files']}" if body.get("files") else ""
310
+ log.info("event=%s session=%s host=%s%s",
311
+ label, body.get("session_id", ""), body.get("host", ""), files_note)
312
+ if label == "StopFailure" and body.get("error_type"):
313
+ log.warning("StopFailure error_type=%s: %s",
314
+ body["error_type"], body.get("error_message", ""))
315
+
316
+ t = cfg["transport"]
317
+ if t["http"]["enabled"]:
318
+ _send_http(data, t, log)
319
+
320
+ return 0
321
+
322
+
323
+ if __name__ == "__main__":
324
+ try:
325
+ code = main()
326
+ except Exception as exc:
327
+ try:
328
+ logging.getLogger("claude_hook").critical("unexpected: %s", exc)
329
+ except Exception:
330
+ pass
331
+ print(f"[claude_hook] unexpected: {exc}", file=sys.stderr)
332
+ code = 0 # never block the harness
333
+ sys.exit(code)
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: claudehub-hooks
3
+ Version: 0.1.0
4
+ Summary: Claude Code hook dispatcher — forwards lifecycle events to Claude Monitor
5
+ Author-email: Pandiyaraj Karuppasamy <pandiyarajk@live.com>
6
+ License-Expression: MIT
7
+ Keywords: claude,claude-code,claudehub,hooks,monitor,dispatcher
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # claudehub-hooks
24
+
25
+ Pip-installable [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hook dispatcher.
26
+ Install once, then wire any repository to a Claude Monitor HTTP
27
+ endpoint in one command — no manual script copying or JSON editing.
28
+
29
+ Events flow: **Claude Code → claudehub-hooks dispatcher → Claude Monitor**.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install claudehub-hooks
35
+ ```
36
+
37
+ Requires Python 3.10+. No third-party runtime dependencies (stdlib only).
38
+
39
+ ## Quick start
40
+
41
+ ```bash
42
+ claudehub-hooks install
43
+ ```
44
+
45
+ Run from a repository root (or pass `--repo`). The command is **idempotent** — safe to re-run.
46
+
47
+ ```bash
48
+ claudehub-hooks install --repo /path/to/repo
49
+ claudehub-hooks install --url http://192.168.1.50:7070/event
50
+ claudehub-hooks install --dry-run
51
+ ```
52
+
53
+ ### What it writes
54
+
55
+ | File | Purpose |
56
+ |------|---------|
57
+ | `.claude/hooks/claude_hook.py` | Hook dispatcher (invoked by Claude Code) |
58
+ | `.claude/hooks/monitor_config.json` | Transport config with the monitor URL |
59
+ | `.claude/settings.json` | Nine hook entries merged in (existing entries kept) |
60
+
61
+ ## Change the monitor URL
62
+
63
+ Edit `.claude/hooks/monitor_config.json` in the repo:
64
+
65
+ ```json
66
+ {
67
+ "transport": {
68
+ "http": { "url": "http://192.168.1.50:7070/event" }
69
+ }
70
+ }
71
+ ```
72
+
73
+ Or set an environment variable (overrides the file):
74
+
75
+ ```bash
76
+ export CLAUDE_HUB_URL="http://192.168.1.50:7070/event"
77
+ ```
78
+
79
+ On Windows (persistent):
80
+
81
+ ```bat
82
+ setx CLAUDE_HUB_URL "http://192.168.1.50:7070/event"
83
+ ```
84
+
85
+ ## Test connectivity
86
+
87
+ ```bash
88
+ python .claude/hooks/claude_hook.py --test
89
+ ```
90
+
91
+ ## Hooks installed
92
+
93
+ | Event | Trigger |
94
+ |-------|---------|
95
+ | `SessionStart` | Claude session begins |
96
+ | `UserPromptSubmit` | User sends a message |
97
+ | `Notification` | Claude needs input |
98
+ | `PostToolUse` | File read/edited (Edit · Write · NotebookEdit · MultiEdit · Read) |
99
+ | `Stop` | Turn completes |
100
+ | `StopFailure` | API error |
101
+ | `PermissionRequest` | Allow/deny dialog shown |
102
+ | `PermissionDenied` | User denied a tool |
103
+ | `PreToolUse` | Tool about to run (resolves permission) |
104
+
105
+ ## Configuration
106
+
107
+ | Variable | Default | Meaning |
108
+ |----------|---------|---------|
109
+ | `CLAUDE_HUB_URL` | `http://127.0.0.1:7070/event` | Monitor endpoint |
110
+ | `CLAUDE_HUB_TIMEOUT` | `2.0` | HTTP timeout (seconds) |
111
+ | `CLAUDE_HUB_HTTP_ENABLED` | `true` | Set to `false` to silence all hooks |
112
+ | `CLAUDE_HOOK_LOG_LEVEL` | `INFO` | Dispatcher log level |
113
+ | `CLAUDE_HOOK_LOG_FILE` | `~/.claude/claude_hook.log` | Override log path |
114
+ | `CLAUDE_HUB_CONFIG` | — | Path to a `monitor_config.json` override |
115
+
116
+ ## License
117
+
118
+ MIT — see [LICENSE](LICENSE).
119
+
120
+ **Author:** Pandiyaraj Karuppasamy · pandiyarajk@live.com
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/claudehub_hooks/__init__.py
5
+ src/claudehub_hooks/_settings.py
6
+ src/claudehub_hooks/cli.py
7
+ src/claudehub_hooks/hook.py
8
+ src/claudehub_hooks.egg-info/PKG-INFO
9
+ src/claudehub_hooks.egg-info/SOURCES.txt
10
+ src/claudehub_hooks.egg-info/dependency_links.txt
11
+ src/claudehub_hooks.egg-info/entry_points.txt
12
+ src/claudehub_hooks.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ claudehub-hooks = claudehub_hooks.cli:main
@@ -0,0 +1 @@
1
+ claudehub_hooks