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
cycode/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = '3.15.3.dev8' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
1
+ __version__ = '3.15.4.dev2' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
@@ -7,46 +7,11 @@ from typing import Optional
7
7
  import typer
8
8
  from rich.console import Console
9
9
 
10
- from cycode.cli.apps.ai_guardrails.consts import AIIDEType
11
-
12
10
  console = Console()
13
11
 
14
12
 
15
- def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]:
16
- """Validate IDE parameter, returning None for 'all'.
17
-
18
- Args:
19
- ide: IDE name string (e.g., 'cursor', 'claude-code', 'all')
20
-
21
- Returns:
22
- AIIDEType enum value, or None if 'all' was specified
23
-
24
- Raises:
25
- typer.Exit: If IDE is invalid
26
- """
27
- if ide.lower() == 'all':
28
- return None
29
- try:
30
- return AIIDEType(ide.lower())
31
- except ValueError:
32
- valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType])
33
- console.print(
34
- f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all',
35
- style='bold red',
36
- )
37
- raise typer.Exit(1) from None
38
-
39
-
40
13
  def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None:
41
- """Validate scope parameter.
42
-
43
- Args:
44
- scope: Scope string to validate
45
- allowed_scopes: Tuple of allowed scope values
46
-
47
- Raises:
48
- typer.Exit: If scope is invalid
49
- """
14
+ """Validate scope parameter."""
50
15
  if scope not in allowed_scopes:
51
16
  scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes)
52
17
  console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red')
@@ -54,15 +19,7 @@ def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo'
54
19
 
55
20
 
56
21
  def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]:
57
- """Resolve repository path, defaulting to current directory for repo scope.
58
-
59
- Args:
60
- scope: The command scope ('user' or 'repo')
61
- repo_path: Provided repo path or None
62
-
63
- Returns:
64
- Resolved Path for repo scope, None for user scope
65
- """
22
+ """Default repo_path to cwd for 'repo' scope; leave None for 'user' scope."""
66
23
  if scope == 'repo' and repo_path is None:
67
24
  return Path(os.getcwd())
68
25
  return repo_path
@@ -1,22 +1,6 @@
1
- """Constants for AI guardrails hooks management.
1
+ """Shared constants and policy/mode enums for AI guardrails."""
2
2
 
3
- Currently supports:
4
- - Cursor
5
- - Claude Code
6
- """
7
-
8
- import platform
9
- from copy import deepcopy
10
3
  from enum import Enum
11
- from pathlib import Path
12
- from typing import NamedTuple
13
-
14
-
15
- class AIIDEType(str, Enum):
16
- """Supported AI IDE types."""
17
-
18
- CURSOR = 'cursor'
19
- CLAUDE_CODE = 'claude-code'
20
4
 
21
5
 
22
6
  class PolicyMode(str, Enum):
@@ -33,123 +17,7 @@ class InstallMode(str, Enum):
33
17
  BLOCK = 'block'
34
18
 
35
19
 
36
- class IDEConfig(NamedTuple):
37
- """Configuration for an AI IDE."""
38
-
39
- name: str
40
- hooks_dir: Path
41
- repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor')
42
- hooks_file_name: str
43
- hook_events: list[str] # List of supported hook event names for this IDE
44
-
45
-
46
- def _get_cursor_hooks_dir() -> Path:
47
- """Get Cursor hooks directory based on platform."""
48
- if platform.system() == 'Darwin':
49
- return Path.home() / '.cursor'
50
- if platform.system() == 'Windows':
51
- return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
52
- # Linux
53
- return Path.home() / '.config' / 'Cursor'
54
-
55
-
56
- def _get_claude_code_hooks_dir() -> Path:
57
- """Get Claude Code hooks directory.
58
-
59
- Claude Code uses ~/.claude on all platforms.
60
- """
61
- return Path.home() / '.claude'
62
-
63
-
64
- # IDE-specific configurations
65
- IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
66
- AIIDEType.CURSOR: IDEConfig(
67
- name='Cursor',
68
- hooks_dir=_get_cursor_hooks_dir(),
69
- repo_hooks_subdir='.cursor',
70
- hooks_file_name='hooks.json',
71
- hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
72
- ),
73
- AIIDEType.CLAUDE_CODE: IDEConfig(
74
- name='Claude Code',
75
- hooks_dir=_get_claude_code_hooks_dir(),
76
- repo_hooks_subdir='.claude',
77
- hooks_file_name='settings.json',
78
- hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'],
79
- ),
80
- }
81
-
82
- # Default IDE
83
- DEFAULT_IDE = AIIDEType.CURSOR
84
-
85
- # Command used in hooks
20
+ # Base CLI commands invoked from installed hooks. IDE classes append --ide flags
21
+ # (and any other suffix) on top of these.
86
22
  CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
