Skip to content

Commit 08499d3

Browse files
Ilanlidoclaude
andauthored
CM-64678: refactor ai-guardrails to single-file-per-IDE abstraction (#459)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db56b13 commit 08499d3

30 files changed

Lines changed: 1618 additions & 2035 deletions

cycode/cli/apps/ai_guardrails/command_utils.py

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,62 +7,19 @@
77
import typer
88
from rich.console import Console
99

10-
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
11-
1210
console = Console()
1311

1412

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-
4013
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."""
5015
if scope not in allowed_scopes:
5116
scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes)
5217
console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red')
5318
raise typer.Exit(1)
5419

5520

5621
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."""
6623
if scope == 'repo' and repo_path is None:
6724
return Path(os.getcwd())
6825
return repo_path
Lines changed: 3 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,6 @@
1-
"""Constants for AI guardrails hooks management.
1+
"""Shared constants and policy/mode enums for AI guardrails."""
22

3-
Currently supports:
4-
- Cursor
5-
- Claude Code
6-
"""
7-
8-
import platform
9-
from copy import deepcopy
103
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'
204

215

226
class PolicyMode(str, Enum):
@@ -33,123 +17,7 @@ class InstallMode(str, Enum):
3317
BLOCK = 'block'
3418

3519

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.
8622
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
8723
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)

0 commit comments

Comments
 (0)