33Ownership discipline (learned the hard way by a sibling policy engine that shipped this
44pattern first): every entry we add is marked, so uninstall removes exactly ours and never
55a user's own hooks, and a re-install replaces rather than duplicates.
6+
7+ `installed()` lives in `install_identity.py` (a query is a different activity from a
8+ write) and is re-exported below -- `agentseam.install.installed` is unchanged for every
9+ existing caller.
610"""
711
812from __future__ import annotations
913
10- import inspect
11- import json
1214import os
1315
1416from . import adapters
15-
16- MARKER = "_agentseam"
17-
18- # TOML configs are the user's whole settings document rather than a hooks file, and the
19- # stdlib can read TOML but not write it. So those get the treatment instruction files
20- # already use: a marker-delimited block we own, with every byte outside it preserved.
21- BEGIN = "# >>> agentseam >>>"
22- END = "# <<< agentseam <<<"
23-
24-
25- class ConfigUnreadable (Exception ):
26- """An existing config file could not be read or parsed, so it must not be overwritten.
27-
28- Returning {} here -- as this used to -- meant install merged its fragment into an empty
29- object and wrote that back, silently DESTROYING everything the user had. For Junie, whose
30- config.json is the whole CLI configuration rather than a hooks-only file, a single stray
31- byte (a UTF-8 BOM, a trailing comma, a half-saved edit) cost the user their entire
32- config. Wiping a file we cannot understand is the exact silent-destruction this library
33- exists to refuse; the honest move is to stop and say so.
34- """
35-
36-
37- def _load (path ):
38- """The existing config as a dict. {} only when the file is genuinely absent.
39-
40- A BOM is tolerated (utf-8-sig), mirroring the runtime's stdin fix -- Windows editors add
41- one and it is not corruption. Anything still unparseable, or unreadable, raises rather
42- than returning {}, because the caller is about to write this file back.
43- """
44- if not os .path .exists (path ):
45- return {}
46- try :
47- with open (path , encoding = "utf-8-sig" ) as fh :
48- text = fh .read ()
49- except OSError as exc :
50- raise ConfigUnreadable ("cannot read %s: %s" % (path , exc )) from exc
51- except UnicodeError as exc :
52- # A file that is not UTF-8/UTF-8-BOM (e.g. UTF-16) raises while decoding here. Wrap it
53- # as ConfigUnreadable, like a JSON error, so it is preserved and reported through the
54- # one path -- keeping the exception type consistent -- rather than crashing
55- # install/uninstall, and installed()'s JSON branch (which catches only
56- # ConfigUnreadable), with a raw UnicodeDecodeError.
57- raise ConfigUnreadable ("%s exists but is not UTF-8 text (%s); refusing to overwrite it." % (path , exc )) from exc
58- if not text .strip ():
59- return {} # an empty file is not corruption; treat it as a fresh config
60- try :
61- loaded = json .loads (text )
62- except ValueError as exc :
63- raise ConfigUnreadable (
64- "%s exists but is not valid JSON (%s); refusing to overwrite it. "
65- "Fix or move the file, then re-run." % (path , exc )
66- ) from exc
67- if not isinstance (loaded , dict ):
68- raise ConfigUnreadable ("%s is valid JSON but not an object; refusing to overwrite it." % path )
69- return loaded
70-
71-
72- def _dump (path , data ):
73- parent = os .path .dirname (path )
74- if parent :
75- os .makedirs (parent , exist_ok = True )
76- with open (path , "w" ) as fh :
77- json .dump (data , fh , indent = 2 , sort_keys = True )
78- fh .write ("\n " )
79-
80-
81- def _mark (obj , owner ):
82- """Tag every hook entry we own so uninstall is surgical.
83-
84- Only a dict reached as a LIST ITEM gets tagged -- that is where every adapter's owned
85- entries actually live, whether that item is a leaf command object (cursor, windsurf) or a
86- matcher/hooks group wrapping one (claude_code, junie, gemini_cli, grok, devin,
87- antigravity, tabnine). The top-level container a caller passes in (`{"hooks": {...}}`,
88- `{"version": 1, "hooks": [...]}`, `{GROUP: group}`) is reached by the initial call, not as
89- a list item, so it is never tagged -- some vendors' hook-config parsers reject unknown
90- top-level fields outright. Witnessed live installing here (Codex CLI, 2026-08-27):
91- "unknown field `_agentseam`, expected `description` or `hooks`", which silently drops
92- every hook in the file -- the whole file failed to load, not just our entry. A
93- known-independently vendor bug, not a one-off: openai/codex#30397 documents Codex < 0.143.0
94- rejecting the entire hooks file over an unexpected top-level `description` key the exact
95- same way (fixed in #30229), which is why a sibling policy engine's own Codex emitter
96- never writes one either.
97- """
98- if isinstance (obj , dict ):
99- for v in obj .values ():
100- _mark (v , owner )
101- elif isinstance (obj , list ):
102- for v in obj :
103- if isinstance (v , dict ):
104- v [MARKER ] = owner
105- _mark (v , owner )
106- return obj
107-
108-
109- def _strip_owned (obj , owner ):
110- """Remove entries `owner` owns; leave everything else untouched -- including its marker.
111-
112- The dict branch used to drop MARKER unconditionally, which erased OTHER owners' marks.
113- That is not cosmetic: ownership is the only thing that makes uninstall surgical, so a
114- second `install(..., owner="b")` silently un-owned everything "a" had written, and
115- neither could be uninstalled afterwards. Both sets of entries stayed in the user's real
116- settings file with nothing left to identify them -- permanent pollution, from the one
117- operation whose entire purpose is to be reversible.
118-
119- A marker equal to `owner` is still dropped here, for the dict that is not a list item
120- and so cannot be removed by the branch above. `_mark` does not put one there today (see
121- its docstring: some vendors reject unknown top-level fields), but stripping ours if it
122- ever appears is the conservative half of the pair.
123- """
124- if isinstance (obj , list ):
125- return [_strip_owned (v , owner ) for v in obj if not (isinstance (v , dict ) and v .get (MARKER ) == owner )]
126- if isinstance (obj , dict ):
127- return {k : _strip_owned (v , owner ) for k , v in obj .items () if not (k == MARKER and v == owner )}
128- return obj
129-
130-
131- def _merge (base , addition ):
132- for key , value in addition .items ():
133- if isinstance (value , dict ) and isinstance (base .get (key ), dict ):
134- _merge (base [key ], value )
135- elif isinstance (value , list ) and isinstance (base .get (key ), list ):
136- base [key ].extend (value )
137- else :
138- base [key ] = value
139- return base
140-
141-
142- def _block_bounds (text , owner ):
143- begin , end = "%s %s" % (BEGIN , owner ), "%s %s" % (END , owner )
144- start , stop = text .find (begin ), text .find (end )
145- if start == - 1 or stop == - 1 or stop < start :
146- return None
147- return start , stop + len (end )
148-
149-
150- def _write_block (path , body , owner ):
151- """Replace our block, or append one. Everything outside it is left byte-for-byte."""
152- text = ""
153- if os .path .exists (path ):
154- with open (path ) as fh :
155- text = fh .read ()
156- block = "%s %s\n %s%s %s" % (BEGIN , owner , body , END , owner )
157- bounds = _block_bounds (text , owner )
158- if bounds :
159- text = text [: bounds [0 ]] + block + text [bounds [1 ] :]
160- else :
161- text = (text .rstrip ("\n " ) + "\n \n " if text .strip () else "" ) + block + "\n "
162- parent = os .path .dirname (path )
163- if parent :
164- os .makedirs (parent , exist_ok = True )
165- with open (path , "w" ) as fh :
166- fh .write (text )
167-
168-
169- def _remove_block (path , owner ):
170- with open (path ) as fh :
171- text = fh .read ()
172- bounds = _block_bounds (text , owner )
173- if not bounds :
174- return False
175- cleaned = (text [: bounds [0 ]].rstrip ("\n " ) + "\n " + text [bounds [1 ] :].lstrip ("\n " )).strip ("\n " )
176- with open (path , "w" ) as fh :
177- fh .write (cleaned + "\n " if cleaned else "" )
178- return True
179-
180-
181- def _resolve (mod , repo_root , owner ):
182- """Where this agent's config lives.
183-
184- A CONFIG_PATH beginning with `~` is user-scoped and deliberately so -- Junie ignores
185- hooks from a repository-controlled config, so a project file there would never fire.
186- Joining it under repo_root produced a literal `./~/` directory: a config written
187- somewhere no agent reads, indistinguishable at capture time from a vendor whose hooks
188- do not work.
189- """
190- config = mod .CONFIG_PATH
191- path = os .path .expanduser (config ) if config .startswith ("~" ) else os .path .join (repo_root , config )
192- return path .replace ("*" , owner ) if "*" in path else path
17+ from .install_config import (
18+ BEGIN ,
19+ END ,
20+ MARKER ,
21+ ConfigUnreadable ,
22+ check_wireable ,
23+ dump ,
24+ fail_closed_kwarg ,
25+ load ,
26+ mark ,
27+ merge ,
28+ remove_block ,
29+ resolve ,
30+ strip_owned ,
31+ write_block ,
32+ )
33+ from .install_identity import installed
34+
35+ __all__ = ["BEGIN" , "END" , "ConfigUnreadable" , "MARKER" , "config_path" , "install" , "installed" , "uninstall" ]
19336
19437
19538def config_path (agent , repo_root = "." , owner = "agentseam" ):
19639 """Where this agent's hook config lives, resolved the way install resolves it.
19740
19841 Exposed because callers keep needing the answer -- a tool reporting what is wired has to
19942 name the file -- and re-deriving it from CONFIG_PATH loses the `~` and `*` handling that
200- `_resolve ` already gets right.
43+ `resolve ` already gets right.
20144 """
202- return _resolve (adapters .get (agent ), repo_root , owner )
45+ return resolve (adapters .get (agent ), repo_root , owner )
20346
20447
20548def install (agent , events , command , repo_root = "." , matcher = None , owner = "agentseam" , fail_closed = None ):
@@ -210,88 +53,29 @@ def install(agent, events, command, repo_root=".", matcher=None, owner="agentsea
21053 failure this library exists to prevent, so it must not be how its own installer behaves.
21154 """
21255 mod = adapters .get (agent )
213- # `fail_closed=False` marks this wiring as an OBSERVER, not a gate. It reaches only the
214- # adapters whose hook_config takes the argument -- today just cursor, the one vendor
215- # whose config carries a per-hook fail mode. Forwarded by signature rather than by name
216- # so an adapter that grows the knob later gets it without a change here.
217- #
218- # It exists for the capture probe, which always allows: installed as a gate on Cursor it
219- # inherits failClosed:true, so a probe that cannot launch BLOCKS the user's real command.
220- # Verification that costs someone a broken session is not worth running.
221- extra = {}
222- if fail_closed is not None and "fail_closed" in inspect .signature (mod .hook_config ).parameters :
223- extra ["fail_closed" ] = fail_closed
224- unwireable = [e for e in events if e not in getattr (mod , "REVERSE_EVENT_MAP" , {})]
225- if unwireable :
226- raise ValueError (
227- "%s has no hook for: %s (it can be wired for: %s)"
228- % (agent , ", " .join (sorted (unwireable )), ", " .join (sorted (mod .REVERSE_EVENT_MAP )))
229- )
230- path = _resolve (mod , repo_root , owner ) # e.g. .github/hooks/*.json
56+ check_wireable (mod , agent , events )
57+ extra = fail_closed_kwarg (mod , fail_closed )
58+ path = resolve (mod , repo_root , owner ) # e.g. .github/hooks/*.json
23159 if getattr (mod , "CONFIG_FORMAT" , "json" ) == "toml" :
232- _write_block (path , mod .render_config (mod .hook_config (events , command , matcher = matcher , ** extra )), owner )
60+ write_block (path , mod .render_config (mod .hook_config (events , command , matcher = matcher , ** extra )), owner )
23361 return path
234- existing = _strip_owned ( _load (path ), owner ) # idempotent: drop our old entries
235- fragment = _mark (mod .hook_config (events , command , matcher = matcher , ** extra ), owner )
236- _dump (path , _merge (existing , fragment ))
62+ existing = strip_owned ( load (path ), owner ) # idempotent: drop our old entries
63+ fragment = mark (mod .hook_config (events , command , matcher = matcher , ** extra ), owner )
64+ dump (path , merge (existing , fragment ))
23765 return path
23866
23967
24068def uninstall (agent , repo_root = "." , owner = "agentseam" ):
24169 """Remove only our entries. Returns True when the file changed."""
24270 mod = adapters .get (agent )
243- path = _resolve (mod , repo_root , owner )
71+ path = resolve (mod , repo_root , owner )
24472 if not os .path .exists (path ):
24573 return False
24674 if getattr (mod , "CONFIG_FORMAT" , "json" ) == "toml" :
247- return _remove_block (path , owner )
248- before = _load (path )
249- after = _strip_owned (before , owner )
75+ return remove_block (path , owner )
76+ before = load (path )
77+ after = strip_owned (before , owner )
25078 if after == before :
25179 return False
252- _dump (path , after )
80+ dump (path , after )
25381 return True
254-
255-
256- def _owns_anything (obj , owner ):
257- """True when some entry carries OUR marker -- not merely our name somewhere in the file.
258-
259- The witness used to be `owner in json.dumps(...)`, a substring test over the whole
260- serialised config. Antigravity's config group is literally named "agentseam", the
261- default owner, so after uninstall the leftover empty group `{"agentseam": {...}}` kept
262- the witness True forever: `installed()` reported a guard that was gone. Any user string
263- containing the owner name did the same -- a path, a command, a comment.
264-
265- Looking for the marker key with our value is the question we actually meant to ask.
266- """
267- if isinstance (obj , dict ):
268- if obj .get (MARKER ) == owner :
269- return True
270- return any (_owns_anything (v , owner ) for v in obj .values ())
271- if isinstance (obj , list ):
272- return any (_owns_anything (v , owner ) for v in obj )
273- return False
274-
275-
276- def installed (agent , repo_root = "." , owner = "agentseam" ):
277- """True when our witness is present in this agent's config."""
278- mod = adapters .get (agent )
279- path = _resolve (mod , repo_root , owner )
280- if getattr (mod , "CONFIG_FORMAT" , "json" ) == "toml" :
281- if not os .path .exists (path ):
282- return False
283- try :
284- with open (path , encoding = "utf-8-sig" ) as fh :
285- text = fh .read ()
286- except (OSError , UnicodeError ):
287- # A query never raises: an unreadable or non-UTF-8 file means our witness is not
288- # known to be there. This mirrors the JSON branch's ConfigUnreadable handling;
289- # uninstall() is where an unreadable file must stop, not here.
290- return False
291- return _block_bounds (text , owner ) is not None
292- try :
293- return _owns_anything (_load (path ), owner )
294- except ConfigUnreadable :
295- # A query never raises: if the file cannot be read, our witness is not known to be
296- # there. uninstall() is where an unreadable file must stop, not here.
297- return False
0 commit comments