87
23
  CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start'
88
-
89
-
90
- def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
91
- """Get Cursor-specific hooks configuration."""
92
- config = IDE_CONFIGS[AIIDEType.CURSOR]
93
- command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND
94
- hooks = {event: [{'command': command}] for event in config.hook_events}
95
- hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}]
96
-
97
- return {
98
- 'version': 1,
99
- 'hooks': hooks,
100
- }
101
-
102
-
103
- def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
104
- """Get Claude Code-specific hooks configuration.
105
-
106
- Claude Code uses a different hook format with nested structure:
107
- - hooks are arrays of objects with 'hooks' containing command arrays
108
- - PreToolUse uses 'matcher' field to specify which tools to intercept
109
- """
110
- command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
111
-
112
- hook_entry = {'type': 'command', 'command': command}
113
- if async_mode:
114
- hook_entry['async'] = True
115
- hook_entry['timeout'] = 20
116
-
117
- return {
118
- 'hooks': {
119
- 'SessionStart': [
120
- {
121
- 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}],
122
- }
123
- ],
124
- 'UserPromptSubmit': [
125
- {
126
- 'hooks': [deepcopy(hook_entry)],
127
- }
128
- ],
129
- 'PreToolUse': [
130
- {
131
- 'matcher': 'Read',
132
- 'hooks': [deepcopy(hook_entry)],
133
- },
134
- {
135
- 'matcher': 'mcp__.*',
136
- 'hooks': [deepcopy(hook_entry)],
137
- },
138
- ],
139
- },
140
- }
141
-
142
-
143
- def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict:
144
- """Get the hooks configuration for a specific IDE.
145
-
146
- Args:
147
- ide: The AI IDE type
148
- async_mode: If True, hooks run asynchronously (non-blocking)
149
-
150
- Returns:
151
- Dict with hooks configuration for the specified IDE
152
- """
153
- if ide == AIIDEType.CLAUDE_CODE:
154
- return _get_claude_code_hooks_config(async_mode=async_mode)
155
- return _get_cursor_hooks_config(async_mode=async_mode)
@@ -1,8 +1,8 @@
1
- """
2
- Hooks manager for AI guardrails.
1
+ """Hooks manager for AI guardrails.
3
2
 
4
- Handles installation, removal, and status checking of AI IDE hooks.
5
- Supports multiple IDEs: Cursor, Claude Code (future).
3
+ Generic install/uninstall/status logic. All IDE-specific concerns (settings
4
+ paths, hooks template shape) live on the `IDE` instance; this module is
5
+ agent-agnostic.
6
6
  """
7
7
 
8
8
  import copy
@@ -12,47 +12,70 @@ from typing import Optional
12
12
 
13
13
  import yaml
14
14
 
15
- from cycode.cli.apps.ai_guardrails.consts import (
16
- DEFAULT_IDE,
17
- IDE_CONFIGS,
18
- AIIDEType,
19
- PolicyMode,
20
- get_hooks_config,
21
- )
15
+ from cycode.cli.apps.ai_guardrails.consts import PolicyMode
16
+ from cycode.cli.apps.ai_guardrails.ides.base import IDE
22
17
  from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME
23
18
  from cycode.logger import get_logger
24
19
 
25
20
  logger = get_logger('AI Guardrails Hooks')
26
21
 
27
22
 
28
- def get_hooks_path(scope: str, repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> Path:
29
- """Get the hooks.json path for the given scope and IDE.
23
+ _CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',)
24
+
25
+
26
+ def _is_cycode_command(command: str) -> bool:
27
+ return any(marker in command for marker in _CYCODE_COMMAND_MARKERS)
28
+
29
+
30
+ def is_cycode_hook_entry(entry: dict) -> bool:
31
+ """True if any hook inside ``entry`` is owned by Cycode."""
32
+ command = entry.get('command', '')
33
+ if _is_cycode_command(command):
34
+ return True
35
+
36
+ for hook in entry.get('hooks', []):
37
+ if isinstance(hook, dict) and _is_cycode_command(hook.get('command', '')):
38
+ return True
39
+
40
+ return False
41
+
42
+
43
+ def _strip_cycode_from_entry(entry: dict) -> Optional[dict]:
44
+ """Remove Cycode hooks from ``entry`` and return the remainder.
30
45
 
31
- Args:
32
- scope: 'user' for user-level hooks, 'repo' for repository-level hooks
33
- repo_path: Repository path (required if scope is 'repo')
34
- ide: The AI IDE type (default: Cursor)
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.
35
49
  """
