Skip to content

Commit dcee451

Browse files
Ilanlidoclaude
andauthored
CM-58331 support claude code (#379)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 043ab3b commit dcee451

18 files changed

+1039
-114
lines changed

cycode/cli/apps/ai_guardrails/command_utils.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,26 @@
1212
console = Console()
1313

1414

15-
def validate_and_parse_ide(ide: str) -> AIIDEType:
16-
"""Validate IDE parameter and convert to AIIDEType enum.
15+
def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]:
16+
"""Validate IDE parameter, returning None for 'all'.
1717
1818
Args:
19-
ide: IDE name string (e.g., 'cursor')
19+
ide: IDE name string (e.g., 'cursor', 'claude-code', 'all')
2020
2121
Returns:
22-
AIIDEType enum value
22+
AIIDEType enum value, or None if 'all' was specified
2323
2424
Raises:
2525
typer.Exit: If IDE is invalid
2626
"""
27+
if ide.lower() == 'all':
28+
return None
2729
try:
2830
return AIIDEType(ide.lower())
2931
except ValueError:
3032
valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType])
3133
console.print(
32-
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}',
34+
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all',
3335
style='bold red',
3436
)
3537
raise typer.Exit(1) from None

cycode/cli/apps/ai_guardrails/consts.py

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@
22
33
Currently supports:
44
- Cursor
5-
6-
To add a new IDE (e.g., Claude Code):
7-
1. Add new value to AIIDEType enum
8-
2. Create _get_<ide>_hooks_dir() function with platform-specific paths
9-
3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names
10-
4. Unhide --ide option in commands (install, uninstall, status)
5+
- Claude Code
116
"""
127

138
import platform
@@ -20,6 +15,14 @@ class AIIDEType(str, Enum):
2015
"""Supported AI IDE types."""
2116

2217
CURSOR = 'cursor'
18+
CLAUDE_CODE = 'claude-code'
19+
20+
21+
class PolicyMode(str, Enum):
22+
"""Policy enforcement mode for global mode and per-feature actions."""
23+
24+
BLOCK = 'block'
25+
WARN = 'warn'
2326

2427

2528
class IDEConfig(NamedTuple):
@@ -42,6 +45,14 @@ def _get_cursor_hooks_dir() -> Path:
4245
return Path.home() / '.config' / 'Cursor'
4346

4447

48+
def _get_claude_code_hooks_dir() -> Path:
49+
"""Get Claude Code hooks directory.
50+
51+
Claude Code uses ~/.claude on all platforms.
52+
"""
53+
return Path.home() / '.claude'
54+
55+
4556
# IDE-specific configurations
4657
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
4758
AIIDEType.CURSOR: IDEConfig(
@@ -51,6 +62,13 @@ def _get_cursor_hooks_dir() -> Path:
5162
hooks_file_name='hooks.json',
5263
hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
5364
),
65+
AIIDEType.CLAUDE_CODE: IDEConfig(
66+
name='Claude Code',
67+
hooks_dir=_get_claude_code_hooks_dir(),
68+
repo_hooks_subdir='.claude',
69+
hooks_file_name='settings.json',
70+
hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'],
71+
),
5472
}
5573

5674
# Default IDE
@@ -60,6 +78,47 @@ def _get_cursor_hooks_dir() -> Path:
6078
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
6179

6280

81+
def _get_cursor_hooks_config() -> dict:
82+
"""Get Cursor-specific hooks configuration."""
83+
config = IDE_CONFIGS[AIIDEType.CURSOR]
84+
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
85+
86+
return {
87+
'version': 1,
88+
'hooks': hooks,
89+
}
90+
91+
92+
def _get_claude_code_hooks_config() -> dict:
93+
"""Get Claude Code-specific hooks configuration.
94+
95+
Claude Code uses a different hook format with nested structure:
96+
- hooks are arrays of objects with 'hooks' containing command arrays
97+
- PreToolUse uses 'matcher' field to specify which tools to intercept
98+
"""
99+
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
100+
101+
return {
102+
'hooks': {
103+
'UserPromptSubmit': [
104+
{
105+
'hooks': [{'type': 'command', 'command': command}],
106+
}
107+
],
108+
'PreToolUse': [
109+
{
110+
'matcher': 'Read',
111+
'hooks': [{'type': 'command', 'command': command}],
112+
},
113+
{
114+
'matcher': 'mcp__.*',
115+
'hooks': [{'type': 'command', 'command': command}],
116+
},
117+
],
118+
},
119+
}
120+
121+
63122
def get_hooks_config(ide: AIIDEType) -> dict:
64123
"""Get the hooks configuration for a specific IDE.
65124
@@ -69,10 +128,6 @@ def get_hooks_config(ide: AIIDEType) -> dict:
69128
Returns:
70129
Dict with hooks configuration for the specified IDE
71130
"""
72-
config = IDE_CONFIGS[ide]
73-
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
74-
75-
return {
76-
'version': 1,
77-
'hooks': hooks,
78-
}
131+
if ide == AIIDEType.CLAUDE_CODE:
132+
return _get_claude_code_hooks_config()
133+
return _get_cursor_hooks_config()

