Skip to content

Commit 3d15222

Browse files
Ilanlidoclaude
andauthored
CM-62984 add codex cli support (#461)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 08499d3 commit 3d15222

14 files changed

Lines changed: 1058 additions & 109 deletions

File tree

cycode/cli/apps/ai_guardrails/hooks_manager.py

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def _is_cycode_command(command: str) -> bool:
2828

2929

3030
def 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+
4368
def _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

157215
def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict:

cycode/cli/apps/ai_guardrails/ides/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99

1010
from cycode.cli.apps.ai_guardrails.ides.base import IDE
1111
from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode
12+
from cycode.cli.apps.ai_guardrails.ides.codex import Codex
1213
from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor
1314

1415
# Single source of truth: name → singleton instance.
1516
# `--ide` choices and install/uninstall/status iteration both derive from this.
16-
IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode())}
17+
IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())}
1718

1819
# Default IDE used when `--ide` is omitted. Kept here so the value is colocated
1920
# with the registry; no module outside `ides/` needs to know which IDE wins.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Shared plugin-resolution helpers for IDE integrations.
2+
3+
Both Claude Code and Codex use the same ``<plugin>@<marketplace>`` key convention
4+
and emit the same telemetry shape — only the marketplace layout and manifest
5+
location differ. ``walk_enabled_plugins`` is the IDE-agnostic loop; each IDE
6+
supplies the two callables that vary (``locate_dir`` + ``read_plugin``).
7+
"""
8+
9+
import json
10+
from pathlib import Path
11+
from typing import Any, Callable, Optional
12+
13+
from cycode.logger import get_logger
14+
15+
logger = get_logger('AI Guardrails Plugins')
16+
17+
18+
def load_plugin_json(path: Path) -> Optional[dict]:
19+
"""Load a JSON file inside a plugin directory; None if missing or invalid."""
20+
if not path.exists():
21+
return None
22+
try:
23+
return json.loads(path.read_text(encoding='utf-8'))
24+
except Exception as e:
25+
logger.debug('Failed to load plugin file, %s', {'path': str(path)}, exc_info=e)
26+
return None
27+
28+
29+
def walk_enabled_plugins(
30+
plugin_entries: dict[str, Any],
31+
is_enabled: Callable[[Any], bool],
32+
locate_dir: Callable[[str, str], Optional[Path]],
33+
read_plugin: Callable[[Path], tuple[dict, dict]],
34+
) -> tuple[dict, dict]:
35+
"""Iterate enabled plugins; merge their MCP servers and metadata.
36+
37+
Args:
38+
plugin_entries: ``{<plugin>@<marketplace>: settings}`` map from the IDE config.
39+
is_enabled: returns True if ``settings`` indicates the plugin is on
40+
(e.g. ``bool(settings)`` for Claude, ``settings.get('enabled')`` for Codex).
41+
locate_dir: given ``(plugin_name, marketplace)``, returns the plugin's
42+
filesystem path or None if it can't be resolved.
43+
read_plugin: given the plugin path, returns ``(entry_fields, servers)``:
44+
``entry_fields`` are extra metadata to attach to the inventory entry
45+
(name/version/description/...), ``servers`` are MCP servers contributed.
46+
47+
Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without
48+
``@`` (or that fail to resolve to a directory) still appear in the
49+
inventory with just ``{'enabled': True}`` so we don't silently drop them.
50+
"""
51+
merged_mcp: dict = {}
52+
enriched: dict = {}
53+
54+
for plugin_key, settings in plugin_entries.items():
55+
if not is_enabled(settings):
56+
continue
57+
58+
entry: dict = {'enabled': True}
59+
enriched[plugin_key] = entry
60+
61+
if '@' not in plugin_key:
62+
continue
63+
plugin_name, marketplace = plugin_key.split('@', 1)
64+
65+
plugin_dir = locate_dir(plugin_name, marketplace)
66+
if plugin_dir is None:
67+
continue
68+
69+
plugin_fields, servers = read_plugin(plugin_dir)
70+
entry.update(plugin_fields)
71+
merged_mcp.update(servers)
72+
73+
return merged_mcp, enriched

cycode/cli/apps/ai_guardrails/ides/base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ def render_hooks_config(self, async_mode: bool = False) -> dict:
108108
``hooks_manager`` can treat them uniformly.
109109
"""
110110

