Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 2 additions & 45 deletions cycode/cli/apps/ai_guardrails/command_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,19 @@
import typer
from rich.console import Console

from cycode.cli.apps.ai_guardrails.consts import AIIDEType

console = Console()


def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]:
"""Validate IDE parameter, returning None for 'all'.

Args:
ide: IDE name string (e.g., 'cursor', 'claude-code', 'all')

Returns:
AIIDEType enum value, or None if 'all' was specified

Raises:
typer.Exit: If IDE is invalid
"""
if ide.lower() == 'all':
return None
try:
return AIIDEType(ide.lower())
except ValueError:
valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType])
console.print(
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all',
style='bold red',
)
raise typer.Exit(1) from None


def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None:
"""Validate scope parameter.

Args:
scope: Scope string to validate
allowed_scopes: Tuple of allowed scope values

Raises:
typer.Exit: If scope is invalid
"""
"""Validate scope parameter."""
if scope not in allowed_scopes:
scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes)
console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red')
raise typer.Exit(1)


def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]:
"""Resolve repository path, defaulting to current directory for repo scope.

Args:
scope: The command scope ('user' or 'repo')
repo_path: Provided repo path or None

Returns:
Resolved Path for repo scope, None for user scope
"""
"""Default repo_path to cwd for 'repo' scope; leave None for 'user' scope."""
if scope == 'repo' and repo_path is None:
return Path(os.getcwd())
return repo_path
138 changes: 3 additions & 135 deletions cycode/cli/apps/ai_guardrails/consts.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,6 @@
"""Constants for AI guardrails hooks management.
"""Shared constants and policy/mode enums for AI guardrails."""

Currently supports:
- Cursor
- Claude Code
"""

import platform
from copy import deepcopy
from enum import Enum
from pathlib import Path
from typing import NamedTuple


class AIIDEType(str, Enum):
"""Supported AI IDE types."""

CURSOR = 'cursor'
CLAUDE_CODE = 'claude-code'


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


class IDEConfig(NamedTuple):
"""Configuration for an AI IDE."""

name: str
hooks_dir: Path
repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor')
hooks_file_name: str
hook_events: list[str] # List of supported hook event names for this IDE


def _get_cursor_hooks_dir() -> Path:
"""Get Cursor hooks directory based on platform."""
if platform.system() == 'Darwin':
return Path.home() / '.cursor'
if platform.system() == 'Windows':
return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
# Linux
return Path.home() / '.config' / 'Cursor'


def _get_claude_code_hooks_dir() -> Path:
"""Get Claude Code hooks directory.

Claude Code uses ~/.claude on all platforms.
"""
return Path.home() / '.claude'


# IDE-specific configurations
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
AIIDEType.CURSOR: IDEConfig(
name='Cursor',
hooks_dir=_get_cursor_hooks_dir(),
repo_hooks_subdir='.cursor',
hooks_file_name='hooks.json',
hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
),
AIIDEType.CLAUDE_CODE: IDEConfig(
name='Claude Code',
hooks_dir=_get_claude_code_hooks_dir(),
repo_hooks_subdir='.claude',
hooks_file_name='settings.json',
hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'],
),
}

# Default IDE
DEFAULT_IDE = AIIDEType.CURSOR

# Command used in hooks
# Base CLI commands invoked from installed hooks. IDE classes append --ide flags
# (and any other suffix) on top of these.
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start'


def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
"""Get Cursor-specific hooks configuration."""
config = IDE_CONFIGS[AIIDEType.CURSOR]
command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND
hooks = {event: [{'command': command}] for event in config.hook_events}
hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}]

return {
'version': 1,
'hooks': hooks,
}


def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
"""Get Claude Code-specific hooks configuration.

Claude Code uses a different hook format with nested structure:
- hooks are arrays of objects with 'hooks' containing command arrays
- PreToolUse uses 'matcher' field to specify which tools to intercept
"""
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'

hook_entry = {'type': 'command', 'command': command}
if async_mode:
hook_entry['async'] = True
hook_entry['timeout'] = 20

return {
'hooks': {
'SessionStart': [
{
'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}],
}
],
'UserPromptSubmit': [
{
'hooks': [deepcopy(hook_entry)],
}
],
'PreToolUse': [
{
'matcher': 'Read',
'hooks': [deepcopy(hook_entry)],
},
{
'matcher': 'mcp__.*',
'hooks': [deepcopy(hook_entry)],
},
],
},
}


def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict:
"""Get the hooks configuration for a specific IDE.

Args:
ide: The AI IDE type
async_mode: If True, hooks run asynchronously (non-blocking)

Returns:
Dict with hooks configuration for the specified IDE
"""
if ide == AIIDEType.CLAUDE_CODE:
return _get_claude_code_hooks_config(async_mode=async_mode)
return _get_cursor_hooks_config(async_mode=async_mode)
Loading
Loading