cycode 3.15.3.dev8__py3-none-any.whl → 3.15.4.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. cycode/__init__.py +1 -1
  2. cycode/cli/apps/ai_guardrails/command_utils.py +2 -45
  3. cycode/cli/apps/ai_guardrails/consts.py +3 -135
  4. cycode/cli/apps/ai_guardrails/hooks_manager.py +123 -152
  5. cycode/cli/apps/ai_guardrails/ides/__init__.py +45 -0
  6. cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +73 -0
  7. cycode/cli/apps/ai_guardrails/ides/base.py +176 -0
  8. cycode/cli/apps/ai_guardrails/ides/claude_code.py +369 -0
  9. cycode/cli/apps/ai_guardrails/ides/codex.py +310 -0
  10. cycode/cli/apps/ai_guardrails/ides/cursor.py +119 -0
  11. cycode/cli/apps/ai_guardrails/install_command.py +14 -23
  12. cycode/cli/apps/ai_guardrails/scan/handlers.py +102 -101
  13. cycode/cli/apps/ai_guardrails/scan/payload.py +14 -255
  14. cycode/cli/apps/ai_guardrails/scan/scan_command.py +60 -48
  15. cycode/cli/apps/ai_guardrails/scan/types.py +8 -30
  16. cycode/cli/apps/ai_guardrails/session_start_command.py +14 -78
  17. cycode/cli/apps/ai_guardrails/status_command.py +13 -16
  18. cycode/cli/apps/ai_guardrails/uninstall_command.py +12 -22
  19. cycode/cli/utils/jwt_utils.py +8 -0
  20. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/METADATA +3 -1
  21. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/RECORD +24 -21
  22. cycode/cli/apps/ai_guardrails/scan/claude_config.py +0 -159
  23. cycode/cli/apps/ai_guardrails/scan/cursor_config.py +0 -36
  24. cycode/cli/apps/ai_guardrails/scan/response_builders.py +0 -135
  25. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/WHEEL +0 -0
  26. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/entry_points.txt +0 -0
  27. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/licenses/LICENCE +0 -0