36
- config = IDE_CONFIGS[ide]
37
- if scope == 'repo' and repo_path:
38
- return repo_path / config.repo_hooks_subdir / config.hooks_file_name
39
- return config.hooks_dir / config.hooks_file_name
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}
40
63
 
64
+ # Entry has neither shape we recognize — leave it alone defensively.
65
+ return entry
41
66
 
42
- def load_hooks_file(hooks_path: Path) -> Optional[dict]:
43
- """Load existing hooks.json file."""
67
+
68
+ def _load_hooks_file(hooks_path: Path) -> Optional[dict]:
44
69
  if not hooks_path.exists():
45
70
  return None
46
71
  try:
47
- content = hooks_path.read_text(encoding='utf-8')
48
- return json.loads(content)
72
+ return json.loads(hooks_path.read_text(encoding='utf-8'))
49
73
  except Exception as e:
50
74
  logger.debug('Failed to load hooks file', exc_info=e)
51
75
  return None
52
76
 
53
77
 
54
- def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
55
- """Save hooks.json file."""
78
+ def _save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
56
79
  try:
57
80
  hooks_path.parent.mkdir(parents=True, exist_ok=True)
58
81
  hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8')
@@ -62,39 +85,7 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
62
85
  return False
63
86
 
64
87
 
65
- _CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',)
66
-
67
-
68
- def _is_cycode_command(command: str) -> bool:
69
- return any(marker in command for marker in _CYCODE_COMMAND_MARKERS)
70
-
71
-
72
- def is_cycode_hook_entry(entry: dict) -> bool:
73
- """Check if a hook entry is from cycode-cli.
74
-
75
- Handles both Cursor format (flat) and Claude Code format (nested).
76
-
77
- Cursor format: {"command": "cycode ai-guardrails scan"}
78
- Claude Code format: {"hooks": [{"type": "command", "command": "cycode ai-guardrails scan --ide claude-code"}]}
79
- """
80
- # Check Cursor format (flat command)
81
- command = entry.get('command', '')
82
- if _is_cycode_command(command):
83
- return True
84
-
85
- # Check Claude Code format (nested hooks array)
86
- hooks = entry.get('hooks', [])
87
- for hook in hooks:
88
- if isinstance(hook, dict):
89
- hook_command = hook.get('command', '')
90
- if _is_cycode_command(hook_command):
91
- return True
92
-
93
- return False
94
-
95
-
96
- def _load_policy(policy_path: Path) -> dict:
97
- """Load existing policy file merged with defaults, or return defaults if not found."""
88
+ def _load_policy_dict(policy_path: Path) -> dict:
98
89
  if not policy_path.exists():
99
90
  return copy.deepcopy(DEFAULT_POLICY)
100
91
  try:
@@ -107,22 +98,13 @@ def _load_policy(policy_path: Path) -> dict:
107
98
  def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]:
108
99
  """Create or update the ai-guardrails.yaml policy file.
109
100
 
110
- If the file already exists, only the mode field is updated.
111
- If it doesn't exist, a new file is created from the default policy.
112
-
113
- Args:
114
- scope: 'user' for user-level, 'repo' for repository-level
115
- mode: The policy mode to set
116
- repo_path: Repository path (required if scope is 'repo')
117
-
118
- Returns:
119
- Tuple of (success, message)
101
+ If the file already exists, only the mode field is updated; otherwise a new
102
+ file is created from the default policy.
120
103
  """
121
104
  config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode'
122
105
  policy_path = config_dir / POLICY_FILE_NAME
123
106
 
124
- policy = _load_policy(policy_path)
125
-
107
+ policy = _load_policy_dict(policy_path)
126
108
  policy['mode'] = mode.value
