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.
- claudehub_hooks-0.1.0/LICENSE +21 -0
- claudehub_hooks-0.1.0/PKG-INFO +120 -0
- claudehub_hooks-0.1.0/README.md +98 -0
- claudehub_hooks-0.1.0/pyproject.toml +36 -0
- claudehub_hooks-0.1.0/setup.cfg +4 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks/__init__.py +4 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks/_settings.py +63 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks/cli.py +189 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks/hook.py +333 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks.egg-info/PKG-INFO +120 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks.egg-info/SOURCES.txt +12 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks.egg-info/dependency_links.txt +1 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks.egg-info/entry_points.txt +2 -0
- claudehub_hooks-0.1.0/src/claudehub_hooks.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
claudehub_hooks
|