@@ -28,7 +28,7 @@ def _is_cycode_command(command: str) -> bool:
2828
2929
3030def is_cycode_hook_entry (entry : dict ) -> bool :
31- """Detect Cycode hook entries in both Cursor (flat) and Claude Code (nested) shapes ."""
31+ """True if any hook inside ``entry`` is owned by Cycode ."""
3232 command = entry .get ('command' , '' )
3333 if _is_cycode_command (command ):
3434 return True
@@ -40,6 +40,31 @@ def is_cycode_hook_entry(entry: dict) -> bool:
4040 return False
4141
4242
43+ def _strip_cycode_from_entry (entry : dict ) -> Optional [dict ]:
44+ """Remove Cycode hooks from ``entry`` and return the remainder.
45+
46+ Returns ``None`` when nothing useful remains (Cursor-flat Cycode entry, or
47+ every nested hook was Cycode). Non-Cycode hooks co-located in the same
48+ entry are preserved.
49+ """
50+ # Cursor format: the entry itself IS a single hook command.
51+ if 'command' in entry and 'hooks' not in entry :
52+ return None if _is_cycode_command (entry .get ('command' , '' )) else entry
53+
54+ # Claude Code / Codex format: nested `hooks` list inside the entry.
55+ nested = entry .get ('hooks' )
56+ if isinstance (nested , list ):
57+ kept = [h for h in nested if not (isinstance (h , dict ) and _is_cycode_command (h .get ('command' , '' )))]
58+ if not kept :
59+ return None
60+ if len (kept ) == len (nested ):
61+ return entry # nothing Cycode-shaped inside; preserve identity
62+ return {** entry , 'hooks' : kept }
63+
64+ # Entry has neither shape we recognize — leave it alone defensively.
65+ return entry
66+
67+
4368def _load_hooks_file (hooks_path : Path ) -> Optional [dict ]:
4469 if not hooks_path .exists ():
4570 return None
@@ -108,50 +133,83 @@ def install_hooks(
108133
109134 for event , entries in rendered ['hooks' ].items ():
110135 existing ['hooks' ].setdefault (event , [])
111-
112- # Remove any existing Cycode entries for this event
113- existing ['hooks' ][event ] = [e for e in existing ['hooks' ][event ] if not is_cycode_hook_entry (e )]
114-
115- # Add new Cycode entries
136+ existing ['hooks' ][event ] = [
137+ stripped for e in existing ['hooks' ][event ] if (stripped := _strip_cycode_from_entry (e )) is not None
138+ ]
116139 for entry in entries :
117140 existing ['hooks' ][event ].append (entry )
118141
119- if _save_hooks_file (hooks_path , existing ):
120- return True , f'AI guardrails hooks installed: { hooks_path } '
121- return False , f'Failed to install hooks to { hooks_path } '
142+ if not _save_hooks_file (hooks_path , existing ):
143+ return False , f'Failed to install hooks to { hooks_path } '
122144
145+ message = f'AI guardrails hooks installed: { hooks_path } '
123146
124- def uninstall_hooks (ide : IDE , scope : str = 'user' , repo_path : Optional [Path ] = None ) -> tuple [bool , str ]:
125- """Remove Cycode AI guardrails hooks for ``ide``."""
126- hooks_path = ide .settings_path (scope , repo_path )
147+ # IDE-specific extras (e.g. Codex enables a TOML feature flag).
148+ extra_ok , extra_message = ide .post_install (scope , repo_path )
149+ if not extra_ok :
150+ return False , extra_message
151+ if extra_message :
152+ message = f'{ message } \n { extra_message } '
127153
128- existing = _load_hooks_file (hooks_path )
129- if existing is None :
130- return True , f'No hooks file found at { hooks_path } '
154+ return True , message
131155
156+
157+ def _strip_cycode_entries (existing : dict ) -> bool :
158+ """Mutate ``existing`` to drop Cycode hooks (surgically). Return True if anything changed."""
132159 modified = False
133160 for event in list (existing .get ('hooks' , {}).keys ()):
134- original_count = len (existing ['hooks' ][event ])
135- existing ['hooks' ][event ] = [e for e in existing ['hooks' ][event ] if not is_cycode_hook_entry (e )]
136- if len (existing ['hooks' ][event ]) != original_count :
137- modified = True
138- if not existing ['hooks' ][event ]:
161+ before = existing ['hooks' ][event ]
162+ after : list = []
163+ for e in before :
164+ stripped = _strip_cycode_from_entry (e )
165+ if stripped is None :
166+ modified = True
167+ continue
168+ if stripped is not e :
169+ modified = True
170+ after .append (stripped )
171+ if not after :
139172 del existing ['hooks' ][event ]
173+ else :
174+ existing ['hooks' ][event ] = after
175+ return modified
140176
177+
178+ def _persist_uninstall (hooks_path : Path , existing : dict , modified : bool ) -> tuple [bool , str ]:
179+ """Apply the uninstall result to disk and return ``(success, message)``."""
141180 if not modified :
142181 return True , 'No Cycode hooks found to remove'
143-
144182 if not existing .get ('hooks' ):
145183 try :
146184 hooks_path .unlink ()
147- return True , f'Removed hooks file: { hooks_path } '
148185 except Exception as e :
149186 logger .debug ('Failed to delete hooks file' , exc_info = e )
150187 return False , f'Failed to remove hooks file: { hooks_path } '
188+ return True , f'Removed hooks file: { hooks_path } '
189+ if not _save_hooks_file (hooks_path , existing ):
190+ return False , f'Failed to update hooks file: { hooks_path } '
191+ return True , f'Cycode hooks removed from: { hooks_path } '
192+
193+
194+ def uninstall_hooks (ide : IDE , scope : str = 'user' , repo_path : Optional [Path ] = None ) -> tuple [bool , str ]:
195+ """Remove Cycode AI guardrails hooks for ``ide``."""
196+ hooks_path = ide .settings_path (scope , repo_path )
197+
198+ existing = _load_hooks_file (hooks_path )
199+ if existing is None :
200+ return True , f'No hooks file found at { hooks_path } '
151201
152- if _save_hooks_file (hooks_path , existing ):
153- return True , f'Cycode hooks removed from: { hooks_path } '
154- return False , f'Failed to update hooks file: { hooks_path } '
202+ modified = _strip_cycode_entries (existing )
203+ file_ok , message = _persist_uninstall (hooks_path , existing , modified )
204+ if not file_ok :
205+ return False , message
206+
207+ extra_ok , extra_message = ide .post_uninstall (scope , repo_path )
208+ if not extra_ok :
209+ return False , extra_message
210+ if extra_message :
211+ message = f'{ message } \n { extra_message } '
212+ return True , message
155213
156214
157215def get_hooks_status (ide : IDE , scope : str = 'user' , repo_path : Optional [Path ] = None ) -> dict :
0 commit comments