Hooks
dotagents._hooks
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 (verified against current Claude Code docs, 2026-07-24) 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.
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 the camelCase
commandWindows key its docs specify) is a separate Windows-only command
OVERRIDE -- Codex runs command normally and substitutes
commandWindows for it on Windows, rather than picking an interpreter for
one shared string. Both are agent-specific vocabulary living in this one
shared merge function rather than duplicated per adapter.
Source code in src/dotagents/_hooks.py
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | |
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.
Source code in src/dotagents/_hooks.py
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | |
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 already carrying our exact
command-> kept as-is; the first such entry wins and any later duplicate is dropped (changed), so repeatedinitruns converge instead of accumulating. - foreign entries -> preserved verbatim, untouched, in their original order.
- malformed entries (bare strings, dicts without a
hookslist, non-dicts) -> dropped, flagged changed. Never raises: a user's hand-edited settings file must not makeinitexplode.
status_message doubles as our hook's IDENTITY. An entry carrying the same
status message is treated as an older shape of our own hook and replaced, not
left beside the new one. Without that, dedup is by exact command string, so
revising the command we write silently orphans the previous version and the
old (often broken) hook keeps running next to the new one. The status message
is a stable label we choose, which makes it a better key than the command text
it describes.
Source code in src/dotagents/_hooks.py
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 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 | |
write_settings(path, data, *, dry_run=False)
Write settings.json with a stable 2-space indent and trailing newline.
Source code in src/dotagents/_hooks.py
185 186 187 188 189 190 | |