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.
Files changed (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. 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
+ }