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.
- cycode/__init__.py +1 -1
- cycode/cli/apps/ai_guardrails/command_utils.py +2 -45
- cycode/cli/apps/ai_guardrails/consts.py +3 -135
- cycode/cli/apps/ai_guardrails/hooks_manager.py +123 -152
- cycode/cli/apps/ai_guardrails/ides/__init__.py +45 -0
- cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +73 -0
- cycode/cli/apps/ai_guardrails/ides/base.py +176 -0
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +369 -0
- cycode/cli/apps/ai_guardrails/ides/codex.py +310 -0
- cycode/cli/apps/ai_guardrails/ides/cursor.py +119 -0
- cycode/cli/apps/ai_guardrails/install_command.py +14 -23
- cycode/cli/apps/ai_guardrails/scan/handlers.py +102 -101
- cycode/cli/apps/ai_guardrails/scan/payload.py +14 -255
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +60 -48
- cycode/cli/apps/ai_guardrails/scan/types.py +8 -30
- cycode/cli/apps/ai_guardrails/session_start_command.py +14 -78
- cycode/cli/apps/ai_guardrails/status_command.py +13 -16
- cycode/cli/apps/ai_guardrails/uninstall_command.py +12 -22
- cycode/cli/utils/jwt_utils.py +8 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/METADATA +3 -1
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/RECORD +24 -21
- cycode/cli/apps/ai_guardrails/scan/claude_config.py +0 -159
- cycode/cli/apps/ai_guardrails/scan/cursor_config.py +0 -36
- cycode/cli/apps/ai_guardrails/scan/response_builders.py +0 -135
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/WHEEL +0 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
if
|
|
38
|
-
return
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
hooks_config = get_hooks_config(ide, async_mode=report_mode)
|
|
132
|
+
rendered = ide.render_hooks_config(async_mode=report_mode)
|
|
164
133
|
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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.
|
|
247
|
-
'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 =
|
|
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
|
|
262
|
-
#
|
|
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)]
|