cycode 3.15.3.dev7__py3-none-any.whl → 3.15.4.dev1__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.
@@ -0,0 +1,156 @@
1
+ """Base abstractions for AI guardrails IDE integrations.
2
+
3
+ Each AI IDE (Cursor, Claude Code, …) is represented by a subclass of `IDE`
4
+ that consolidates every IDE-specific concern in a single module: settings file
5
+ paths, hooks template rendering, payload parsing, response building, and any
6
+ IDE-specific session-context lookup.
7
+
8
+ Adding a new IDE is a matter of:
9
+ 1. Subclassing `IDE` and implementing the abstract methods.
10
+ 2. Registering the instance in `cycode/cli/apps/ai_guardrails/ides/__init__.py`.
11
+
12
+ The `HookDecision` dataclass is the canonical, IDE-agnostic return type for
13
+ event handlers; `IDE.build_hook_response` translates it into the IDE-specific
14
+ JSON response shape that the IDE expects on stdout.
15
+ """
16
+
17
+ from abc import ABC, abstractmethod
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import ClassVar, Optional
22
+
23
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
24
+ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
25
+
26
+
27
+ class DecisionAction(str, Enum):
28
+ """Canonical decision action returned by event handlers."""
29
+
30
+ ALLOW = 'allow'
31
+ DENY = 'deny'
32
+ ASK = 'ask'
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class HookDecision:
37
+ """Canonical, IDE-agnostic decision returned by event handlers.
38
+
39
+ Carries the event type so `IDE.build_hook_response` can pick the right
40
+ IDE-specific response shape (Cursor's "permission" style for tool events
41
+ vs. "continue" style for prompts; Claude Code's "hookSpecificOutput"
42
+ vs. "decision: block").
43
+ """
44
+
45
+ action: DecisionAction
46
+ event_type: AiHookEventType
47
+ user_message: Optional[str] = None
48
+ agent_message: Optional[str] = None
49
+
50
+ @classmethod
51
+ def allow(cls, event_type: AiHookEventType) -> 'HookDecision':
52
+ return cls(action=DecisionAction.ALLOW, event_type=event_type)
53
+
54
+ @classmethod
55
+ def deny(
56
+ cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None
57
+ ) -> 'HookDecision':
58
+ return cls(
59
+ action=DecisionAction.DENY,
60
+ event_type=event_type,
61
+ user_message=user_message,
62
+ agent_message=agent_message,
63
+ )
64
+
65
+ @classmethod
66
+ def ask(cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None) -> 'HookDecision':
67
+ return cls(
68
+ action=DecisionAction.ASK,
69
+ event_type=event_type,
70
+ user_message=user_message,
71
+ agent_message=agent_message,
72
+ )
73
+
74
+
75
+ class IDE(ABC):
76
+ """Per-IDE integration. Owns every IDE-specific concern in a single module.
77
+
78
+ Subclasses declare identity via class attributes and implement the abstract
79
+ methods. Defaults are provided for `get_user_email` and `get_session_context`
80
+ so IDEs without those capabilities (e.g. no plugin system, no local
81
+ account file) can skip them.
82
+ """
83
+
84
+ # CLI value passed to --ide (e.g. 'cursor', 'claude-code').
85
+ name: ClassVar[str]
86
+ # Human-friendly name for output ('Cursor', 'Claude Code').
87
+ display_name: ClassVar[str]
88
+ # Event names for status display. Use '<event>:<matcher>' for IDEs that
89
+ # qualify a single hook by a sub-matcher (e.g. Claude Code's PreToolUse:Read).
90
+ hook_events: ClassVar[list[str]]
91
+
92
+ # --- install / status ---
93
+
94
+ @abstractmethod
95
+ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
96
+ """Return the hooks/settings file path for the given scope.
97
+
98
+ `scope` is 'user' or 'repo'. `repo_path` is required when scope == 'repo'.
99
+ """
100
+
101
+ @abstractmethod
102
+ def render_hooks_config(self, async_mode: bool = False) -> dict:
103
+ """Return the settings blob to merge into the IDE's settings file.
104
+
105
+ Shape is IDE-specific (Cursor uses a flat ``{event: [{command}]}`` dict;
106
+ Claude Code uses a nested ``{event: [{hooks: [{type, command}]}]}``
107
+ dict). Both share the outer ``{"hooks": ...}`` wrapper so
108
+ ``hooks_manager`` can treat them uniformly.
109
+ """
110
+
111
+ # --- runtime scan ---
112
+
113
+ @abstractmethod
114
+ def matches_payload(self, raw_payload: dict) -> bool:
115
+ """Return True if ``raw_payload`` originated from this IDE.
116
+
117
+ Prevents double-processing when an IDE forwards another IDE's hook
118
+ event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json).
119
+ """
120
+
121
+ @abstractmethod
122
+ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
123
+ """Normalize a raw stdin payload into the canonical ``AIHookPayload``."""
124
+
125
+ @abstractmethod
126
+ def build_hook_response(self, decision: HookDecision) -> dict:
127
+ """Translate a canonical ``HookDecision`` into the IDE-specific JSON.
128
+
129
+ The result is what ``scan_command`` writes to stdout for the IDE to
130
+ act on.
131
+ """
132
+
133
+ # --- session lifecycle (optional; sensible defaults) ---
134
+
135
+ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
136
+ """Build a session-start payload from the raw stdin payload.
137
+
138
+ Default: a minimal payload tagged with this IDE's ``name``. IDEs
139
+ that need to enrich with transcript/version info should override.
140
+ """
141
+ return AIHookPayload(ide_provider=self.name)
142
+
143
+ def get_user_email(self) -> Optional[str]:
144
+ """Best-effort read of the user's email from IDE-specific config.
145
+
146
+ Default: None. Override if the IDE stores a usable account locally.
147
+ """
148
+ return None
149
+
150
+ def get_session_context(self) -> tuple[dict, dict]:
151
+ """Return ``(mcp_servers, enabled_plugins)`` for session-context reporting.
152
+
153
+ Default: empty dicts (no plugin system, no discoverable MCP config).
154
+ Override to surface MCP/plugin inventory.
155
+ """
156
+ return {}, {}
@@ -0,0 +1,389 @@
1
+ """Claude Code IDE integration for AI guardrails."""
2
+
3
+ import json
4
+ from collections.abc import Iterator
5
+ from copy import deepcopy
6
+ from pathlib import Path
7
+ from typing import ClassVar, Optional
8
+
9
+ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
10
+ from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
11
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
12
+ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
13
+ from cycode.logger import get_logger
14
+
15
+ logger = get_logger('AI Guardrails Claude Code')
16
+
17
+ _CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'})
18
+
19
+ _USER_HOOKS_DIR = Path.home() / '.claude'
20
+ _HOOKS_FILE_NAME = 'settings.json'
21
+ _REPO_SUBDIR = '.claude'
22
+ _HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp']
23
+
24
+ _CLAUDE_CONFIG_PATH = Path.home() / '.claude.json'
25
+ _CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json'
26
+
27
+ _SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
28
+ _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'
29
+
30
+
31
+ # --- transcript JSONL parsing -------------------------------------------------
32
+
33
+
34
+ def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]:
35
+ """Yield lines of `path` from end to start without loading the file.
36
+
37
+ The Claude Code transcript can be very large; reading from the tail keeps
38
+ memory bounded since we only care about the most recent entries.
39
+ """
40
+ with path.open('rb') as f:
41
+ f.seek(0, 2)
42
+ file_size = f.tell()
43
+ if file_size == 0:
44
+ return
45
+
46
+ remaining = file_size
47
+ buffer = b''
48
+
49
+ while remaining > 0:
50
+ read_size = min(buf_size, remaining)
51
+ remaining -= read_size
52
+ f.seek(remaining)
53
+ chunk = f.read(read_size)
54
+ buffer = chunk + buffer
55
+
56
+ while b'\n' in buffer:
57
+ newline_pos = buffer.rfind(b'\n')
58
+ if newline_pos == len(buffer) - 1:
59
+ newline_pos = buffer.rfind(b'\n', 0, newline_pos)
60
+ if newline_pos == -1:
61
+ break
62
+ line = buffer[newline_pos + 1 :]
63
+ buffer = buffer[: newline_pos + 1]
64
+ if line.strip():
65
+ yield line.decode('utf-8', errors='replace')
66
+
67
+ if buffer.strip():
68
+ yield buffer.decode('utf-8', errors='replace')
69
+
70
+
71
+ def _extract_model(entry: dict) -> Optional[str]:
72
+ """Extract model from a transcript entry (top level or nested in message)."""
73
+ return entry.get('model') or (entry.get('message') or {}).get('model')
74
+
75
+
76
+ def _extract_generation_id(entry: dict) -> Optional[str]:
77
+ """Extract generation ID from a user-type transcript entry."""
78
+ if entry.get('type') == 'user':
79
+ return entry.get('uuid')
80
+ return None
81
+
82
+
83
+ def extract_from_claude_transcript(
84
+ transcript_path: str,
85
+ ) -> tuple[Optional[str], Optional[str], Optional[str]]:
86
+ """Extract ``(ide_version, model, generation_id)`` from a transcript.
87
+
88
+ The transcript is a JSONL file scanned from end → start so the most recent
89
+ entries are read first. Any field may come back ``None`` if not found.
90
+ """
91
+ if not transcript_path:
92
+ return None, None, None
93
+
94
+ path = Path(transcript_path)
95
+ if not path.exists():
96
+ return None, None, None
97
+
98
+ ide_version = None
99
+ model = None
100
+ generation_id = None
101
+
102
+ try:
103
+ for line in _reverse_readline(path):
104
+ line = line.strip()
105
+ if not line:
106
+ continue
107
+ try:
108
+ entry = json.loads(line)
109
+ ide_version = ide_version or entry.get('version')
110
+ model = model or _extract_model(entry)
111
+ generation_id = generation_id or _extract_generation_id(entry)
112
+
113
+ if ide_version and model and generation_id:
114
+ break
115
+ except json.JSONDecodeError:
116
+ continue
117
+ except OSError:
118
+ pass
119
+
120
+ return ide_version, model, generation_id
121
+
122
+
123
+ # --- ~/.claude.json + ~/.claude/settings.json parsing -------------------------
124
+
125
+
126
+ def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
127
+ """Load and parse `~/.claude.json`. Returns None if missing/invalid."""
128
+ path = config_path or _CLAUDE_CONFIG_PATH
129
+ if not path.exists():
130
+ logger.debug('Claude config file not found', extra={'path': str(path)})
131
+ return None
132
+ try:
133
+ return json.loads(path.read_text(encoding='utf-8'))
134
+ except Exception as e:
135
+ logger.debug('Failed to load Claude config file', exc_info=e)
136
+ return None
137
+
138
+
139
+ def _email_from_config(config: dict) -> Optional[str]:
140
+ """Read ``oauthAccount.emailAddress`` from a parsed Claude config."""
141
+ return config.get('oauthAccount', {}).get('emailAddress')
142
+
143
+
144
+ def get_mcp_servers(config: dict) -> Optional[dict]:
145
+ """Read ``mcpServers`` from a parsed Claude config."""
146
+ return config.get('mcpServers')
147
+
148
+
149
+ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]:
150
+ """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid."""
151
+ path = settings_path or _CLAUDE_SETTINGS_PATH
152
+ if not path.exists():
153
+ logger.debug('Claude settings file not found', extra={'path': str(path)})
154
+ return None
155
+ try:
156
+ return json.loads(path.read_text(encoding='utf-8'))
157
+ except Exception as e:
158
+ logger.debug('Failed to load Claude settings file', exc_info=e)
159
+ return None
160
+
161
+
162
+ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
163
+ """Resolve filesystem path for a directory-type marketplace."""
164
+ source = marketplace.get('source', {})
165
+ if source.get('source') != 'directory':
166
+ return None
167
+ raw = source.get('path')
168
+ if not raw:
169
+ return None
170
+ path = Path(raw)
171
+ return path if path.is_dir() else None
172
+
173
+
174
+ def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]:
175
+ """Load and parse a JSON file inside a plugin directory.
176
+
177
+ Returns None if the file is missing, unreadable, or has invalid JSON.
178
+ """
179
+ target = plugin_path / relative_path
180
+ if not target.exists():
181
+ return None
182
+ try:
183
+ return json.loads(target.read_text(encoding='utf-8'))
184
+ except Exception as e:
185
+ logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e)
186
+ return None
187
+
188
+
189
+ def resolve_plugins(settings: dict) -> tuple[dict, dict]:
190
+ """Resolve enabled plugins to their MCP servers and metadata.
191
+
192
+ Walks ``enabledPlugins`` from claude settings, resolves each plugin's
193
+ marketplace directory via ``extraKnownMarketplaces``, and reads:
194
+ - ``<path>/.mcp.json`` for MCP servers (merged into a flat dict)
195
+ - ``<path>/.claude-plugin/plugin.json`` for metadata (name, version, description)
196
+
197
+ Returns ``(merged_mcp_servers, enriched_plugins)``.
198
+ """
199
+ enabled = settings.get('enabledPlugins') or {}
200
+ marketplaces = settings.get('extraKnownMarketplaces') or {}
201
+ merged_mcp: dict = {}
202
+ enriched: dict = {}
203
+
204
+ for plugin_key, is_enabled in enabled.items():
205
+ if not is_enabled:
206
+ continue
207
+
208
+ entry: dict = {'enabled': True}
209
+ enriched[plugin_key] = entry
210
+
211
+ if '@' not in plugin_key:
212
+ continue
213
+
214
+ _plugin_name, marketplace_name = plugin_key.split('@', 1)
215
+ marketplace = marketplaces.get(marketplace_name)
216
+ if not marketplace:
217
+ continue
218
+
219
+ plugin_path = _resolve_marketplace_path(marketplace)
220
+ if plugin_path is None:
221
+ continue
222
+
223
+ metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {}
224
+ for field in ('name', 'version', 'description'):
225
+ if field in metadata:
226
+ entry[field] = metadata[field]
227
+
228
+ mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {}
229
+ plugin_server_names = []
230
+ for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items():
231
+ merged_mcp[server_name] = server_cfg
232
+ plugin_server_names.append(server_name)
233
+ if plugin_server_names:
234
+ entry['mcp_server_names'] = plugin_server_names
235
+
236
+ return merged_mcp, enriched
237
+
238
+
239
+ # --- IDE integration ----------------------------------------------------------
240
+
241
+
242
+ class ClaudeCode(IDE):
243
+ name: ClassVar[str] = 'claude-code'
244
+ display_name: ClassVar[str] = 'Claude Code'
245
+ hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
246
+
247
+ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
248
+ if scope == 'repo' and repo_path:
249
+ return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
250
+ return _USER_HOOKS_DIR / _HOOKS_FILE_NAME
251
+
252
+ def render_hooks_config(self, async_mode: bool = False) -> dict:
253
+ # Claude Code uses a nested hook structure with optional async/timeout.
254
+ hook_entry: dict = {'type': 'command', 'command': _SCAN_COMMAND}
255
+ if async_mode:
256
+ hook_entry['async'] = True
257
+ hook_entry['timeout'] = 20
258
+
259
+ return {
260
+ 'hooks': {
261
+ 'SessionStart': [
262
+ {
263
+ 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
264
+ }
265
+ ],
266
+ 'UserPromptSubmit': [
267
+ {
268
+ 'hooks': [deepcopy(hook_entry)],
269
+ }
270
+ ],
271
+ 'PreToolUse': [
272
+ {
273
+ 'matcher': 'Read',
274
+ 'hooks': [deepcopy(hook_entry)],
275
+ },
276
+ {
277
+ 'matcher': 'mcp__.*',
278
+ 'hooks': [deepcopy(hook_entry)],
279
+ },
280
+ ],
281
+ },
282
+ }
283
+
284
+ def matches_payload(self, raw_payload: dict) -> bool:
285
+ return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES
286
+
287
+ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
288
+ hook_event_name = raw_payload.get('hook_event_name', '')
289
+ tool_name = raw_payload.get('tool_name', '')
290
+ tool_input = raw_payload.get('tool_input')
291
+
292
+ if hook_event_name == 'UserPromptSubmit':
293
+ canonical_event: AiHookEventType | str = AiHookEventType.PROMPT
294
+ elif hook_event_name == 'PreToolUse':
295
+ canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION
296
+ else:
297
+ canonical_event = hook_event_name
298
+
299
+ # Extract file_path from tool_input for the Read tool.
300
+ file_path = None
301
+ if tool_name == 'Read' and isinstance(tool_input, dict):
302
+ file_path = tool_input.get('file_path')
303
+
304
+ # For MCP tools, the entire tool_input is the arguments.
305
+ mcp_arguments = tool_input if tool_name.startswith('mcp__') else None
306
+
307
+ # MCP tool name format: mcp__<server>__<tool>
308
+ mcp_server_name = None
309
+ mcp_tool_name = None
310
+ if tool_name.startswith('mcp__'):
311
+ parts = tool_name.split('__')
312
+ if len(parts) >= 2:
313
+ mcp_server_name = parts[1]
314
+ if len(parts) >= 3:
315
+ mcp_tool_name = parts[2]
316
+
317
+ ide_version, model, generation_id = extract_from_claude_transcript(raw_payload.get('transcript_path'))
318
+
319
+ config = load_claude_config()
320
+ ide_user_email = _email_from_config(config) if config else None
321
+
322
+ return AIHookPayload(
323
+ event_name=canonical_event,
324
+ conversation_id=raw_payload.get('session_id'),
325
+ generation_id=generation_id,
326
+ ide_user_email=ide_user_email,
327
+ model=model,
328
+ ide_provider=self.name,
329
+ ide_version=ide_version,
330
+ prompt=raw_payload.get('prompt', ''),
331
+ file_path=file_path,
332
+ mcp_server_name=mcp_server_name,
333
+ mcp_tool_name=mcp_tool_name,
334
+ mcp_arguments=mcp_arguments,
335
+ )
336
+
337
+ def build_hook_response(self, decision: HookDecision) -> dict:
338
+ if decision.event_type == AiHookEventType.PROMPT:
339
+ if decision.action == DecisionAction.ALLOW:
340
+ return {}
341
+ # Both DENY and (unexpected) ASK on prompts collapse to a block.
342
+ return {'decision': 'block', 'reason': decision.user_message or ''}
343
+
344
+ # FILE_READ / MCP_EXECUTION → hookSpecificOutput shape.
345
+ if decision.action == DecisionAction.ALLOW:
346
+ return {
347
+ 'hookSpecificOutput': {
348
+ 'hookEventName': 'PreToolUse',
349
+ 'permissionDecision': 'allow',
350
+ }
351
+ }
352
+ return {
353
+ 'hookSpecificOutput': {
354
+ 'hookEventName': 'PreToolUse',
355
+ 'permissionDecision': decision.action.value, # 'deny' or 'ask'
356
+ 'permissionDecisionReason': decision.user_message or '',
357
+ }
358
+ }
359
+
360
+ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
361
+ config = load_claude_config()
362
+ ide_user_email = _email_from_config(config) if config else None
363
+ ide_version, _, _ = extract_from_claude_transcript(raw_payload.get('transcript_path'))
364
+
365
+ return AIHookPayload(
366
+ conversation_id=raw_payload.get('session_id'),
367
+ ide_user_email=ide_user_email,
368
+ model=raw_payload.get('model'),
369
+ ide_provider=self.name,
370
+ ide_version=ide_version,
371
+ source=raw_payload.get('source'),
372
+ )
373
+
374
+ def get_user_email(self) -> Optional[str]:
375
+ config = load_claude_config()
376
+ return _email_from_config(config) if config else None
377
+
378
+ def get_session_context(self) -> tuple[dict, dict]:
379
+ config = load_claude_config()
380
+ mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {}
381
+
382
+ settings = load_claude_settings()
383
+ if settings:
384
+ plugin_mcp, enriched_plugins = resolve_plugins(settings)
385
+ mcp_servers.update(plugin_mcp)
386
+ else:
387
+ enriched_plugins = {}
388
+
389
+ return mcp_servers, enriched_plugins
@@ -0,0 +1,119 @@
1
+ """Cursor IDE integration for AI guardrails."""
2
+
3
+ import json
4
+ import platform
5
+ from pathlib import Path
6
+ from typing import ClassVar, Optional
7
+
8
+ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
9
+ from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
10
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
11
+ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
12
+ from cycode.logger import get_logger
13
+
14
+ logger = get_logger('AI Guardrails Cursor')
15
+
16
+ _CURSOR_EVENT_MAPPING: dict[str, AiHookEventType] = {
17
+ 'beforeSubmitPrompt': AiHookEventType.PROMPT,
18
+ 'beforeReadFile': AiHookEventType.FILE_READ,
19
+ 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION,
20
+ }
21
+
22
+ _HOOKS_FILE_NAME = 'hooks.json'
23
+ _REPO_SUBDIR = '.cursor'
24
+ _MCP_CONFIG_FILENAME = 'mcp.json'
25
+
26
+ # Cursor was the original default IDE — its scan command omits --ide to stay
27
+ # byte-identical with already-installed hooks.json files. Session-start is
28
+ # always explicit because it was introduced after Claude Code support.
29
+ _SCAN_COMMAND = CYCODE_SCAN_PROMPT_COMMAND
30
+ _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide cursor'
31
+
32
+
33
+ def _user_hooks_dir() -> Path:
34
+ """Per-platform Cursor user-scope settings directory."""
35
+ if platform.system() == 'Darwin':
36
+ return Path.home() / '.cursor'
37
+ if platform.system() == 'Windows':
38
+ return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
39
+ return Path.home() / '.config' / 'Cursor'
40
+
41
+
42
+ def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]:
43
+ """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
44
+ path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME)
45
+ if not path.exists():
46
+ logger.debug('Cursor MCP config file not found', extra={'path': str(path)})
47
+ return None
48
+ try:
49
+ return json.loads(path.read_text(encoding='utf-8'))
50
+ except Exception as e:
51
+ logger.debug('Failed to load Cursor MCP config file', exc_info=e)
52
+ return None
53
+
54
+
55
+ class Cursor(IDE):
56
+ name: ClassVar[str] = 'cursor'
57
+ display_name: ClassVar[str] = 'Cursor'
58
+ hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING)
59
+
60
+ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
61
+ if scope == 'repo' and repo_path:
62
+ return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
63
+ return _user_hooks_dir() / _HOOKS_FILE_NAME
64
+
65
+ def render_hooks_config(self, async_mode: bool = False) -> dict:
66
+ command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND
67
+ hooks = {event: [{'command': command}] for event in self.hook_events}
68
+ hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}]
69
+ return {'version': 1, 'hooks': hooks}
70
+
71
+ def matches_payload(self, raw_payload: dict) -> bool:
72
+ return raw_payload.get('hook_event_name', '') in _CURSOR_EVENT_MAPPING
73
+
74
+ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
75
+ cursor_event_name = raw_payload.get('hook_event_name', '')
76
+ canonical_event = _CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name)
77
+ return AIHookPayload(
78
+ event_name=canonical_event,
79
+ conversation_id=raw_payload.get('conversation_id'),
80
+ generation_id=raw_payload.get('generation_id'),
81
+ ide_user_email=raw_payload.get('user_email'),
82
+ model=raw_payload.get('model'),
83
+ ide_provider=self.name,
84
+ ide_version=raw_payload.get('cursor_version'),
85
+ prompt=raw_payload.get('prompt', ''),
86
+ file_path=raw_payload.get('file_path') or raw_payload.get('path'),
87
+ mcp_server_name=raw_payload.get('command'),
88
+ mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'),
89
+ mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')),
90
+ )
91
+
92
+ def build_hook_response(self, decision: HookDecision) -> dict:
93
+ if decision.event_type == AiHookEventType.PROMPT:
94
+ if decision.action == DecisionAction.ALLOW:
95
+ return {'continue': True}
96
+ return {'continue': False, 'user_message': decision.user_message or ''}
97
+
98
+ # FILE_READ / MCP_EXECUTION → permission shape
99
+ if decision.action == DecisionAction.ALLOW:
100
+ return {'permission': 'allow'}
101
+ return {
102
+ 'permission': decision.action.value, # 'deny' or 'ask'
103
+ 'user_message': decision.user_message or '',
104
+ 'agent_message': decision.agent_message or '',
105
+ }
106
+
107
+ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
108
+ return AIHookPayload(
109
+ conversation_id=raw_payload.get('conversation_id'),
110
+ ide_user_email=raw_payload.get('user_email'),
111
+ model=raw_payload.get('model'),
112
+ ide_provider=self.name,
113
+ ide_version=raw_payload.get('cursor_version'),
114
+ )
115
+
116
+ def get_session_context(self) -> tuple[dict, dict]:
117
+ config = _load_cursor_mcp_config()
118
+ mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {}
119
+ return mcp_servers, {}