cycode/cli/apps/ai_guardrails/hooks_manager.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,27 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
5959

6060

6161
def is_cycode_hook_entry(entry: dict) -> bool:
62-
"""Check if a hook entry is from cycode-cli."""
62+
"""Check if a hook entry is from cycode-cli.
63+
64+
Handles both Cursor format (flat) and Claude Code format (nested).
65+
66+
Cursor format: {"command": "cycode ai-guardrails scan"}
67+
Claude Code format: {"hooks": [{"type": "command", "command": "cycode ai-guardrails scan --ide claude-code"}]}
68+
"""
69+
# Check Cursor format (flat command)
6370
command = entry.get('command', '')
64-
return CYCODE_SCAN_PROMPT_COMMAND in command
71+
if CYCODE_SCAN_PROMPT_COMMAND in command:
72+
return True
73+
74+
# Check Claude Code format (nested hooks array)
75+
hooks = entry.get('hooks', [])
76+
for hook in hooks:
77+
if isinstance(hook, dict):
78+
hook_command = hook.get('command', '')
79+
if CYCODE_SCAN_PROMPT_COMMAND in hook_command:
80+
return True
81+
82+
return False
6583

6684

6785
def install_hooks(
@@ -185,7 +203,15 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide:
185203
ide_config = IDE_CONFIGS[ide]
186204
has_cycode_hooks = False
187205
for event in ide_config.hook_events:
188-
entries = existing.get('hooks', {}).get(event, [])
206+
# Handle event:matcher format
207+
if ':' in event:
208+
actual_event, matcher_prefix = event.split(':', 1)
209+
all_entries = existing.get('hooks', {}).get(actual_event, [])
210+
# Filter entries by matcher
211+
entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)]
212+
else:
213+
entries = existing.get('hooks', {}).get(event, [])
214+
189215
cycode_entries = [e for e in entries if is_cycode_hook_entry(e)]
190216
if cycode_entries:
191217
has_cycode_hooks = True

cycode/cli/apps/ai_guardrails/install_command.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
validate_and_parse_ide,
1212
validate_scope,
1313
)
14-
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS
14+
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType
1515
from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks
1616
from cycode.cli.utils.sentry import add_breadcrumb
1717

@@ -30,9 +30,9 @@ def install_command(
3030
str,
3131
typer.Option(
3232
'--ide',
33-
help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.',
33+
help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.',
3434
),
35-
] = 'cursor',
35+
] = AIIDEType.CURSOR,
3636
repo_path: Annotated[
3737
Optional[Path],
3838
typer.Option(
@@ -54,6 +54,7 @@ def install_command(
5454
cycode ai-guardrails install # Install for all projects (user scope)
5555
cycode ai-guardrails install --scope repo # Install for current repo only
5656
cycode ai-guardrails install --ide cursor # Install for Cursor IDE
57+
cycode ai-guardrails install --ide all # Install for all supported IDEs
5758
cycode ai-guardrails install --scope repo --repo-path /path/to/repo
5859
"""
5960
add_breadcrumb('ai-guardrails-install')
@@ -62,17 +63,35 @@ def install_command(
6263
validate_scope(scope)
6364
repo_path = resolve_repo_path(scope, repo_path)
6465
ide_type = validate_and_parse_ide(ide)
65-
ide_name = IDE_CONFIGS[ide_type].name
66-
success, message = install_hooks(scope, repo_path, ide=ide_type)
6766

68-
if success:
69-
console.print(f'[green]✓[/] {message}')
67+
ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type]
68+
69+
results: list[tuple[str, bool, str]] = []
70+
for current_ide in ides_to_install:
71+
ide_name = IDE_CONFIGS[current_ide].name
72+
success, message = install_hooks(scope, repo_path, ide=current_ide)
73+
results.append((ide_name, success, message))
74+
75+
# Report results for each IDE
76+
any_success = False
77+
all_success = True
78+
for _ide_name, success, message in results:
79+
if success:
80+
console.print(f'[green]✓[/] {message}')
81+
any_success = True
82+
else:
83+
console.print(f'[red]✗[/] {message}', style='bold red')
84+
all_success = False
85+
86+
if any_success:
7087
console.print()
7188
console.print('[bold]Next steps:[/]')
72-
console.print(f'1. Restart {ide_name} to activate the hooks')
89+
successful_ides = [name for name, success, _ in results if success]
90+
ide_list = ', '.join(successful_ides)
91+
console.print(f'1. Restart {ide_list} to activate the hooks')
7392
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
7493
console.print()
7594
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')
76-
else:
77-
console.print(f'[red]✗[/] {message}', style='bold red')
95+
96+
if not all_success:
7897
raise typer.Exit(1)

0 commit comments

Comments
 (0)