127
109
 
128
110
  try:
@@ -135,135 +117,125 @@ def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] =
135
117
 
136
118
 
137
119
  def install_hooks(
120
+ ide: IDE,
138
121
  scope: str = 'user',
139
122
  repo_path: Optional[Path] = None,
140
- ide: AIIDEType = DEFAULT_IDE,
141
123
  report_mode: bool = False,
142
124
  ) -> tuple[bool, str]:
143
- """
144
- Install Cycode AI guardrails hooks.
145
-
146
- Args:
147
- scope: 'user' for user-level hooks, 'repo' for repository-level hooks
148
- repo_path: Repository path (required if scope is 'repo')
149
- ide: The AI IDE type (default: Cursor)
150
- report_mode: If True, install hooks in async mode (non-blocking)
151
-
152
- Returns:
153
- Tuple of (success, message)
154
- """
155
- hooks_path = get_hooks_path(scope, repo_path, ide)
125
+ """Install Cycode AI guardrails hooks for ``ide``."""
126
+ hooks_path = ide.settings_path(scope, repo_path)
156
127
 
157
- # Load existing hooks or create new
158
- existing = load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}}
128
+ existing = _load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}}
159
129
  existing.setdefault('version', 1)
160
130
  existing.setdefault('hooks', {})
161
131
 
162
- # Get IDE-specific hooks configuration
163
- hooks_config = get_hooks_config(ide, async_mode=report_mode)
132
+ rendered = ide.render_hooks_config(async_mode=report_mode)
164
133
 
165
- # Add/update Cycode hooks
166
- for event, entries in hooks_config['hooks'].items():
134
+ for event, entries in rendered['hooks'].items():
167
135
  existing['hooks'].setdefault(event, [])
168
-
169
- # Remove any existing Cycode entries for this event
170
- existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
171
-
172
- # 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
+ ]
173
139
  for entry in entries:
174
140
  existing['hooks'][event].append(entry)
175
141
 
176
- # Save
177
- if save_hooks_file(hooks_path, existing):
178
- return True, f'AI guardrails hooks installed: {hooks_path}'
179
- 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}'
180
144
 
145
+ message = f'AI guardrails hooks installed: {hooks_path}'
181
146
 
182
- def uninstall_hooks(
183
- scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
184
- ) -> tuple[bool, str]:
185
- """
186
- Remove Cycode AI guardrails hooks.
187
-
188
- Args:
189
- scope: 'user' for user-level hooks, 'repo' for repository-level hooks
190
- repo_path: Repository path (required if scope is 'repo')
191
- ide: The AI IDE type (default: Cursor)
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}'
192
153
 
193
- Returns:
194
- Tuple of (success, message)
195
- """
196
- hooks_path = get_hooks_path(scope, repo_path, ide)
154
+ return True, message
197
155
 
198
- existing = load_hooks_file(hooks_path)
199
- if existing is None:
200
- return True, f'No hooks file found at {hooks_path}'
201
156
 
202
- # Remove Cycode entries from all events
157
+ def _strip_cycode_entries(existing: dict) -> bool:
158
+ """Mutate ``existing`` to drop Cycode hooks (surgically). Return True if anything changed."""
203
159
  modified = False
204
160
  for event in list(existing.get('hooks', {}).keys()):
205
- original_count = len(existing['hooks'][event])
206
- existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
207
- if len(existing['hooks'][event]) != original_count:
208
- modified = True
209
- # Remove empty event lists
210
- 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:
211
172
  del existing['hooks'][event]
173
+ else:
174
+ existing['hooks'][event] = after
175
+ return modified
212
176
 
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)``."""
213
180
  if not modified:
214
181
  return True, 'No Cycode hooks found to remove'
215
-
216
- # Save or delete if empty
217
182
  if not existing.get('hooks'):
218
183
  try:
219
184
  hooks_path.unlink()
220
- return True, f'Removed hooks file: {hooks_path}'
221
185
  except Exception as e:
222
186
  logger.debug('Failed to delete hooks file', exc_info=e)
223
187
  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}'
224
192
 
225
- if save_hooks_file(hooks_path, existing):
226
- return True, f'Cycode hooks removed from: {hooks_path}'
227
- return False, f'Failed to update hooks file: {hooks_path}'
228
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)
229
197
 
230
- def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> dict:
231
- """
232
- Get the status of AI guardrails hooks.
198
+ existing = _load_hooks_file(hooks_path)
199
+ if existing is None:
200
+ return True, f'No hooks file found at {hooks_path}'
233
201
 
