obey-cli 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.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""JSON-preserving, idempotent merges of obey's hook entries into agents' settings files.
|
|
2
|
+
|
|
3
|
+
obey's entries are recognised by their command, which always runs `.obey/hooks/obey_hook.py`, so no extra marker
|
|
4
|
+
key is needed. Merging replaces obey's previous entries and keeps every other key and entry in its original order.
|
|
5
|
+
Removing deletes only obey's entries; if what remains equals the backed-up original, the original bytes come back.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from obey_cli.config import ConfigError
|
|
17
|
+
|
|
18
|
+
MARKER = ".obey/hooks/obey_hook.py"
|
|
19
|
+
BACKUP_DIR = ".obey/backup"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Commands obey generates end with the agent name (and "stop"); a user's hook that only mentions the script is kept.
|
|
23
|
+
_OBEY_COMMAND = re.compile(r"\.obey/hooks/obey_hook\.py\b.*\b(?:claude|codex|cursor|gemini|copilot)(?: stop)?\s*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_obey_entry(entry: Any) -> bool:
|
|
27
|
+
if not isinstance(entry, dict):
|
|
28
|
+
return False
|
|
29
|
+
if any(isinstance(entry.get(k), str) and _OBEY_COMMAND.search(entry[k]) for k in ("command", "bash")):
|
|
30
|
+
return True
|
|
31
|
+
return isinstance(entry.get("hooks"), list) and any(is_obey_entry(h) for h in entry["hooks"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip(value: Any) -> Any:
|
|
35
|
+
"""Remove obey entries from lists of hook entries, dropping containers that become empty."""
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
kept = [_strip(v) for v in value if not is_obey_entry(v) or _has_foreign_hooks(v)]
|
|
38
|
+
return [v for v in kept if v not in ({}, [])]
|
|
39
|
+
if isinstance(value, dict):
|
|
40
|
+
out = {}
|
|
41
|
+
for k, v in value.items():
|
|
42
|
+
if isinstance(v, list | dict):
|
|
43
|
+
stripped = _strip(v)
|
|
44
|
+
if stripped in ({}, []) and v not in ({}, []):
|
|
45
|
+
continue
|
|
46
|
+
out[k] = stripped
|
|
47
|
+
else:
|
|
48
|
+
out[k] = v
|
|
49
|
+
return out
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _has_foreign_hooks(entry: Any) -> bool:
|
|
54
|
+
# A matcher group shared with the user's own hooks keeps the user's hooks.
|
|
55
|
+
if isinstance(entry, dict) and isinstance(entry.get("hooks"), list):
|
|
56
|
+
return any(not is_obey_entry(h) for h in entry["hooks"])
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def remove_obey(data: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
if isinstance(data, dict) and isinstance(data.get("hooks"), dict | list):
|
|
62
|
+
hooks = _strip(data["hooks"])
|
|
63
|
+
if isinstance(data["hooks"], list) and isinstance(hooks, list):
|
|
64
|
+
hooks = [h for h in hooks if not is_obey_entry(h)]
|
|
65
|
+
out: dict[str, Any] = dict(data)
|
|
66
|
+
if hooks in ({}, []):
|
|
67
|
+
out.pop("hooks")
|
|
68
|
+
else:
|
|
69
|
+
out["hooks"] = hooks
|
|
70
|
+
return out
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def add_hooks(data: dict[str, Any], hooks: Mapping[str, list[Any]]) -> dict[str, Any]:
|
|
75
|
+
"""Append obey's entries under data["hooks"][event] after removing any previous obey entries."""
|
|
76
|
+
clean = remove_obey(data)
|
|
77
|
+
out = dict(clean)
|
|
78
|
+
current = out.get("hooks")
|
|
79
|
+
merged: dict[str, Any] = dict(current) if isinstance(current, dict) else {}
|
|
80
|
+
for event, entries in hooks.items():
|
|
81
|
+
merged[event] = [*merged.get(event, []), *entries]
|
|
82
|
+
out["hooks"] = merged
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _indent_of(text: str) -> int:
|
|
87
|
+
for line in text.splitlines()[1:]:
|
|
88
|
+
stripped = line.lstrip(" ")
|
|
89
|
+
if stripped and len(stripped) != len(line):
|
|
90
|
+
return len(line) - len(stripped)
|
|
91
|
+
return 2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def dumps_like(data: Any, original: str | None) -> str:
|
|
95
|
+
indent = _indent_of(original) if original else 2
|
|
96
|
+
return json.dumps(data, indent=indent, ensure_ascii=False) + "\n"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Plan:
|
|
100
|
+
"""Files to write, with the original content for diffs, backups and removal."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, root: Path):
|
|
103
|
+
self.root = root
|
|
104
|
+
self.changes: dict[str, tuple[str | None, str | None]] = {} # path -> (before, after); after None = delete
|
|
105
|
+
|
|
106
|
+
def put(self, rel: str, content: str | None) -> None:
|
|
107
|
+
path = self.root / rel
|
|
108
|
+
before = path.read_text(encoding="utf-8") if path.exists() else None
|
|
109
|
+
if before != content:
|
|
110
|
+
self.changes[rel] = (before, content)
|
|
111
|
+
|
|
112
|
+
def merge_json(self, rel: str, edit: Callable[[dict[str, Any]], dict[str, Any]]) -> None:
|
|
113
|
+
path = self.root / rel
|
|
114
|
+
before = path.read_text(encoding="utf-8") if path.exists() else None
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(before) if before and before.strip() else {}
|
|
117
|
+
except json.JSONDecodeError as exc:
|
|
118
|
+
# Gemini and Cursor accept JSON with comments; obey does not rewrite those (it would drop the comments).
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
f"{rel} is not plain JSON ({exc.msg} at line {exc.lineno}); remove comments or trailing commas, "
|
|
121
|
+
"or leave this agent out with --targets"
|
|
122
|
+
) from exc
|
|
123
|
+
if not isinstance(data, dict):
|
|
124
|
+
raise ConfigError(f"{rel} is not a JSON object; refusing to change it")
|
|
125
|
+
after_data = edit(data)
|
|
126
|
+
if before is not None and after_data == data:
|
|
127
|
+
return
|
|
128
|
+
backup = self.root / BACKUP_DIR / (rel + ".orig")
|
|
129
|
+
if not after_data:
|
|
130
|
+
# Only obey's entries were in a file obey created: delete it, or restore the backup when one exists.
|
|
131
|
+
if backup.exists():
|
|
132
|
+
self.changes[rel] = (before, backup.read_text(encoding="utf-8"))
|
|
133
|
+
else:
|
|
134
|
+
self.changes[rel] = (before, None)
|
|
135
|
+
return
|
|
136
|
+
if backup.exists():
|
|
137
|
+
original = backup.read_text(encoding="utf-8")
|
|
138
|
+
if json.loads(original or "{}") == after_data:
|
|
139
|
+
self.changes[rel] = (before, original)
|
|
140
|
+
return
|
|
141
|
+
self.changes[rel] = (before, dumps_like(after_data, before))
|
|
142
|
+
|
|
143
|
+
def diff(self) -> str:
|
|
144
|
+
import difflib
|
|
145
|
+
|
|
146
|
+
out: list[str] = []
|
|
147
|
+
for rel, (before, after) in sorted(self.changes.items()):
|
|
148
|
+
out += difflib.unified_diff(
|
|
149
|
+
(before or "").splitlines(keepends=True),
|
|
150
|
+
(after or "").splitlines(keepends=True),
|
|
151
|
+
fromfile=f"a/{rel}" if before is not None else "/dev/null",
|
|
152
|
+
tofile=f"b/{rel}" if after is not None else "/dev/null",
|
|
153
|
+
)
|
|
154
|
+
return "".join(out)
|
|
155
|
+
|
|
156
|
+
def apply(self) -> list[str]:
|
|
157
|
+
root = self.root.resolve()
|
|
158
|
+
|
|
159
|
+
def inside(rel: str) -> Path:
|
|
160
|
+
path = (self.root / rel).resolve()
|
|
161
|
+
if not path.is_relative_to(root): # obey never writes outside the repository, symlinks included
|
|
162
|
+
raise ConfigError(f"refusing to write outside the repository: {rel}")
|
|
163
|
+
return path
|
|
164
|
+
|
|
165
|
+
# Check every path (and every backup) before writing anything, so a refusal leaves the repository as it was.
|
|
166
|
+
steps = []
|
|
167
|
+
for rel, (before, after) in self.changes.items():
|
|
168
|
+
backup = None
|
|
169
|
+
if before is not None and after is not None and rel.endswith(".json") and not rel.startswith(".obey/"):
|
|
170
|
+
backup = inside(f"{BACKUP_DIR}/{rel}.orig")
|
|
171
|
+
steps.append((rel, inside(rel), before, after, backup))
|
|
172
|
+
|
|
173
|
+
# The hook scripts go in before the settings that call them, and leave after them.
|
|
174
|
+
def order(step: tuple[str, Path, str | None, str | None, Path | None]) -> tuple[int, str]:
|
|
175
|
+
rel, after = step[0], step[3]
|
|
176
|
+
return (0 if after is not None else 2) if rel.startswith(".obey/") else 1, rel
|
|
177
|
+
|
|
178
|
+
written = []
|
|
179
|
+
for rel, path, before, after, backup in sorted(steps, key=order):
|
|
180
|
+
if backup is not None and before is not None and not backup.exists():
|
|
181
|
+
_write(backup, before)
|
|
182
|
+
if after is None:
|
|
183
|
+
path.unlink(missing_ok=True)
|
|
184
|
+
else:
|
|
185
|
+
_write(path, after)
|
|
186
|
+
if rel.endswith((".py", "pre-commit")) and rel.startswith(".obey/"):
|
|
187
|
+
path.chmod(0o755)
|
|
188
|
+
written.append(rel)
|
|
189
|
+
return written
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _write(path: Path, text: str) -> None:
|
|
193
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
tmp = path.with_name(f".{path.name}.obey-tmp")
|
|
195
|
+
tmp.write_text(text, encoding="utf-8")
|
|
196
|
+
tmp.replace(path)
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Per-agent enforcement targets: where each agent reads hooks from and what obey writes there.
|
|
2
|
+
|
|
3
|
+
Every target calls the same generated hook script, `.obey/hooks/obey_hook.py <agent> [stop]`, which uses the vendored
|
|
4
|
+
rule engine, so what a hook enforces is exactly what the checkers measure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from obey_cli.enforce.merge import Plan, add_hooks, remove_obey
|
|
15
|
+
|
|
16
|
+
HOOK = ".obey/hooks/obey_hook.py"
|
|
17
|
+
# Rule types each event can enforce.
|
|
18
|
+
SHELL_TYPES = {"command_forbidden", "path_forbidden", "dependency_unchanged", "no_commit", "no_push", "commit_message"}
|
|
19
|
+
EDIT_TYPES = {
|
|
20
|
+
"path_forbidden",
|
|
21
|
+
"dependency_unchanged",
|
|
22
|
+
"content_forbidden_added",
|
|
23
|
+
"content_required_added",
|
|
24
|
+
"file_naming",
|
|
25
|
+
"command_forbidden",
|
|
26
|
+
}
|
|
27
|
+
STOP_TYPES = {"command_required", "path_required_with", "final_message", "content_required_added"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Target:
|
|
32
|
+
name: str
|
|
33
|
+
display: str
|
|
34
|
+
files: tuple[str, ...]
|
|
35
|
+
write: Callable[[Plan, Sequence[dict[str, Any]]], None]
|
|
36
|
+
remove: Callable[[Plan], None]
|
|
37
|
+
stop: bool # can block finishing
|
|
38
|
+
edit_block: bool # can block file edits before they happen
|
|
39
|
+
notes: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _types(rules: Sequence[dict[str, Any]]) -> set[str]:
|
|
43
|
+
return {r["checker"]["type"] for r in rules}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cmd(script: str, agent: str, event: str | None = None, *, shell: bool = True) -> str:
|
|
47
|
+
args = f" {agent}" + (f" {event}" if event else "")
|
|
48
|
+
if not shell:
|
|
49
|
+
return f"python3 {script}{args}"
|
|
50
|
+
# A missing script must not block the agent (python3 exits 2, which agents read as "block"): skip quietly.
|
|
51
|
+
return f'h={script}; [ -f "$h" ] || exit 0; python3 "$h"{args}'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------------------------- Claude Code
|
|
55
|
+
|
|
56
|
+
CLAUDE_CMD = f'"$CLAUDE_PROJECT_DIR/{HOOK}"'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def claude_hooks(rules: Sequence[dict[str, Any]]) -> dict[str, list[Any]]:
|
|
60
|
+
types = _types(rules)
|
|
61
|
+
pre: list[Any] = []
|
|
62
|
+
if types & SHELL_TYPES:
|
|
63
|
+
pre.append({"matcher": "Bash", "hooks": [{"type": "command", "command": _cmd(CLAUDE_CMD, "claude")}]})
|
|
64
|
+
if types & EDIT_TYPES:
|
|
65
|
+
pre.append(
|
|
66
|
+
{
|
|
67
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
68
|
+
"hooks": [{"type": "command", "command": _cmd(CLAUDE_CMD, "claude")}],
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
hooks: dict[str, list[Any]] = {}
|
|
72
|
+
if pre:
|
|
73
|
+
hooks["PreToolUse"] = pre
|
|
74
|
+
if types & STOP_TYPES:
|
|
75
|
+
hooks["Stop"] = [{"hooks": [{"type": "command", "command": _cmd(CLAUDE_CMD, "claude", "stop")}]}]
|
|
76
|
+
return hooks
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _write_claude(plan: Plan, rules: Sequence[dict[str, Any]]) -> None:
|
|
80
|
+
hooks = claude_hooks(rules)
|
|
81
|
+
plan.merge_json(".claude/settings.json", lambda d: add_hooks(d, hooks) if hooks else remove_obey(d))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _remove_json(rel: str, empty_keys: frozenset[str] | set[str] = frozenset()) -> Callable[[Plan], None]:
|
|
85
|
+
"""Remove obey's entries; a file left holding only `empty_keys` (such as Cursor's version) counts as empty."""
|
|
86
|
+
|
|
87
|
+
def edit(d: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
out = remove_obey(d)
|
|
89
|
+
return {} if set(out) <= set(empty_keys) else out
|
|
90
|
+
|
|
91
|
+
def remove(plan: Plan) -> None:
|
|
92
|
+
if (plan.root / rel).exists():
|
|
93
|
+
plan.merge_json(rel, edit)
|
|
94
|
+
|
|
95
|
+
return remove
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --------------------------------------------------------------------------------------------- Codex CLI
|
|
99
|
+
|
|
100
|
+
GIT_ROOT_CMD = f'"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/{HOOK}"'
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def codex_hooks(rules: Sequence[dict[str, Any]]) -> dict[str, list[Any]]:
|
|
104
|
+
types = _types(rules)
|
|
105
|
+
pre: list[Any] = []
|
|
106
|
+
if types & SHELL_TYPES:
|
|
107
|
+
pre.append({"matcher": "Bash", "hooks": [{"type": "command", "command": _cmd(GIT_ROOT_CMD, "codex")}]})
|
|
108
|
+
if types & EDIT_TYPES:
|
|
109
|
+
pre.append(
|
|
110
|
+
{
|
|
111
|
+
"matcher": "apply_patch|Edit|Write",
|
|
112
|
+
"hooks": [{"type": "command", "command": _cmd(GIT_ROOT_CMD, "codex")}],
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
hooks: dict[str, list[Any]] = {}
|
|
116
|
+
if pre:
|
|
117
|
+
hooks["PreToolUse"] = pre
|
|
118
|
+
if types & STOP_TYPES:
|
|
119
|
+
hooks["Stop"] = [{"hooks": [{"type": "command", "command": _cmd(GIT_ROOT_CMD, "codex", "stop")}]}]
|
|
120
|
+
return hooks
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _write_codex(plan: Plan, rules: Sequence[dict[str, Any]]) -> None:
|
|
124
|
+
hooks = codex_hooks(rules)
|
|
125
|
+
plan.merge_json(".codex/hooks.json", lambda d: add_hooks(d, hooks) if hooks else remove_obey(d))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------------------------- Cursor
|
|
129
|
+
|
|
130
|
+
CURSOR_CMD = HOOK # Cursor runs project hooks from the project root; not known to use a shell
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cursor_hooks(rules: Sequence[dict[str, Any]]) -> dict[str, list[Any]]:
|
|
134
|
+
types = _types(rules)
|
|
135
|
+
hooks: dict[str, list[Any]] = {}
|
|
136
|
+
if types & SHELL_TYPES:
|
|
137
|
+
hooks["beforeShellExecution"] = [{"command": _cmd(CURSOR_CMD, "cursor", shell=False), "timeout": 10}]
|
|
138
|
+
if types & EDIT_TYPES:
|
|
139
|
+
hooks["preToolUse"] = [
|
|
140
|
+
{"command": _cmd(CURSOR_CMD, "cursor", shell=False), "matcher": "Write|Delete", "timeout": 10}
|
|
141
|
+
]
|
|
142
|
+
if types & STOP_TYPES:
|
|
143
|
+
hooks["stop"] = [{"command": _cmd(CURSOR_CMD, "cursor", "stop", shell=False), "loop_limit": 3}]
|
|
144
|
+
return hooks
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_cursor(plan: Plan, rules: Sequence[dict[str, Any]]) -> None:
|
|
148
|
+
hooks = cursor_hooks(rules)
|
|
149
|
+
|
|
150
|
+
def edit(d: dict[str, Any]) -> dict[str, Any]:
|
|
151
|
+
if not hooks:
|
|
152
|
+
return remove_obey(d)
|
|
153
|
+
out = add_hooks(d, hooks)
|
|
154
|
+
out.setdefault("version", 1)
|
|
155
|
+
return {"version": out.pop("version"), **out}
|
|
156
|
+
|
|
157
|
+
plan.merge_json(".cursor/hooks.json", edit)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------------------------- Gemini CLI
|
|
161
|
+
|
|
162
|
+
GEMINI_CMD = f'"$GEMINI_PROJECT_DIR/{HOOK}"'
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def gemini_hooks(rules: Sequence[dict[str, Any]]) -> dict[str, list[Any]]:
|
|
166
|
+
types = _types(rules)
|
|
167
|
+
tools = []
|
|
168
|
+
if types & SHELL_TYPES:
|
|
169
|
+
tools.append("run_shell_command")
|
|
170
|
+
if types & EDIT_TYPES:
|
|
171
|
+
tools += ["write_file", "replace"]
|
|
172
|
+
hooks: dict[str, list[Any]] = {}
|
|
173
|
+
if tools:
|
|
174
|
+
hooks["BeforeTool"] = [
|
|
175
|
+
{
|
|
176
|
+
"matcher": "|".join(tools),
|
|
177
|
+
"hooks": [{"name": "obey", "type": "command", "command": _cmd(GEMINI_CMD, "gemini"), "timeout": 5000}],
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
if types & STOP_TYPES:
|
|
181
|
+
hooks["AfterAgent"] = [
|
|
182
|
+
{
|
|
183
|
+
"hooks": [
|
|
184
|
+
{
|
|
185
|
+
"name": "obey-stop",
|
|
186
|
+
"type": "command",
|
|
187
|
+
"command": _cmd(GEMINI_CMD, "gemini", "stop"),
|
|
188
|
+
"timeout": 10000,
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
return hooks
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _write_gemini(plan: Plan, rules: Sequence[dict[str, Any]]) -> None:
|
|
197
|
+
hooks = gemini_hooks(rules)
|
|
198
|
+
plan.merge_json(".gemini/settings.json", lambda d: add_hooks(d, hooks) if hooks else remove_obey(d))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --------------------------------------------------------------------------------------------- Copilot CLI
|
|
202
|
+
|
|
203
|
+
COPILOT_CMD = HOOK # the "bash" key runs in bash
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def copilot_hooks(rules: Sequence[dict[str, Any]]) -> dict[str, list[Any]]:
|
|
207
|
+
types = _types(rules)
|
|
208
|
+
hooks: dict[str, list[Any]] = {}
|
|
209
|
+
if types & (SHELL_TYPES | EDIT_TYPES):
|
|
210
|
+
hooks["preToolUse"] = [{"type": "command", "bash": _cmd(COPILOT_CMD, "copilot"), "cwd": ".", "timeoutSec": 30}]
|
|
211
|
+
if types & STOP_TYPES:
|
|
212
|
+
hooks["agentStop"] = [
|
|
213
|
+
{"type": "command", "bash": _cmd(COPILOT_CMD, "copilot", "stop"), "cwd": ".", "timeoutSec": 60}
|
|
214
|
+
]
|
|
215
|
+
return hooks
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _write_copilot(plan: Plan, rules: Sequence[dict[str, Any]]) -> None:
|
|
219
|
+
hooks = copilot_hooks(rules)
|
|
220
|
+
if hooks:
|
|
221
|
+
plan.put(".github/hooks/obey.json", json.dumps({"version": 1, "hooks": hooks}, indent=2) + "\n")
|
|
222
|
+
else:
|
|
223
|
+
plan.put(".github/hooks/obey.json", None)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _remove_copilot(plan: Plan) -> None:
|
|
227
|
+
plan.put(".github/hooks/obey.json", None)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------------------------------- OpenCode
|
|
231
|
+
|
|
232
|
+
OPENCODE_PLUGIN = """// Generated by obey (https://github.com/B0yko/obey). Remove with `obey enforce --remove`.
|
|
233
|
+
// Calls the shared rule engine before every tool runs; a thrown error blocks the tool.
|
|
234
|
+
export const ObeyGuard = async ({ directory, worktree }) => ({
|
|
235
|
+
"tool.execute.before": async (input, output) => {
|
|
236
|
+
const root = worktree || directory;
|
|
237
|
+
let text = "";
|
|
238
|
+
try {
|
|
239
|
+
const proc = Bun.spawnSync(["python3", `${root}/.obey/hooks/obey_hook.py`, "opencode"], {
|
|
240
|
+
cwd: root,
|
|
241
|
+
stdin: new TextEncoder().encode(JSON.stringify({ tool: input.tool, args: output.args, cwd: root })),
|
|
242
|
+
});
|
|
243
|
+
text = new TextDecoder().decode(proc.stdout).trim();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.warn(`obey: hook failed, allowing the tool: ${error}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!text) return;
|
|
249
|
+
let verdict;
|
|
250
|
+
try {
|
|
251
|
+
verdict = JSON.parse(text);
|
|
252
|
+
} catch {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (verdict && verdict.deny) throw new Error(verdict.deny);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _write_opencode(plan: Plan, rules: Sequence[dict[str, Any]]) -> None:
|
|
262
|
+
plan.put(".opencode/plugins/obey.ts", OPENCODE_PLUGIN if _types(rules) & (SHELL_TYPES | EDIT_TYPES) else None)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _remove_opencode(plan: Plan) -> None:
|
|
266
|
+
plan.put(".opencode/plugins/obey.ts", None)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# --------------------------------------------------------------------------------------------- git pre-commit
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _write_precommit(plan: Plan, rules: Sequence[dict[str, Any]]) -> None:
|
|
273
|
+
pass # the .obey/pre-commit script is written with the shared files; see enforce.compile
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _remove_precommit(plan: Plan) -> None:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
TARGETS: dict[str, Target] = {
|
|
281
|
+
"claude": Target(
|
|
282
|
+
"claude",
|
|
283
|
+
"Claude Code",
|
|
284
|
+
(".claude/settings.json",),
|
|
285
|
+
_write_claude,
|
|
286
|
+
_remove_json(".claude/settings.json"),
|
|
287
|
+
stop=True,
|
|
288
|
+
edit_block=True,
|
|
289
|
+
notes="PreToolUse deny with hookSpecificOutput; Stop blocks finishing.",
|
|
290
|
+
),
|
|
291
|
+
"codex": Target(
|
|
292
|
+
"codex",
|
|
293
|
+
"Codex CLI",
|
|
294
|
+
(".codex/hooks.json",),
|
|
295
|
+
_write_codex,
|
|
296
|
+
_remove_json(".codex/hooks.json"),
|
|
297
|
+
stop=True,
|
|
298
|
+
edit_block=True,
|
|
299
|
+
notes="Project hooks load only in a trusted project after the hook is reviewed with /hooks "
|
|
300
|
+
'(headless: trust_level="trusted" plus --dangerously-bypass-hook-trust).',
|
|
301
|
+
),
|
|
302
|
+
"cursor": Target(
|
|
303
|
+
"cursor",
|
|
304
|
+
"Cursor",
|
|
305
|
+
(".cursor/hooks.json",),
|
|
306
|
+
_write_cursor,
|
|
307
|
+
_remove_json(".cursor/hooks.json", empty_keys={"version"}),
|
|
308
|
+
stop=False,
|
|
309
|
+
edit_block=True,
|
|
310
|
+
notes="beforeShellExecution and preToolUse (Write|Delete) deny; the stop hook can only send a "
|
|
311
|
+
"follow-up message, so stop checks are advisory.",
|
|
312
|
+
),
|
|
313
|
+
"gemini": Target(
|
|
314
|
+
"gemini",
|
|
315
|
+
"Gemini CLI",
|
|
316
|
+
(".gemini/settings.json",),
|
|
317
|
+
_write_gemini,
|
|
318
|
+
_remove_json(".gemini/settings.json"),
|
|
319
|
+
stop=True,
|
|
320
|
+
edit_block=True,
|
|
321
|
+
notes="BeforeTool deny; AfterAgent deny forces a retry turn.",
|
|
322
|
+
),
|
|
323
|
+
"copilot": Target(
|
|
324
|
+
"copilot",
|
|
325
|
+
"Copilot CLI",
|
|
326
|
+
(".github/hooks/obey.json",),
|
|
327
|
+
_write_copilot,
|
|
328
|
+
_remove_copilot,
|
|
329
|
+
stop=True,
|
|
330
|
+
edit_block=True,
|
|
331
|
+
notes="preToolUse deny; in -p mode repo hooks need --trust-folder or "
|
|
332
|
+
"GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS=true.",
|
|
333
|
+
),
|
|
334
|
+
"opencode": Target(
|
|
335
|
+
"opencode",
|
|
336
|
+
"OpenCode",
|
|
337
|
+
(".opencode/plugins/obey.ts",),
|
|
338
|
+
_write_opencode,
|
|
339
|
+
_remove_opencode,
|
|
340
|
+
stop=False,
|
|
341
|
+
edit_block=True,
|
|
342
|
+
notes="tool.execute.before throws; OpenCode has no stop hook, so stop checks are advisory.",
|
|
343
|
+
),
|
|
344
|
+
"precommit": Target(
|
|
345
|
+
"precommit",
|
|
346
|
+
"git pre-commit",
|
|
347
|
+
(".obey/pre-commit",),
|
|
348
|
+
_write_precommit,
|
|
349
|
+
_remove_precommit,
|
|
350
|
+
stop=False,
|
|
351
|
+
edit_block=False,
|
|
352
|
+
notes="Agent-agnostic: checks the staged diff and the commit message.",
|
|
353
|
+
),
|
|
354
|
+
}
|