@@ -0,0 +1,310 @@
1
+ """Codex CLI IDE integration for AI guardrails."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import ClassVar, Optional
8
+
9
+ import tomli_w
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else: # pragma: no cover - py<3.11 fallback
14
+ import tomli as tomllib
15
+
16
+ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
17
+ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
18
+ from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
19
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
20
+ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
21
+ from cycode.cli.utils.jwt_utils import decode_jwt_unverified
22
+ from cycode.logger import get_logger
23
+
24
+ logger = get_logger('AI Guardrails Codex')
25
+
26
+ _CONFIG_DIR_NAME = '.codex'
27
+ _HOOKS_FILE_NAME = 'hooks.json'
28
+ _CONFIG_TOML_NAME = 'config.toml'
29
+ _AUTH_JSON_NAME = 'auth.json'
30
+ _CODEX_HOME_ENV_VAR = 'CODEX_HOME'
31
+
32
+ _HOOK_EVENTS = ('UserPromptSubmit', 'PreToolUse:mcp')
33
+ _CODEX_EVENT_NAMES = frozenset(e.split(':', 1)[0] for e in _HOOK_EVENTS)
34
+
35
+ _SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide codex'
36
+ _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide codex'
37
+
38
+
39
+ def _codex_home() -> Path:
40
+ """Resolve Codex's user-scope home directory.
41
+
42
+ Honors ``$CODEX_HOME`` per Codex's documented override; falls back to
43
+ ``~/.codex``.
44
+ """
45
+ override = os.environ.get(_CODEX_HOME_ENV_VAR)
46
+ if override:
47
+ return Path(override)
48
+ return Path.home() / _CONFIG_DIR_NAME
49
+
50
+
51
+ def _codex_config_toml_path(scope: str, repo_path: Optional[Path] = None) -> Path:
52
+ """Return the Codex ``config.toml`` path for the given scope."""
53
+ if scope == 'repo' and repo_path:
54
+ return repo_path / _CONFIG_DIR_NAME / _CONFIG_TOML_NAME
55
+ return _codex_home() / _CONFIG_TOML_NAME
56
+
57
+
58
+ def _load_codex_config(config_path: Optional[Path] = None) -> Optional[dict]:
59
+ """Load and parse Codex's ``config.toml``. Returns None on missing/invalid."""
60
+ path = config_path or (_codex_home() / _CONFIG_TOML_NAME)
61
+ if not path.exists():
62
+ logger.debug('Codex config file not found, %s', {'path': str(path)})
63
+ return None
64
+ try:
65
+ with path.open('rb') as f:
66
+ return tomllib.load(f)
67
+ except Exception as e:
68
+ logger.debug('Failed to load Codex config file, %s', {'path': str(path)}, exc_info=e)
69
+ return None
70
+
71
+
72
+ def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]:
73
+ """Best-effort extraction of the signed-in Codex user's email.
74
+
75
+ Reads ``~/.codex/auth.json`` and decodes the JWT in ``tokens.id_token``
76
+ to pull the ``email`` claim. Returns None if auth.json is missing
77
+ (``OPENAI_API_KEY``-only setups, OS keychain credentials) or unreadable.
78
+ """
79
+ path = auth_path or (_codex_home() / _AUTH_JSON_NAME)
80
+ if not path.exists():
81
+ logger.debug('Codex auth file not found, %s', {'path': str(path)})
82
+ return None
83
+ try:
84
+ auth = json.loads(path.read_text(encoding='utf-8'))
85
+ except (OSError, json.JSONDecodeError) as e:
86
+ logger.debug('Failed to load Codex auth file, %s', {'path': str(path)}, exc_info=e)
87
+ return None
88
+
89
+ token = (auth.get('tokens') or {}).get('id_token')
90
+ if not token:
91
+ return None
92
+ claims = decode_jwt_unverified(token)
93
+ if not claims:
94
+ return None
95
+ return claims.get('email')
96
+
97
+
98
+ def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]:
99
+ """Find ``~/.codex/plugins/cache/<marketplace>/<plugin>/<hash>/``.
100
+
101
+ The trailing segment is a content hash. If multiple are cached, pick the
102
+ most recently modified.
103
+ """
104
+ base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name
105
+ if not base.is_dir():
106
+ return None
107
+ candidates = [d for d in base.iterdir() if d.is_dir()]
108
+ if not candidates:
109
+ return None
110
+ return max(candidates, key=lambda d: d.stat().st_mtime)
111
+
112
+
113
+ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
114
+ """Read one Codex plugin's manifest + MCP servers.
115
+
116
+ Codex's manifest references the MCP file via a path string in the
117
+ ``mcpServers`` field (default ``./.mcp.json``); the target file is either
118
+ a bare ``{name: cfg}`` map or wrapped in ``{"mcpServers": {...}}``.
119
+ """
120
+ manifest = load_plugin_json(plugin_dir / '.codex-plugin' / 'plugin.json')
121
+ entry: dict = {}
122
+ if not manifest:
123
+ return entry, {}
124
+
125
+ for field in ('name', 'version', 'description'):
126
+ if field in manifest:
127
+ entry[field] = manifest[field]
128
+
129
+ mcp_ref = manifest.get('mcpServers')
130
+ if not mcp_ref:
131
+ return entry, {}
132
+ mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {}
133
+ servers = mcp_doc.get('mcpServers', mcp_doc)
134
+ if not isinstance(servers, dict):
135
+ servers = {}
136
+ if servers:
137
+ entry['mcp_server_names'] = list(servers.keys())
138
+ return entry, servers
139
+
140
+
141
+ def _resolve_codex_plugins(config: dict) -> tuple[dict, dict]:
142
+ """Walk enabled ``[plugins."<plugin>@<marketplace>"]`` entries."""
143
+ return walk_enabled_plugins(
144
+ plugin_entries=config.get('plugins') or {},
145
+ is_enabled=lambda s: isinstance(s, dict) and bool(s.get('enabled')),
146
+ locate_dir=_resolve_codex_plugin_dir,
147
+ read_plugin=_read_codex_plugin,
148
+ )
149
+
150
+
151
+ def _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
152
+ """Set ``[features] hooks = true`` in Codex's ``config.toml``.
153
+
154
+ Codex's hook scripts are gated behind this feature flag. We preserve any
155
+ existing keys and create the file (+ parent dir) when missing.
156
+ """
157
+ config_path = _codex_config_toml_path(scope, repo_path)
158
+
159
+ config: dict = {}
160
+ if config_path.exists():
161
+ try:
162
+ with config_path.open('rb') as f:
163
+ config = tomllib.load(f)
164
+ except Exception as e:
165
+ logger.error('Failed to parse Codex config.toml, %s', {'path': str(config_path)}, exc_info=e)
166
+ return False, f'Failed to parse existing Codex config at {config_path}'
167
+
168
+ features = config.get('features')
169
+ if not isinstance(features, dict):
170
+ features = {}
171
+ features['hooks'] = True
172
+ config['features'] = features
173
+
174
+ try:
175
+ config_path.parent.mkdir(parents=True, exist_ok=True)
176
+ with config_path.open('wb') as f:
177
+ tomli_w.dump(config, f)
178
+ return True, f'Enabled hooks feature in {config_path}'
179
+ except Exception as e:
180
+ logger.error('Failed to write Codex config.toml, %s', {'path': str(config_path)}, exc_info=e)
181
+ return False, f'Failed to write Codex config at {config_path}'
182
+
183
+
184
+ class Codex(IDE):
185
+ name: ClassVar[str] = 'codex'
186
+ display_name: ClassVar[str] = 'Codex'
187
+ hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
188
+
189
+ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
190
+ if scope == 'repo' and repo_path:
191
+ return repo_path / _CONFIG_DIR_NAME / _HOOKS_FILE_NAME
192
+ return _codex_home() / _HOOKS_FILE_NAME
193
+
194
+ def render_hooks_config(self, async_mode: bool = False) -> dict:
195
+ # Codex's TOML `async: true` flag is unimplemented; shell-background via
196
+ # `&` is the working mechanism. SessionStart stays sync so the
197
+ # conversation context is registered before any scan hook fires.
198
+ bg = ' &' if async_mode else ''
199
+ scan_cmd = f'{_SCAN_COMMAND}{bg}'
200
+ return {
201
+ 'hooks': {
202
+ 'SessionStart': [
203
+ {
204
+ 'matcher': 'startup|clear',
205
+ 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
206
+ }
207
+ ],
208
+ 'UserPromptSubmit': [
209
+ {
210
+ 'hooks': [{'type': 'command', 'command': scan_cmd}],
211
+ }
212
+ ],
213
+ 'PreToolUse': [
214
+ {
215
+ 'matcher': 'mcp__.*',
216
+ 'hooks': [{'type': 'command', 'command': scan_cmd}],
217
+ },
218
+ ],
219
+ },
220
+ }
221
+
222
+ def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
223
+ return _enable_codex_hooks_feature(scope, repo_path)
224
+
225
+ def matches_payload(self, raw_payload: dict) -> bool:
226
+ return raw_payload.get('hook_event_name', '') in _CODEX_EVENT_NAMES
227
+
228
+ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
229
+ hook_event_name = raw_payload.get('hook_event_name', '')
230
+ tool_name = raw_payload.get('tool_name', '')
231
+ tool_input = raw_payload.get('tool_input')
232
+
233
+ if hook_event_name == 'UserPromptSubmit':
234
+ canonical_event: AiHookEventType | str = AiHookEventType.PROMPT
235
+ elif hook_event_name == 'PreToolUse' and tool_name.startswith('mcp__'):
236
+ canonical_event = AiHookEventType.MCP_EXECUTION
237
+ else:
238
+ canonical_event = hook_event_name
239
+
240
+ mcp_server_name = None
241
+ mcp_tool_name = None
242
+ mcp_arguments = None
243
+ if tool_name.startswith('mcp__'):
244
+ parts = tool_name.split('__')
245
+ if len(parts) >= 2:
246
+ mcp_server_name = parts[1]
247
+ if len(parts) >= 3:
248
+ mcp_tool_name = parts[2]
249
+ mcp_arguments = tool_input
250
+
251
+ return AIHookPayload(
252
+ event_name=canonical_event,
253
+ conversation_id=raw_payload.get('session_id'),
254
+ generation_id=raw_payload.get('turn_id'),
255
+ ide_user_email=_email_from_auth(),
256
+ model=raw_payload.get('model'),
257
+ ide_provider=self.name,
258
+ prompt=raw_payload.get('prompt', ''),
259
+ mcp_server_name=mcp_server_name,
260
+ mcp_tool_name=mcp_tool_name,
261
+ mcp_arguments=mcp_arguments,
262
+ )
263
+
264
+ def build_hook_response(self, decision: HookDecision) -> dict:
265
+ # Codex accepts the same hook response shapes as Claude Code:
266
+ # - PROMPT: empty for allow, {"decision": "block", "reason": ...} for deny
267
+ # - PreToolUse: hookSpecificOutput.permissionDecision
268
+ if decision.event_type == AiHookEventType.PROMPT:
269
+ if decision.action == DecisionAction.ALLOW:
270
+ return {}
271
+ return {'decision': 'block', 'reason': decision.user_message or ''}
272
+
273
+ if decision.action == DecisionAction.ALLOW:
274
+ return {
275
+ 'hookSpecificOutput': {
276
+ 'hookEventName': 'PreToolUse',
277
+ 'permissionDecision': 'allow',
278
+ }
279
+ }
280
+ return {
281
+ 'hookSpecificOutput': {
282
+ 'hookEventName': 'PreToolUse',
283
+ 'permissionDecision': decision.action.value, # 'deny' or 'ask'
284
+ 'permissionDecisionReason': decision.user_message or '',
285
+ }
286
+ }
287
+
288
+ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
289
+ return AIHookPayload(
290
+ conversation_id=raw_payload.get('session_id'),
291
+ ide_user_email=_email_from_auth(),
292
+ model=raw_payload.get('model'),
293
+ ide_provider=self.name,
294
+ ide_version=raw_payload.get('codex_version'),
295
+ source=raw_payload.get('source'),
296
+ )
297
+
298
+ def get_user_email(self) -> Optional[str]:
299
+ return _email_from_auth()
300
+
301
+ def get_session_context(self) -> tuple[dict, dict]:
302
+ config = _load_codex_config()
303
+ if not config:
304
+ return {}, {}
305
+ # Codex stores MCP servers under `[mcp_servers.<name>]`. Plugin-contributed
306
+ # servers (via `[plugins."<plugin>@<marketplace>"]`) merge on top.
307
+ mcp_servers: dict = dict(config.get('mcp_servers') or {})
308
+ plugin_mcp, enriched_plugins = _resolve_codex_plugins(config)
309
+ mcp_servers.update(plugin_mcp)
310
+ return mcp_servers, enriched_plugins
@@ -0,0 +1,119 @@
1
+ """Cursor IDE integration for AI guardrails."""
2
+
3
+ import json
4
+ import platform
5
+ from pathlib import Path
6
+ from typing import ClassVar, Optional
7
+
8
+ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
9
+ from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
10
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
11
+ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
12
+ from cycode.logger import get_logger
13
+
14
+ logger = get_logger('AI Guardrails Cursor')
15
+
16
+ _CURSOR_EVENT_MAPPING: dict[str, AiHookEventType] = {
17
+ 'beforeSubmitPrompt': AiHookEventType.PROMPT,
18
+ 'beforeReadFile': AiHookEventType.FILE_READ,
19
+ 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION,
20
+ }
21
+
22
+ _HOOKS_FILE_NAME = 'hooks.json'
23
+ _REPO_SUBDIR = '.cursor'
24
+ _MCP_CONFIG_FILENAME = 'mcp.json'
25
+
26
+ # Cursor was the original default IDE — its scan command omits --ide to stay
27
+ # byte-identical with already-installed hooks.json files. Session-start is
28
+ # always explicit because it was introduced after Claude Code support.
29
+ _SCAN_COMMAND = CYCODE_SCAN_PROMPT_COMMAND
30
+ _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide cursor'
31
+
32
+
33
+ def _user_hooks_dir() -> Path:
34
+ """Per-platform Cursor user-scope settings directory."""
35
+ if platform.system() == 'Darwin':
36
+ return Path.home() / '.cursor'
37
+ if platform.system() == 'Windows':
38
+ return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
39
+ return Path.home() / '.config' / 'Cursor'
40
+
41
+
42
+ def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]:
43
+ """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
44
+ path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME)
45
+ if not path.exists():
46
+ logger.debug('Cursor MCP config file not found, %s', {'path': str(path)})
47
+ return None
48
+ try:
49
+ return json.loads(path.read_text(encoding='utf-8'))
50
+ except Exception as e:
51
+ logger.debug('Failed to load Cursor MCP config file', exc_info=e)
52
+ return None
53
+
54
+
55
+ class Cursor(IDE):
56
+ name: ClassVar[str] = 'cursor'
57
+ display_name: ClassVar[str] = 'Cursor'
58
+ hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING)
59
+
60
+ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
61
+ if scope == 'repo' and repo_path:
62
+ return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
63
+ return _user_hooks_dir() / _HOOKS_FILE_NAME
64
+
65
+ def render_hooks_config(self, async_mode: bool = False) -> dict:
66
+ command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND
67
+ hooks = {event: [{'command': command}] for event in self.hook_events}
68
+ hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}]
69
+ return {'version': 1, 'hooks': hooks}
70
+
71
+ def matches_payload(self, raw_payload: dict) -> bool:
72
+ return raw_payload.get('hook_event_name', '') in _CURSOR_EVENT_MAPPING
73
+
74
+ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
75
+ cursor_event_name = raw_payload.get('hook_event_name', '')
76
+ canonical_event = _CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name)
77
+ return AIHookPayload(
78
+ event_name=canonical_event,
79
+ conversation_id=raw_payload.get('conversation_id'),
80
+ generation_id=raw_payload.get('generation_id'),
81
+ ide_user_email=raw_payload.get('user_email'),
82
+ model=raw_payload.get('model'),
83
+ ide_provider=self.name,
84
+ ide_version=raw_payload.get('cursor_version'),
85
+ prompt=raw_payload.get('prompt', ''),
86
+ file_path=raw_payload.get('file_path') or raw_payload.get('path'),
87
+ mcp_server_name=raw_payload.get('command'),
88
+ mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'),
89
+ mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')),
90
+ )
91
+
92
+ def build_hook_response(self, decision: HookDecision) -> dict:
93
+ if decision.event_type == AiHookEventType.PROMPT:
94
+ if decision.action == DecisionAction.ALLOW:
95
+ return {'continue': True}
96
+ return {'continue': False, 'user_message': decision.user_message or ''}
97
+
98
+ # FILE_READ / MCP_EXECUTION → permission shape
99
+ if decision.action == DecisionAction.ALLOW:
100
+ return {'permission': 'allow'}
101
+ return {
102
+ 'permission': decision.action.value, # 'deny' or 'ask'
103
+ 'user_message': decision.user_message or '',
104
+ 'agent_message': decision.agent_message or '',
105
+ }
106
+
107
+ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
108
+ return AIHookPayload(
109
+ conversation_id=raw_payload.get('conversation_id'),
110
+ ide_user_email=raw_payload.get('user_email'),
111
+ model=raw_payload.get('model'),
112
+ ide_provider=self.name,
113
+ ide_version=raw_payload.get('cursor_version'),
114
+ )
115
+
116
+ def get_session_context(self) -> tuple[dict, dict]:
117
+ config = _load_cursor_mcp_config()
118
+ mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {}
119
+ return mcp_servers, {}
@@ -5,14 +5,10 @@ from typing import Annotated, Optional
5
5
 
6
6
  import typer
7
7
 
8
- from cycode.cli.apps.ai_guardrails.command_utils import (
9
- console,
10
- resolve_repo_path,
11
- validate_and_parse_ide,
12
- validate_scope,
13
- )
14
- from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType, InstallMode, PolicyMode
8
+ from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope
9
+ from cycode.cli.apps.ai_guardrails.consts import InstallMode, PolicyMode
15
10
  from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks
11
+ from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides
16
12
 
17
13
 
18
14
  def install_command(
@@ -29,9 +25,9 @@ def install_command(
29
25
  str,
30
26
  typer.Option(
31
27
  '--ide',
32
- help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.',
28
+ help=f'IDE to install hooks for ({", ".join(IDES)}, or "all" for every supported IDE).',
33
29
  ),
34
- ] = AIIDEType.CURSOR.value,
30
+ ] = DEFAULT_IDE_NAME,
35
31
  repo_path: Annotated[
36
32
  Optional[Path],
37
33
  typer.Option(
@@ -55,35 +51,30 @@ def install_command(
55
51
  ) -> None:
56
52
  """Install AI guardrails hooks for supported IDEs.
