harness-hooks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: harness-hooks
3
+ Version: 0.1.0
4
+ Summary: Installs the hooks that a config file lists into Claude Code and Codex, and removes them
5
+ Project-URL: Homepage, https://github.com/a-pertsev/harness-hooks
6
+ Author: Alexei Pertsev
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: claude-code,codex,hooks
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # harness-hooks
18
+
19
+ Installs the hooks that a config file lists into Claude Code and Codex, and removes them.
20
+ Works on macOS and Linux.
21
+
22
+ Run it with [uv](https://docs.astral.sh/uv/):
23
+
24
+ ```sh
25
+ uvx harness-hooks install path/to/harness-hooks.json
26
+ uvx harness-hooks remove path/to/harness-hooks.json
27
+ ```
28
+
29
+ Or install it once, which puts `harness-hooks` on your `PATH`:
30
+
31
+ ```sh
32
+ uv tool install harness-hooks
33
+ ```
34
+
35
+ ## Config
36
+
37
+ ```json
38
+ {
39
+ "name": "notify",
40
+ "files": ["notify.py"],
41
+ "hooks": {
42
+ "Stop": [{"command": "python3 {dir}/notify.py {agent}", "timeout": 5}]
43
+ },
44
+ "claude": {
45
+ "Notification": [{"matcher": "idle_prompt", "command": "python3 {dir}/notify.py claude"}]
46
+ },
47
+ "codex": {
48
+ "PreToolUse": [{"matcher": "^request_user_input", "command": "python3 {dir}/notify.py codex"}]
49
+ }
50
+ }
51
+ ```
52
+
53
+ - `hooks` go to both agents, `claude` and `codex` only to that agent.
54
+ - `{dir}` becomes the folder with the hooks' files, so hooks can call scripts there.
55
+ `{agent}` becomes `claude` or `codex`.
56
+ - `matcher` and `command` mean what they mean in each agent's own hook files. Other
57
+ fields, such as `timeout`, are copied as they are.
58
+ - `files`, if given, lists the files the hooks need, as paths inside the config's folder.
59
+
60
+ ## Copies
61
+
62
+ With `files` in the config, `install` copies those files to `~/.claude/harness-hooks/<name>/`
63
+ and `~/.codex/harness-hooks/<name>/`, and `{dir}` points there. The hooks keep working when
64
+ the config's folder moves or is deleted. Run `install` again to bring in changed files;
65
+ `remove` deletes the copies.
66
+
67
+ Without `files`, or with `--editable`, `{dir}` is the config's folder, so an edit takes
68
+ effect at once. `--editable` also deletes copies left by an earlier `install`. `--project`
69
+ never copies.
70
+
71
+ ## Where hooks go
72
+
73
+ By default into `~/.claude/settings.json` and `~/.codex/hooks.json`, so they run in every
74
+ project. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` move these files, as they do for the agents.
75
+
76
+ With `--project`, into `.claude/settings.json` and `.codex/hooks.json` of the git repository
77
+ that holds the config. Commit them, and everyone who clones the repository gets the hooks.
78
+ `{dir}` is then found from the repository root, so the hooks work in any clone.
79
+ Claude reads them only in sessions started at the repository root. Codex reads them only
80
+ after you trust the project.
81
+
82
+ `--agent claude` or `--agent codex` touches only that agent.
83
+
84
+ ## How it finds its hooks
85
+
86
+ Each installed command ends with `# harness-hooks: <name>`. `install` replaces the commands
87
+ with that mark and `remove` deletes them, so a hook dropped from the config disappears on
88
+ the next run. Keep `name` the same once installed. It is also the copies' folder name, so it
89
+ can't hold `/`. Hooks without the mark, such as ones you added by hand, are left alone, and
90
+ the rest of each file is kept.
91
+
92
+ Codex asks you to review new or changed hooks at its next start. It remembers approvals
93
+ by a hook's place in the file, so removing hooks can make it ask again about the hooks
94
+ after them.
95
+
96
+ ## Development
97
+
98
+ From a clone, `uv tool install --editable .` runs the code in the clone, so an edit takes
99
+ effect at once.
100
+
101
+ ```sh
102
+ python3 -m unittest
103
+ ```
@@ -0,0 +1,6 @@
1
+ harness_hooks.py,sha256=JS9RzEswLu2KgrwRrgbMli2GeCMA_IHWwFren3zfOO8,6609
2
+ harness_hooks-0.1.0.dist-info/METADATA,sha256=QgsDWbBpxm3MCzlM1druiYX2poCawouPFfr3G2jfhpY,3700
3
+ harness_hooks-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
4
+ harness_hooks-0.1.0.dist-info/entry_points.txt,sha256=iLa8DhRb2ZMMLr-3ASdIPw6xXBJMma0Mj8k2KcqpFIo,53
5
+ harness_hooks-0.1.0.dist-info/licenses/LICENSE,sha256=p8oUZNv5Fjrsv9qd4y8QH-fjOrvAX_kfR9Z_X6d4BNA,1071
6
+ harness_hooks-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ harness-hooks = harness_hooks:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexei Pertsev
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.
harness_hooks.py ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """Installs the hooks that a config file lists into Claude Code and Codex, and removes them.
3
+
4
+ Usage: harness-hooks install|remove CONFIG [--project] [--editable] [--agent claude|codex]
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+
17
+ AGENTS = ("claude", "codex")
18
+ PROJECT_FILES = {"claude": ".claude/settings.json", "codex": ".codex/hooks.json"}
19
+ # Codex runs a hook in the folder the session started in, which can be below the project root.
20
+ PROJECT_ROOT = '"$(git rev-parse --show-toplevel)"'
21
+
22
+
23
+ def hook_file(agent, project):
24
+ if project:
25
+ return project / PROJECT_FILES[agent]
26
+ if agent == "claude":
27
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) / "settings.json"
28
+ return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) / "hooks.json"
29
+
30
+
31
+ def git_root(folder):
32
+ result = subprocess.run(["git", "-C", str(folder), "rev-parse", "--show-toplevel"], capture_output=True, text=True)
33
+ if result.returncode:
34
+ sys.exit(f"--project needs the config inside a git repository: {result.stderr.strip()}")
35
+ return Path(result.stdout.strip())
36
+
37
+
38
+ def config_dir(folder, project):
39
+ if not project:
40
+ return shlex.quote(str(folder))
41
+ rel = folder.relative_to(project)
42
+ return PROJECT_ROOT + ("" if rel == Path() else "/" + shlex.quote(str(rel)))
43
+
44
+
45
+ def build(config, agent, folder, tag):
46
+ events = {}
47
+ for block in (config.get("hooks", {}), config.get(agent, {})):
48
+ for event, entries in block.items():
49
+ for entry in entries:
50
+ handler = {"type": "command", **entry}
51
+ matcher = handler.pop("matcher", None)
52
+ handler["command"] = handler["command"].replace("{dir}", folder).replace("{agent}", agent) + tag
53
+ group = {} if matcher is None else {"matcher": matcher}
54
+ group["hooks"] = [handler]
55
+ events.setdefault(event, []).append(group)
56
+ return events
57
+
58
+
59
+ def strip(hooks, tag):
60
+ kept = {}
61
+ for event, groups in hooks.items():
62
+ groups = [{**g, "hooks": [h for h in g["hooks"] if not h.get("command", "").endswith(tag)]} for g in groups]
63
+ groups = [g for g in groups if g["hooks"]]
64
+ if groups:
65
+ kept[event] = groups
66
+ return kept
67
+
68
+
69
+ def write(path, text):
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
72
+ with os.fdopen(fd, "w") as f:
73
+ f.write(text)
74
+ if path.exists():
75
+ shutil.copymode(path, tmp)
76
+ os.replace(tmp, path)
77
+
78
+
79
+ def copy(folder, files, target):
80
+ """Builds the copy aside and swaps it in, so a hook never runs from a half-copied folder."""
81
+ target.parent.mkdir(parents=True, exist_ok=True)
82
+ fresh = Path(tempfile.mkdtemp(dir=target.parent))
83
+ for name in files:
84
+ (fresh / name).parent.mkdir(parents=True, exist_ok=True)
85
+ shutil.copy2(folder / name, fresh / name)
86
+ old = fresh.with_suffix(".old")
87
+ if target.exists():
88
+ os.rename(target, old)
89
+ os.rename(fresh, target)
90
+ shutil.rmtree(old, ignore_errors=True)
91
+
92
+
93
+ def update(path, tag, new):
94
+ """Replaces the hooks carrying the tag with the new ones; returns whether the file changed."""
95
+ # Writing through a symlink keeps a settings file that lives elsewhere, such as in dotfiles.
96
+ path = path.resolve()
97
+ data = json.loads(path.read_text()) if path.exists() else {}
98
+ old = data.get("hooks", {})
99
+ hooks = strip(old, tag)
100
+ for event, groups in new.items():
101
+ hooks.setdefault(event, []).extend(groups)
102
+ if hooks == old:
103
+ return False
104
+ if hooks:
105
+ data["hooks"] = hooks
106
+ else:
107
+ data.pop("hooks", None)
108
+ write(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
109
+ return True
110
+
111
+
112
+ def main():
113
+ parser = argparse.ArgumentParser(description="Installs the hooks a config file lists into Claude Code and Codex, and removes them.")
114
+ parser.add_argument("action", choices=["install", "remove"])
115
+ parser.add_argument("config", type=Path)
116
+ parser.add_argument("--project", action="store_true", help="use the hook files of the git repository that holds the config, for everyone who clones it")
117
+ parser.add_argument("--editable", action="store_true", help="run hooks from the config's folder instead of a copy of its files, so edits take effect at once")
118
+ parser.add_argument("--agent", choices=AGENTS, help="only this agent")
119
+ args = parser.parse_args()
120
+
121
+ path = args.config.resolve()
122
+ config = json.loads(path.read_text())
123
+ if "name" not in config or set(config) - {"name", "files", "hooks", *AGENTS}:
124
+ sys.exit(f"{path}: needs a name, and allows only files, hooks, claude and codex besides it")
125
+ name = config["name"]
126
+ # The name is also a folder that remove deletes, so it must not lead out of its parent.
127
+ if name in ("", ".", "..") or "/" in name:
128
+ sys.exit(f"{path}: name must be a plain folder name")
129
+ files = config.get("files", [])
130
+ if args.action == "install":
131
+ for file in files:
132
+ if Path(file).is_absolute() or ".." in Path(file).parts or not (path.parent / file).is_file():
133
+ sys.exit(f"{path}: {file} is not a file in {path.parent}")
134
+ project = git_root(path.parent) if args.project else None
135
+ copying = args.action == "install" and bool(files) and not args.editable and not project
136
+ # Marks every installed command, so remove finds it even after the config changed.
137
+ tag = f" # harness-hooks: {name}"
138
+ folder = config_dir(path.parent, project)
139
+ for agent in [args.agent] if args.agent else AGENTS:
140
+ target = hook_file(agent, project)
141
+ # Next to the agent's own settings, so the hooks keep working after the config's folder moves.
142
+ copy_dir = target.parent / "harness-hooks" / name
143
+ if copying:
144
+ copy(path.parent, files, copy_dir)
145
+ print(f"{agent}: copied {', '.join(files)} to {copy_dir}")
146
+ hooks = build(config, agent, shlex.quote(str(copy_dir)) if copying else folder, tag) if args.action == "install" else {}
147
+ changed = update(target, tag, hooks)
148
+ print(f"{agent}: {'updated' if changed else 'unchanged'} {target}")
149
+ if not copying and not project and copy_dir.exists():
150
+ shutil.rmtree(copy_dir)
151
+ print(f"{agent}: removed {copy_dir}")
152
+ if agent == "codex" and changed and hooks:
153
+ print("codex: asks you to review the new hooks at its next start")
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()