Merge dotagents' hooks into an agent's settings.json without disturbing it.
An agent's settings file belongs to the user: it may carry unrelated keys and
hooks we did not write, in shapes we did not choose. So every function here is
additive and defensive -- foreign entries survive verbatim, malformed ones are
coerced or dropped rather than raising, and re-running is a no-op.
Idempotence is the property that matters: dotagents init is re-run often, and a
merge that appended a duplicate hook each time would quietly grow the file until
the agent ran our command N times per session.
The schema (Claude Code's; Codex's is the same shape) is::
hooks: { "<Event>": [ { "matcher"?: str,
"hooks": [ {"type": "command", "command": str,
"statusMessage"?: str} ] } ] }
hooks.<Event> is a list of matcher-objects, each holding its own hooks
list -- not a flat list of commands. statusMessage is a supported key.
Pure stdlib (json/pathlib) so it works in a plain pip install and inside
the .pyz.
STATUS_PREFIX = 'dotagents: '
module-attribute
build_hook_entry(command, *, matcher=None, status_message=None, shell=None, command_windows=None)
One matcher-object wrapping a single type: command hook.
matcher/statusMessage/shell/command_windows are omitted
entirely when None rather than written as null -- an absent key is the
documented "no matcher" / "default shell" form. shell (Claude) selects
the interpreter for THIS hook's own command ("bash" or
"powershell"); command_windows (Codex, emitted as commandWindows)
is a separate Windows-only command that Codex substitutes for command
on Windows.
Source code in src/dotagents/_hooks.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102 | def build_hook_entry(
command: str,
*,
matcher: "Optional[str]" = None,
status_message: "Optional[str]" = None,
shell: "Optional[str]" = None,
command_windows: "Optional[str]" = None,
) -> "dict[str, Any]":
"""One matcher-object wrapping a single ``type: command`` hook.
``matcher``/``statusMessage``/``shell``/``command_windows`` are omitted
entirely when None rather than written as null -- an absent key is the
documented "no matcher" / "default shell" form. ``shell`` (Claude) selects
the interpreter for THIS hook's own ``command`` (``"bash"`` or
``"powershell"``); ``command_windows`` (Codex, emitted as ``commandWindows``)
is a separate Windows-only command that Codex substitutes for ``command``
on Windows.
"""
hook: "dict[str, Any]" = {"type": "command", "command": command}
if shell:
hook["shell"] = shell
if command_windows:
hook["commandWindows"] = command_windows
if status_message:
hook["statusMessage"] = _labels(status_message)[0]
entry: "dict[str, Any]" = {}
if matcher is not None:
entry["matcher"] = matcher
entry["hooks"] = [hook]
return entry
|
load_settings(path)
Read a settings.json, tolerating absence but never corruption.
A missing or empty file yields {}. Invalid JSON raises SystemExit with
the path and parse error: silently starting from {} there would overwrite a
user's whole settings file on the next write. A UTF-8 byte-order mark (what
Windows PowerShell 5's -Encoding UTF8 writes) is accepted; the file is
written back without one.
Source code in src/dotagents/_hooks.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278 | def load_settings(path: Path) -> "dict[str, Any]":
"""Read a settings.json, tolerating absence but never corruption.
A missing or empty file yields ``{}``. Invalid JSON raises ``SystemExit`` with
the path and parse error: silently starting from ``{}`` there would overwrite a
user's whole settings file on the next write. A UTF-8 byte-order mark (what
Windows PowerShell 5's ``-Encoding UTF8`` writes) is accepted; the file is
written back without one.
"""
if not path.is_file():
return {}
try:
raw = path.read_text(encoding="utf-8-sig").strip()
except UnicodeDecodeError as exc:
raise SystemExit(
"error: %s is not UTF-8 text (%s). Fix or move it, then re-run." % (path, exc)
) from exc
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise SystemExit(
"error: %s is not valid JSON (%s). Fix or move it, then re-run." % (path, exc)
) from exc
if not isinstance(data, dict):
raise SystemExit("error: %s must contain a JSON object, got %s" % (path, type(data).__name__))
return data
|
merge_hook(existing, command, *, matcher=None, status_message=None, shell=None, command_windows=None)
Fold our hook into existing, returning (normalized_list, changed).
Rules, in order:
- absent / not-a-list
existing -> a fresh single-entry list (changed).
- an entry that is exactly what we would write -> kept as-is (unchanged);
one carrying our hook in a different shape (
shell / matcher /
commandWindows / status, or a different command text under the same
status message) -> replaced in place; any later duplicate is dropped
(changed), so repeated init runs converge instead of accumulating.
- a matcher-object holding a foreign hook AND ours -> the foreign hook stays
verbatim in that entry, ours moves to its own entry.
- foreign entries -> preserved verbatim, untouched, in their original order.
- malformed entries (bare strings, dicts without a
hooks list, non-dicts)
-> kept verbatim: they are the user's, and deleting them silently lost
whatever they were meant to be. Never raises: a user's hand-edited
settings file must not make init explode.
status_message doubles as our hook's IDENTITY, written namespaced
(:data:STATUS_PREFIX); a hook carrying that status message, or the bare
label an earlier release wrote on a command that runs dotagents, is ours
and gets refreshed, so revising the
command text never leaves the previous version running beside the new one.
A refresh rewrites only the keys dotagents manages: a key the user added to
our hook or its entry stays.
Source code in src/dotagents/_hooks.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228 | def merge_hook(
existing: Any,
command: str,
*,
matcher: "Optional[str]" = None,
status_message: "Optional[str]" = None,
shell: "Optional[str]" = None,
command_windows: "Optional[str]" = None,
) -> "tuple[list, bool]":
"""Fold our hook into `existing`, returning ``(normalized_list, changed)``.
Rules, in order:
* absent / not-a-list ``existing`` -> a fresh single-entry list (changed).
* an entry that is exactly what we would write -> kept as-is (unchanged);
one carrying our hook in a different shape (``shell`` / ``matcher`` /
``commandWindows`` / status, or a different command text under the same
status message) -> replaced in place; any later duplicate is dropped
(changed), so repeated ``init`` runs converge instead of accumulating.
* a matcher-object holding a foreign hook AND ours -> the foreign hook stays
verbatim in that entry, ours moves to its own entry.
* foreign entries -> preserved verbatim, untouched, in their original order.
* malformed entries (bare strings, dicts without a ``hooks`` list, non-dicts)
-> kept verbatim: they are the user's, and deleting them silently lost
whatever they were meant to be. Never raises: a user's hand-edited
settings file must not make ``init`` explode.
``status_message`` doubles as our hook's IDENTITY, written namespaced
(:data:`STATUS_PREFIX`); a hook carrying that status message, or the bare
label an earlier release wrote on a command that runs dotagents, is ours
and gets refreshed, so revising the
command text never leaves the previous version running beside the new one.
A refresh rewrites only the keys dotagents manages: a key the user added to
our hook or its entry stays.
"""
ours = build_hook_entry(
command, matcher=matcher, status_message=status_message, shell=shell,
command_windows=command_windows,
)
if not isinstance(existing, list):
# None/absent is the common case; a non-list is a malformed file we replace.
return [ours], True
def _mine(hook: Any) -> bool:
return isinstance(hook, dict) and (
hook.get("command") == command or _labelled_ours(hook, status_message)
)
def _refreshed(entry: dict) -> dict:
old = next(h for h in entry["hooks"] if _mine(h))
hook = {k: v for k, v in old.items() if k not in _MANAGED_KEYS}
hook.update(ours["hooks"][0])
new = {k: v for k, v in entry.items() if k not in ("matcher", "hooks")}
if "matcher" in ours:
new["matcher"] = ours["matcher"]
new["hooks"] = [hook]
return new
normalized: list = []
changed = False
seen_ours = False
for entry in existing:
if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
normalized.append(entry) # malformed, but the user's: keep it
continue
inner = entry["hooks"]
if not any(_mine(h) for h in inner):
normalized.append(entry) # foreign but well-formed: leave alone
continue
foreign = [h for h in inner if not _mine(h)]
if foreign:
# A matcher-object holding BOTH a foreign hook and ours: the foreign
# sibling stays, verbatim, in its own entry; ours moves to (or is
# refreshed in) our own entry below.
kept = dict(entry)
kept["hooks"] = foreign
normalized.append(kept)
changed = True
continue
if seen_ours:
changed = True # a duplicate of our own hook -- collapse it
continue
seen_ours = True
# Ours, and only ours: keep it exactly when it already equals what we
# would write, otherwise REPLACE it -- a changed `shell` / `matcher` /
# `commandWindows` / status must land even when the command text is
# unchanged.
refreshed = _refreshed(entry)
normalized.append(refreshed)
if refreshed != entry:
changed = True
if not seen_ours:
normalized.append(ours)
changed = True
return normalized, changed
|
remove_hook(existing, status_message)
Retract our hook identified by status_message from existing --
the inverse of :func:merge_hook, for a hook this platform does not
register (a shell: powershell variant on a POSIX host). Returns
(list, changed). A foreign hook sharing an entry with ours stays; an
entry left empty is dropped; a non-list existing is ([], False).
Source code in src/dotagents/_hooks.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248 | def remove_hook(existing: Any, status_message: str) -> "tuple[list, bool]":
"""Retract our hook identified by ``status_message`` from ``existing`` --
the inverse of :func:`merge_hook`, for a hook this platform does not
register (a `shell: powershell` variant on a POSIX host). Returns
``(list, changed)``. A foreign hook sharing an entry with ours stays; an
entry left empty is dropped; a non-list ``existing`` is ``([], False)``."""
if not isinstance(existing, list):
return [], False
kept_entries, changed = [], False
for entry in existing:
if not _has_status(entry, status_message):
kept_entries.append(entry)
continue
changed = True
remaining = [h for h in entry["hooks"] if not _labelled_ours(h, status_message)]
if remaining:
kept_entries.append(dict(entry, hooks=remaining))
return kept_entries, changed
|
write_settings(path, data, *, dry_run=False)
Write settings.json with a stable 2-space indent and trailing newline.
LF-only and ATOMIC (temp file + os.replace): the agent may read its own
settings at any moment, and a half-written file is invalid JSON. Non-ASCII
is kept as-is (ensure_ascii=False) rather than rewriting a user's own
values as \uXXXX escapes.
Source code in src/dotagents/_hooks.py
281
282
283
284
285
286
287
288
289
290
291
292 | def write_settings(path: Path, data: "dict[str, Any]", *, dry_run: bool = False) -> None:
"""Write settings.json with a stable 2-space indent and trailing newline.
LF-only and ATOMIC (temp file + ``os.replace``): the agent may read its own
settings at any moment, and a half-written file is invalid JSON. Non-ASCII
is kept as-is (``ensure_ascii=False``) rather than rewriting a user's own
values as ``\\uXXXX`` escapes."""
if dry_run:
return
from dotagents._fs import write_text_lf
write_text_lf(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n", atomic=True)
|