57
53
 
58
- This command configures the specified IDE to use Cycode for scanning prompts, file reads,
59
- and MCP tool calls for secrets before they are sent to AI models.
54
+ Configures the specified IDE to use Cycode for scanning prompts, file reads,
55
+ and MCP tool calls for secrets before they reach the AI model.
60
56
 
61
57
  Examples:
62
58
  cycode ai-guardrails install # Install in report mode (default)
63
59
  cycode ai-guardrails install --mode block # Install in block mode
64
60
  cycode ai-guardrails install --scope repo # Install for current repo only
65
- cycode ai-guardrails install --ide cursor # Install for Cursor IDE
66
- cycode ai-guardrails install --ide all # Install for all supported IDEs
67
- cycode ai-guardrails install --scope repo --repo-path /path/to/repo
61
+ cycode ai-guardrails install --ide claude-code # Install for a specific IDE
62
+ cycode ai-guardrails install --ide all # Install for every supported IDE
68
63
  """
69
- # Validate inputs
70
64
  validate_scope(scope)
71
65
  repo_path = resolve_repo_path(scope, repo_path)
72
- ide_type = validate_and_parse_ide(ide)
66
+ ides_to_install = resolve_ides(ide)
73
67
 
74
- ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type]
68
+ report_mode = mode == InstallMode.REPORT
75
69
 
76
70
  results: list[tuple[str, bool, str]] = []
77
71
  for current_ide in ides_to_install:
78
- ide_name = IDE_CONFIGS[current_ide].name
79
- report_mode = mode == InstallMode.REPORT
80
- success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode)
81
- results.append((ide_name, success, message))
72
+ success, message = install_hooks(current_ide, scope, repo_path, report_mode=report_mode)
73
+ results.append((current_ide.display_name, success, message))
82
74
 
83
- # Report results for each IDE
84
75
  any_success = False
85
76
  all_success = True
86
- for _ide_name, success, message in results:
77
+ for _name, success, message in results:
87
78
  if success:
88
79
  console.print(f'[green]✓[/] {message}')
89
80
  any_success = True