111+
def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
112+
"""Run IDE-specific actions after the hooks file is written.
113+
114+
Default: no-op success. Override to perform extra setup that doesn't
115+
belong in the hooks file itself — e.g. Codex enables a
116+
``[features] codex_hooks = true`` flag in its TOML config.
117+
118+
Returns ``(success, message)``. If ``success`` is False, the overall
119+
install is considered failed.
120+
"""
121+
return True, ''
122+
123+
def post_uninstall(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
124+
"""Run IDE-specific cleanup after the hooks file is removed.
125+
126+
Default: no-op success. Override to undo whatever ``post_install``
127+
wrote outside the hooks file.
128+
"""
129+
return True, ''
130+
111131
# --- runtime scan ---
112132

113133
@abstractmethod

cycode/cli/apps/ai_guardrails/ides/claude_code.py

Lines changed: 32 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import ClassVar, Optional
88

99
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
10+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
1011
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1112
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1213
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -127,7 +128,7 @@ def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
127128
"""Load and parse `~/.claude.json`. Returns None if missing/invalid."""
128129
path = config_path or _CLAUDE_CONFIG_PATH
129130
if not path.exists():
130-
logger.debug('Claude config file not found', extra={'path': str(path)})
131+
logger.debug('Claude config file not found, %s', {'path': str(path)})
131132
return None
132133
try:
133134
return json.loads(path.read_text(encoding='utf-8'))
@@ -150,7 +151,7 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]
150151
"""Load and parse `~/.claude/settings.json`. Returns None if missing/invalid."""
151152
path = settings_path or _CLAUDE_SETTINGS_PATH
152153
if not path.exists():
153-
logger.debug('Claude settings file not found', extra={'path': str(path)})
154+
logger.debug('Claude settings file not found, %s', {'path': str(path)})
154155
return None
155156
try:
156157
return json.loads(path.read_text(encoding='utf-8'))
@@ -171,69 +172,47 @@ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
171172
return path if path.is_dir() else None
172173

173174

174-
def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]:
175-
"""Load and parse a JSON file inside a plugin directory.
175+
def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
176+
"""Read one Claude Code plugin's manifest + MCP servers.
176177
177-
Returns None if the file is missing, unreadable, or has invalid JSON.
178+
Claude hardcodes the MCP file at ``<plugin_dir>/.mcp.json`` and always
179+
wraps it as ``{"mcpServers": {...}}``.
178180
"""
179-
target = plugin_path / relative_path
180-
if not target.exists():
181-
return None
182-
try:
183-
return json.loads(target.read_text(encoding='utf-8'))
184-
except Exception as e:
185-
logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e)
186-
return None
181+
manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {}
182+
entry: dict = {}
183+
for field in ('name', 'version', 'description'):
184+
if field in manifest:
185+
entry[field] = manifest[field]
187186

187+
mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {}
188+
servers: dict = mcp_config.get('mcpServers') or {}
189+
if servers:
190+
entry['mcp_server_names'] = list(servers.keys())
191+
return entry, servers
188192

189-
def resolve_plugins(settings: dict) -> tuple[dict, dict]:
190-
"""Resolve enabled plugins to their MCP servers and metadata.
191193

192-
Walks ``enabledPlugins`` from claude settings, resolves each plugin's
193-
marketplace directory via ``extraKnownMarketplaces``, and reads:
194-
- ``<path>/.mcp.json`` for MCP servers (merged into a flat dict)
195-
- ``<path>/.claude-plugin/plugin.json`` for metadata (name, version, description)
194+
def resolve_plugins(settings: dict) -> tuple[dict, dict]:
195+
"""Walk Claude Code's ``enabledPlugins`` via the shared plugin walker.
196196
197-
Returns ``(merged_mcp_servers, enriched_plugins)``.
197+
Each enabled plugin's marketplace is resolved through
198+
``extraKnownMarketplaces`` to a directory; the rest of the work
199+
(manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``.
198200
"""
199201
enabled = settings.get('enabledPlugins') or {}
200202
marketplaces = settings.get('extraKnownMarketplaces') or {}
201-
merged_mcp: dict = {}
202-
enriched: dict = {}
203203

204-
for plugin_key, is_enabled in enabled.items():
205-
if not is_enabled:
206-
continue
207-
208-
entry: dict = {'enabled': True}
209-
enriched[plugin_key] = entry
210-
211-
if '@' not in plugin_key:
212-
continue
213-
214-
_plugin_name, marketplace_name = plugin_key.split('@', 1)
204+
def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]:
215205
marketplace = marketplaces.get(marketplace_name)
216206
if not marketplace:
217-
continue
218-
219-
plugin_path = _resolve_marketplace_path(marketplace)
220-
if plugin_path is None:
221-
continue
222-
223-
metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {}
224-
for field in ('name', 'version', 'description'):
225-
if field in metadata:
226-
entry[field] = metadata[field]
227-
228-
mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {}
229-
plugin_server_names = []
230-
for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items():
231-
merged_mcp[server_name] = server_cfg
232-
plugin_server_names.append(server_name)
233-
if plugin_server_names:
234-
entry['mcp_server_names'] = plugin_server_names
207+
return None
208+
return _resolve_marketplace_path(marketplace)
235209

236-
return merged_mcp, enriched
210+
return walk_enabled_plugins(
211+
plugin_entries=enabled,
212+
is_enabled=bool,
213+
locate_dir=_locate,
214+
read_plugin=_read_claude_plugin,
215+
)
237216

238217

239218
# --- IDE integration ----------------------------------------------------------
@@ -260,6 +239,7 @@ def render_hooks_config(self, async_mode: bool = False) -> dict:
260239
'hooks': {
261240
'SessionStart': [
262241
{
242+
'matcher': 'startup|clear',
263243
'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
264244
}
265245
],

0 commit comments

Comments
 (0)