234
- Args:
235
- scope: 'user' for user-level hooks, 'repo' for repository-level hooks
236
- repo_path: Repository path (required if scope is 'repo')
237
- ide: The AI IDE type (default: Cursor)
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
238
206
 
239
- Returns:
240
- Dict with status information
241
- """
242
- hooks_path = get_hooks_path(scope, repo_path, ide)
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
243
213
 
244
- status = {
214
+
215
+ def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict:
216
+ """Return installation status of Cycode hooks for ``ide``."""
217
+ hooks_path = ide.settings_path(scope, repo_path)
218
+
219
+ status: dict = {
245
220
  'scope': scope,
246
- 'ide': ide.value,
247
- 'ide_name': IDE_CONFIGS[ide].name,
221
+ 'ide': ide.name,
222
+ 'ide_name': ide.display_name,
248
223
  'hooks_path': str(hooks_path),
249
224
  'file_exists': hooks_path.exists(),
250
225
  'cycode_installed': False,
251
226
  'hooks': {},
252
227
  }
253
228
 
254
- existing = load_hooks_file(hooks_path)
229
+ existing = _load_hooks_file(hooks_path)
255
230
  if existing is None:
256
231
  return status
257
232
 
258
- # Check each hook event for this IDE
259
- ide_config = IDE_CONFIGS[ide]
260
233
  has_cycode_hooks = False
261
- for event in ide_config.hook_events:
262
- # Handle event:matcher format
234
+ for event in ide.hook_events:
235
+ # '<event>:<matcher>' filters entries to a specific tool/matcher.
263
236
  if ':' in event:
264
237
  actual_event, matcher_prefix = event.split(':', 1)
265
238
  all_entries = existing.get('hooks', {}).get(actual_event, [])
266
- # Filter entries by matcher
267
239
  entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)]
268
240
  else:
269
241
  entries = existing.get('hooks', {}).get(event, [])
@@ -278,5 +250,4 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide:
278
250
  }
279
251
 
280
252
  status['cycode_installed'] = has_cycode_hooks
281
-
282
253
  return status
@@ -0,0 +1,45 @@
1
+ """Registry of supported AI guardrails IDE integrations.
2
+
3
+ Adding a new IDE: create `ides/<name>.py` with a subclass of `IDE`, import it
4
+ here, and include an instance in the `IDES` tuple. Nothing else in the package
5
+ needs to change.
6
+ """
7
+
8
+ import typer
9
+
10
+ from cycode.cli.apps.ai_guardrails.ides.base import IDE
11
+ from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode
12
+ from cycode.cli.apps.ai_guardrails.ides.codex import Codex
13
+ from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor
14
+
15
+ # Single source of truth: name → singleton instance.
16
+ # `--ide` choices and install/uninstall/status iteration both derive from this.
17
+ IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())}
18
+
19
+ # Default IDE used when `--ide` is omitted. Kept here so the value is colocated
20
+ # with the registry; no module outside `ides/` needs to know which IDE wins.
21
+ DEFAULT_IDE_NAME = 'cursor'
22
+
23
+
24
+ def get_ide(name: str) -> IDE:
25
+ """Look up the IDE integration registered under ``name``.
26
+
27
+ Raises ``typer.BadParameter`` when the name is unknown — surfaces as a
28
+ user-friendly CLI error rather than a KeyError stack trace.
29
+ """
30
+ ide = IDES.get(name.lower())
31
+ if ide is None:
32
+ valid = ', '.join(IDES.keys())
33
+ raise typer.BadParameter(f'Unknown IDE "{name}". Supported: {valid}.')
34
+ return ide
35
+
36
+
37
+ def resolve_ides(name: str) -> list[IDE]:
38
+ """Resolve an ``--ide`` argument to one or all IDE instances.
39
+
40
+ ``"all"`` returns every registered IDE; anything else returns a single
41
+ matching IDE (raising ``typer.BadParameter`` for unknown names).
42
+ """
43
+ if name.lower() == 'all':
44
+ return list(IDES.values())
45
+ return [get_ide(name)]