harness-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.
- harness_hooks-0.1.0/.gitignore +2 -0
- harness_hooks-0.1.0/LICENSE +21 -0
- harness_hooks-0.1.0/PKG-INFO +103 -0
- harness_hooks-0.1.0/README.md +87 -0
- harness_hooks-0.1.0/harness_hooks.py +157 -0
- harness_hooks-0.1.0/pyproject.toml +30 -0
- harness_hooks-0.1.0/test_harness_hooks.py +222 -0
|
@@ -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.
|
|
@@ -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,87 @@
|
|
|
1
|
+
# harness-hooks
|
|
2
|
+
|
|
3
|
+
Installs the hooks that a config file lists into Claude Code and Codex, and removes them.
|
|
4
|
+
Works on macOS and Linux.
|
|
5
|
+
|
|
6
|
+
Run it with [uv](https://docs.astral.sh/uv/):
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
uvx harness-hooks install path/to/harness-hooks.json
|
|
10
|
+
uvx harness-hooks remove path/to/harness-hooks.json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install it once, which puts `harness-hooks` on your `PATH`:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
uv tool install harness-hooks
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Config
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"name": "notify",
|
|
24
|
+
"files": ["notify.py"],
|
|
25
|
+
"hooks": {
|
|
26
|
+
"Stop": [{"command": "python3 {dir}/notify.py {agent}", "timeout": 5}]
|
|
27
|
+
},
|
|
28
|
+
"claude": {
|
|
29
|
+
"Notification": [{"matcher": "idle_prompt", "command": "python3 {dir}/notify.py claude"}]
|
|
30
|
+
},
|
|
31
|
+
"codex": {
|
|
32
|
+
"PreToolUse": [{"matcher": "^request_user_input", "command": "python3 {dir}/notify.py codex"}]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `hooks` go to both agents, `claude` and `codex` only to that agent.
|
|
38
|
+
- `{dir}` becomes the folder with the hooks' files, so hooks can call scripts there.
|
|
39
|
+
`{agent}` becomes `claude` or `codex`.
|
|
40
|
+
- `matcher` and `command` mean what they mean in each agent's own hook files. Other
|
|
41
|
+
fields, such as `timeout`, are copied as they are.
|
|
42
|
+
- `files`, if given, lists the files the hooks need, as paths inside the config's folder.
|
|
43
|
+
|
|
44
|
+
## Copies
|
|
45
|
+
|
|
46
|
+
With `files` in the config, `install` copies those files to `~/.claude/harness-hooks/<name>/`
|
|
47
|
+
and `~/.codex/harness-hooks/<name>/`, and `{dir}` points there. The hooks keep working when
|
|
48
|
+
the config's folder moves or is deleted. Run `install` again to bring in changed files;
|
|
49
|
+
`remove` deletes the copies.
|
|
50
|
+
|
|
51
|
+
Without `files`, or with `--editable`, `{dir}` is the config's folder, so an edit takes
|
|
52
|
+
effect at once. `--editable` also deletes copies left by an earlier `install`. `--project`
|
|
53
|
+
never copies.
|
|
54
|
+
|
|
55
|
+
## Where hooks go
|
|
56
|
+
|
|
57
|
+
By default into `~/.claude/settings.json` and `~/.codex/hooks.json`, so they run in every
|
|
58
|
+
project. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` move these files, as they do for the agents.
|
|
59
|
+
|
|
60
|
+
With `--project`, into `.claude/settings.json` and `.codex/hooks.json` of the git repository
|
|
61
|
+
that holds the config. Commit them, and everyone who clones the repository gets the hooks.
|
|
62
|
+
`{dir}` is then found from the repository root, so the hooks work in any clone.
|
|
63
|
+
Claude reads them only in sessions started at the repository root. Codex reads them only
|
|
64
|
+
after you trust the project.
|
|
65
|
+
|
|
66
|
+
`--agent claude` or `--agent codex` touches only that agent.
|
|
67
|
+
|
|
68
|
+
## How it finds its hooks
|
|
69
|
+
|
|
70
|
+
Each installed command ends with `# harness-hooks: <name>`. `install` replaces the commands
|
|
71
|
+
with that mark and `remove` deletes them, so a hook dropped from the config disappears on
|
|
72
|
+
the next run. Keep `name` the same once installed. It is also the copies' folder name, so it
|
|
73
|
+
can't hold `/`. Hooks without the mark, such as ones you added by hand, are left alone, and
|
|
74
|
+
the rest of each file is kept.
|
|
75
|
+
|
|
76
|
+
Codex asks you to review new or changed hooks at its next start. It remembers approvals
|
|
77
|
+
by a hook's place in the file, so removing hooks can make it ask again about the hooks
|
|
78
|
+
after them.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
From a clone, `uv tool install --editable .` runs the code in the clone, so an edit takes
|
|
83
|
+
effect at once.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
python3 -m unittest
|
|
87
|
+
```
|
|
@@ -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()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "harness-hooks"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Installs the hooks that a config file lists into Claude Code and Codex, and removes them"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{name = "Alexei Pertsev"}]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["claude-code", "codex", "hooks"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/a-pertsev/harness-hooks"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
harness-hooks = "harness_hooks:main"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
only-include = ["harness_hooks.py"]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
TOOL = Path(__file__).with_name("harness_hooks.py")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HarnessHooksTest(unittest.TestCase):
|
|
14
|
+
def setUp(self):
|
|
15
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
16
|
+
self.root = root = Path(self.tmp.name).resolve()
|
|
17
|
+
self.repo = root / "repo"
|
|
18
|
+
self.tools = self.repo / "tools"
|
|
19
|
+
self.tools.mkdir(parents=True)
|
|
20
|
+
# Stands in for a hook script: records how it was called next to itself.
|
|
21
|
+
script = self.tools / "log.sh"
|
|
22
|
+
script.write_text('#!/bin/sh\necho "$@" >> "$(dirname "$0")/log"\n')
|
|
23
|
+
script.chmod(0o755)
|
|
24
|
+
self.config = self.tools / "harness-hooks.json"
|
|
25
|
+
self.write_config({
|
|
26
|
+
"name": "demo",
|
|
27
|
+
"hooks": {"Stop": [{"command": "{dir}/log.sh {agent} Stop", "timeout": 5}]},
|
|
28
|
+
"claude": {"PreToolUse": [{"matcher": "AskUserQuestion", "command": "{dir}/log.sh claude ask"}]},
|
|
29
|
+
"codex": {"PreToolUse": [{"matcher": "^request_user_input", "command": "{dir}/log.sh codex ask"}]},
|
|
30
|
+
})
|
|
31
|
+
self.claude_file = root / "claude" / "settings.json"
|
|
32
|
+
self.codex_file = root / "codex" / "hooks.json"
|
|
33
|
+
self.env = {**os.environ, "CLAUDE_CONFIG_DIR": str(root / "claude"), "CODEX_HOME": str(root / "codex")}
|
|
34
|
+
|
|
35
|
+
def tearDown(self):
|
|
36
|
+
self.tmp.cleanup()
|
|
37
|
+
|
|
38
|
+
def write_config(self, config):
|
|
39
|
+
self.config.write_text(json.dumps(config))
|
|
40
|
+
|
|
41
|
+
def run_tool(self, *args, check=True):
|
|
42
|
+
return subprocess.run([sys.executable, TOOL, *args], text=True, capture_output=True, env=self.env, check=check)
|
|
43
|
+
|
|
44
|
+
def commands(self, path):
|
|
45
|
+
hooks = json.loads(path.read_text())["hooks"]
|
|
46
|
+
return {event: [(g.get("matcher"), h["command"]) for g in groups for h in g["hooks"]] for event, groups in hooks.items()}
|
|
47
|
+
|
|
48
|
+
def run_hook(self, command, cwd, log, env=None):
|
|
49
|
+
subprocess.run(["/bin/sh", "-c", command], cwd=cwd, env={**os.environ, **(env or {})}, check=True)
|
|
50
|
+
return log.read_text().splitlines()[-1]
|
|
51
|
+
|
|
52
|
+
def copy_dir(self, agent):
|
|
53
|
+
return self.root / agent / "harness-hooks" / "demo"
|
|
54
|
+
|
|
55
|
+
def write_copied_config(self, files):
|
|
56
|
+
self.write_config({"name": "demo", "files": files, "hooks": {"Stop": [{"command": "{dir}/log.sh {agent} Stop"}]}})
|
|
57
|
+
|
|
58
|
+
def test_install_adds_shared_and_harness_hooks_that_run(self):
|
|
59
|
+
self.run_tool("install", str(self.config))
|
|
60
|
+
|
|
61
|
+
log = self.tools / "log"
|
|
62
|
+
for path, agent, matcher in [(self.claude_file, "claude", "AskUserQuestion"), (self.codex_file, "codex", "^request_user_input")]:
|
|
63
|
+
[(stop_matcher, stop)] = self.commands(path)["Stop"]
|
|
64
|
+
[(ask_matcher, ask)] = self.commands(path)["PreToolUse"]
|
|
65
|
+
self.assertIsNone(stop_matcher)
|
|
66
|
+
self.assertEqual(ask_matcher, matcher)
|
|
67
|
+
self.assertEqual(self.run_hook(stop, "/", log), f"{agent} Stop")
|
|
68
|
+
self.assertEqual(self.run_hook(ask, "/", log), f"{agent} ask")
|
|
69
|
+
self.assertEqual(json.loads(self.claude_file.read_text())["hooks"]["Stop"][0]["hooks"][0]["timeout"], 5)
|
|
70
|
+
|
|
71
|
+
def test_install_keeps_other_settings_and_hooks_and_repeats_without_changes(self):
|
|
72
|
+
foreign = {"hooks": [{"type": "command", "command": "notify.sh"}]}
|
|
73
|
+
self.claude_file.parent.mkdir()
|
|
74
|
+
self.claude_file.write_text(json.dumps({"model": "opus", "hooks": {"Stop": [foreign]}}))
|
|
75
|
+
|
|
76
|
+
self.run_tool("install", str(self.config))
|
|
77
|
+
first = self.claude_file.read_text()
|
|
78
|
+
out = self.run_tool("install", str(self.config)).stdout
|
|
79
|
+
|
|
80
|
+
self.assertEqual(self.claude_file.read_text(), first)
|
|
81
|
+
self.assertIn("claude: unchanged", out)
|
|
82
|
+
settings = json.loads(first)
|
|
83
|
+
self.assertEqual(settings["model"], "opus")
|
|
84
|
+
self.assertEqual(settings["hooks"]["Stop"][0], foreign)
|
|
85
|
+
self.assertEqual(len(settings["hooks"]["Stop"]), 2)
|
|
86
|
+
|
|
87
|
+
def test_install_replaces_hooks_of_an_earlier_config(self):
|
|
88
|
+
self.run_tool("install", str(self.config))
|
|
89
|
+
self.write_config({"name": "demo", "hooks": {"Stop": [{"command": "{dir}/log.sh new"}]}})
|
|
90
|
+
|
|
91
|
+
self.run_tool("install", str(self.config))
|
|
92
|
+
|
|
93
|
+
commands = self.commands(self.claude_file)
|
|
94
|
+
self.assertEqual(list(commands), ["Stop"])
|
|
95
|
+
[(_, stop)] = commands["Stop"]
|
|
96
|
+
self.assertEqual(self.run_hook(stop, "/", self.tools / "log"), "new")
|
|
97
|
+
|
|
98
|
+
def test_remove_takes_out_only_installed_hooks(self):
|
|
99
|
+
settings = {"model": "opus", "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "notify.sh"}]}]}}
|
|
100
|
+
self.claude_file.parent.mkdir()
|
|
101
|
+
self.claude_file.write_text(json.dumps(settings))
|
|
102
|
+
self.run_tool("install", str(self.config))
|
|
103
|
+
self.write_config({"name": "demo"})
|
|
104
|
+
|
|
105
|
+
self.run_tool("remove", str(self.config))
|
|
106
|
+
|
|
107
|
+
self.assertEqual(json.loads(self.claude_file.read_text()), settings)
|
|
108
|
+
self.assertEqual(json.loads(self.codex_file.read_text()), {})
|
|
109
|
+
|
|
110
|
+
def test_remove_without_install_writes_nothing(self):
|
|
111
|
+
out = self.run_tool("remove", str(self.config)).stdout
|
|
112
|
+
|
|
113
|
+
self.assertIn("claude: unchanged", out)
|
|
114
|
+
self.assertFalse(self.claude_file.exists())
|
|
115
|
+
self.assertFalse(self.codex_file.exists())
|
|
116
|
+
|
|
117
|
+
def test_install_copies_listed_files_so_hooks_survive_the_config_folder(self):
|
|
118
|
+
self.write_copied_config(["log.sh"])
|
|
119
|
+
|
|
120
|
+
self.run_tool("install", str(self.config))
|
|
121
|
+
shutil.rmtree(self.repo)
|
|
122
|
+
|
|
123
|
+
for path, agent in [(self.claude_file, "claude"), (self.codex_file, "codex")]:
|
|
124
|
+
[(_, stop)] = self.commands(path)["Stop"]
|
|
125
|
+
self.assertEqual(self.run_hook(stop, "/", self.copy_dir(agent) / "log"), f"{agent} Stop")
|
|
126
|
+
|
|
127
|
+
def test_install_again_replaces_the_copy(self):
|
|
128
|
+
(self.tools / "notes.txt").write_text("old")
|
|
129
|
+
self.write_copied_config(["log.sh", "notes.txt"])
|
|
130
|
+
self.run_tool("install", str(self.config))
|
|
131
|
+
(self.tools / "log.sh").write_text('#!/bin/sh\necho new "$@" >> "$(dirname "$0")/log"\n')
|
|
132
|
+
self.write_copied_config(["log.sh"])
|
|
133
|
+
|
|
134
|
+
self.run_tool("install", str(self.config))
|
|
135
|
+
|
|
136
|
+
[(_, stop)] = self.commands(self.claude_file)["Stop"]
|
|
137
|
+
self.assertEqual(self.run_hook(stop, "/", self.copy_dir("claude") / "log"), "new claude Stop")
|
|
138
|
+
self.assertEqual(sorted(p.name for p in self.copy_dir("claude").parent.iterdir()), ["demo"])
|
|
139
|
+
self.assertFalse((self.copy_dir("claude") / "notes.txt").exists())
|
|
140
|
+
|
|
141
|
+
def test_remove_deletes_the_copy(self):
|
|
142
|
+
self.write_copied_config(["log.sh"])
|
|
143
|
+
self.run_tool("install", str(self.config))
|
|
144
|
+
|
|
145
|
+
self.run_tool("remove", str(self.config))
|
|
146
|
+
|
|
147
|
+
self.assertEqual(json.loads(self.claude_file.read_text()), {})
|
|
148
|
+
self.assertFalse(self.copy_dir("claude").exists())
|
|
149
|
+
self.assertFalse(self.copy_dir("codex").exists())
|
|
150
|
+
|
|
151
|
+
def test_editable_install_runs_from_the_config_folder_and_drops_an_earlier_copy(self):
|
|
152
|
+
self.write_copied_config(["log.sh"])
|
|
153
|
+
self.run_tool("install", str(self.config))
|
|
154
|
+
|
|
155
|
+
self.run_tool("install", str(self.config), "--editable")
|
|
156
|
+
|
|
157
|
+
self.assertFalse(self.copy_dir("claude").exists())
|
|
158
|
+
[(_, stop)] = self.commands(self.claude_file)["Stop"]
|
|
159
|
+
self.assertEqual(self.run_hook(stop, "/", self.tools / "log"), "claude Stop")
|
|
160
|
+
|
|
161
|
+
def test_file_that_is_not_in_the_config_folder_is_refused(self):
|
|
162
|
+
for file in ["missing.sh", "../tools/log.sh", str(self.tools / "log.sh")]:
|
|
163
|
+
with self.subTest(file=file):
|
|
164
|
+
self.write_copied_config([file])
|
|
165
|
+
|
|
166
|
+
result = self.run_tool("install", str(self.config), check=False)
|
|
167
|
+
|
|
168
|
+
self.assertNotEqual(result.returncode, 0)
|
|
169
|
+
self.assertIn("is not a file", result.stderr)
|
|
170
|
+
self.assertFalse(self.claude_file.exists())
|
|
171
|
+
|
|
172
|
+
def test_name_that_leads_out_of_its_folder_is_refused(self):
|
|
173
|
+
# With this folder present, the copy folder of a name ".." is the agent's whole home.
|
|
174
|
+
(self.root / "claude" / "harness-hooks").mkdir(parents=True)
|
|
175
|
+
self.claude_file.write_text("{}")
|
|
176
|
+
self.write_config({"name": ".."})
|
|
177
|
+
|
|
178
|
+
result = self.run_tool("remove", str(self.config), check=False)
|
|
179
|
+
|
|
180
|
+
self.assertNotEqual(result.returncode, 0)
|
|
181
|
+
self.assertTrue(self.claude_file.exists())
|
|
182
|
+
|
|
183
|
+
def test_project_install_runs_scripts_in_a_clone_from_any_of_its_folders(self):
|
|
184
|
+
subprocess.run(["git", "init", "-q", str(self.repo)], check=True)
|
|
185
|
+
self.write_config({**json.loads(self.config.read_text()), "files": ["log.sh"]})
|
|
186
|
+
|
|
187
|
+
self.run_tool("install", str(self.config), "--project")
|
|
188
|
+
|
|
189
|
+
self.assertFalse(self.claude_file.parent.exists())
|
|
190
|
+
self.assertFalse(self.codex_file.parent.exists())
|
|
191
|
+
clone = Path(self.tmp.name).resolve() / "clone"
|
|
192
|
+
shutil.copytree(self.repo, clone)
|
|
193
|
+
(clone / "sub").mkdir()
|
|
194
|
+
log = clone / "tools" / "log"
|
|
195
|
+
[(_, claude_stop)] = self.commands(clone / ".claude/settings.json")["Stop"]
|
|
196
|
+
[(_, codex_stop)] = self.commands(clone / ".codex/hooks.json")["Stop"]
|
|
197
|
+
self.assertEqual(self.run_hook(claude_stop, clone / "sub", log), "claude Stop")
|
|
198
|
+
self.assertEqual(self.run_hook(codex_stop, clone / "sub", log), "codex Stop")
|
|
199
|
+
|
|
200
|
+
def test_project_install_outside_git_is_refused(self):
|
|
201
|
+
result = self.run_tool("install", str(self.config), "--project", check=False)
|
|
202
|
+
|
|
203
|
+
self.assertNotEqual(result.returncode, 0)
|
|
204
|
+
self.assertIn("git repository", result.stderr)
|
|
205
|
+
|
|
206
|
+
def test_agent_option_installs_into_that_agent_only(self):
|
|
207
|
+
self.run_tool("install", str(self.config), "--agent", "codex")
|
|
208
|
+
|
|
209
|
+
self.assertFalse(self.claude_file.exists())
|
|
210
|
+
self.assertIn("Stop", self.commands(self.codex_file))
|
|
211
|
+
|
|
212
|
+
def test_config_without_name_is_refused(self):
|
|
213
|
+
self.write_config({"hooks": {"Stop": [{"command": "x"}]}})
|
|
214
|
+
|
|
215
|
+
result = self.run_tool("install", str(self.config), check=False)
|
|
216
|
+
|
|
217
|
+
self.assertNotEqual(result.returncode, 0)
|
|
218
|
+
self.assertFalse(self.claude_file.exists())
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
unittest.main()
|