foundry-mcp 0.8.22__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.

Potentially problematic release.


This version of foundry-mcp might be problematic. Click here for more details.

Files changed (153) hide show
  1. foundry_mcp/__init__.py +13 -0
  2. foundry_mcp/cli/__init__.py +67 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +640 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +667 -0
  15. foundry_mcp/cli/commands/session.py +472 -0
  16. foundry_mcp/cli/commands/specs.py +686 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +298 -0
  22. foundry_mcp/cli/logging.py +212 -0
  23. foundry_mcp/cli/main.py +44 -0
  24. foundry_mcp/cli/output.py +122 -0
  25. foundry_mcp/cli/registry.py +110 -0
  26. foundry_mcp/cli/resilience.py +178 -0
  27. foundry_mcp/cli/transcript.py +217 -0
  28. foundry_mcp/config.py +1454 -0
  29. foundry_mcp/core/__init__.py +144 -0
  30. foundry_mcp/core/ai_consultation.py +1773 -0
  31. foundry_mcp/core/batch_operations.py +1202 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/health.py +749 -0
  40. foundry_mcp/core/intake.py +933 -0
  41. foundry_mcp/core/journal.py +700 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1376 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +146 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +387 -0
  57. foundry_mcp/core/prometheus.py +564 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +691 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
  61. foundry_mcp/core/prompts/plan_review.py +627 -0
  62. foundry_mcp/core/providers/__init__.py +237 -0
  63. foundry_mcp/core/providers/base.py +515 -0
  64. foundry_mcp/core/providers/claude.py +472 -0
  65. foundry_mcp/core/providers/codex.py +637 -0
  66. foundry_mcp/core/providers/cursor_agent.py +630 -0
  67. foundry_mcp/core/providers/detectors.py +515 -0
  68. foundry_mcp/core/providers/gemini.py +426 -0
  69. foundry_mcp/core/providers/opencode.py +718 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +308 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +857 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/research/__init__.py +68 -0
  78. foundry_mcp/core/research/memory.py +528 -0
  79. foundry_mcp/core/research/models.py +1234 -0
  80. foundry_mcp/core/research/providers/__init__.py +40 -0
  81. foundry_mcp/core/research/providers/base.py +242 -0
  82. foundry_mcp/core/research/providers/google.py +507 -0
  83. foundry_mcp/core/research/providers/perplexity.py +442 -0
  84. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  85. foundry_mcp/core/research/providers/tavily.py +383 -0
  86. foundry_mcp/core/research/workflows/__init__.py +25 -0
  87. foundry_mcp/core/research/workflows/base.py +298 -0
  88. foundry_mcp/core/research/workflows/chat.py +271 -0
  89. foundry_mcp/core/research/workflows/consensus.py +539 -0
  90. foundry_mcp/core/research/workflows/deep_research.py +4142 -0
  91. foundry_mcp/core/research/workflows/ideate.py +682 -0
  92. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  93. foundry_mcp/core/resilience.py +600 -0
  94. foundry_mcp/core/responses.py +1624 -0
  95. foundry_mcp/core/review.py +366 -0
  96. foundry_mcp/core/security.py +438 -0
  97. foundry_mcp/core/spec.py +4119 -0
  98. foundry_mcp/core/task.py +2463 -0
  99. foundry_mcp/core/testing.py +839 -0
  100. foundry_mcp/core/validation.py +2357 -0
  101. foundry_mcp/dashboard/__init__.py +32 -0
  102. foundry_mcp/dashboard/app.py +119 -0
  103. foundry_mcp/dashboard/components/__init__.py +17 -0
  104. foundry_mcp/dashboard/components/cards.py +88 -0
  105. foundry_mcp/dashboard/components/charts.py +177 -0
  106. foundry_mcp/dashboard/components/filters.py +136 -0
  107. foundry_mcp/dashboard/components/tables.py +195 -0
  108. foundry_mcp/dashboard/data/__init__.py +11 -0
  109. foundry_mcp/dashboard/data/stores.py +433 -0
  110. foundry_mcp/dashboard/launcher.py +300 -0
  111. foundry_mcp/dashboard/views/__init__.py +12 -0
  112. foundry_mcp/dashboard/views/errors.py +217 -0
  113. foundry_mcp/dashboard/views/metrics.py +164 -0
  114. foundry_mcp/dashboard/views/overview.py +96 -0
  115. foundry_mcp/dashboard/views/providers.py +83 -0
  116. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  117. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  118. foundry_mcp/prompts/__init__.py +9 -0
  119. foundry_mcp/prompts/workflows.py +525 -0
  120. foundry_mcp/resources/__init__.py +9 -0
  121. foundry_mcp/resources/specs.py +591 -0
  122. foundry_mcp/schemas/__init__.py +38 -0
  123. foundry_mcp/schemas/intake-schema.json +89 -0
  124. foundry_mcp/schemas/sdd-spec-schema.json +414 -0
  125. foundry_mcp/server.py +150 -0
  126. foundry_mcp/tools/__init__.py +10 -0
  127. foundry_mcp/tools/unified/__init__.py +92 -0
  128. foundry_mcp/tools/unified/authoring.py +3620 -0
  129. foundry_mcp/tools/unified/context_helpers.py +98 -0
  130. foundry_mcp/tools/unified/documentation_helpers.py +268 -0
  131. foundry_mcp/tools/unified/environment.py +1341 -0
  132. foundry_mcp/tools/unified/error.py +479 -0
  133. foundry_mcp/tools/unified/health.py +225 -0
  134. foundry_mcp/tools/unified/journal.py +841 -0
  135. foundry_mcp/tools/unified/lifecycle.py +640 -0
  136. foundry_mcp/tools/unified/metrics.py +777 -0
  137. foundry_mcp/tools/unified/plan.py +876 -0
  138. foundry_mcp/tools/unified/pr.py +294 -0
  139. foundry_mcp/tools/unified/provider.py +589 -0
  140. foundry_mcp/tools/unified/research.py +1283 -0
  141. foundry_mcp/tools/unified/review.py +1042 -0
  142. foundry_mcp/tools/unified/review_helpers.py +314 -0
  143. foundry_mcp/tools/unified/router.py +102 -0
  144. foundry_mcp/tools/unified/server.py +565 -0
  145. foundry_mcp/tools/unified/spec.py +1283 -0
  146. foundry_mcp/tools/unified/task.py +3846 -0
  147. foundry_mcp/tools/unified/test.py +431 -0
  148. foundry_mcp/tools/unified/verification.py +520 -0
  149. foundry_mcp-0.8.22.dist-info/METADATA +344 -0
  150. foundry_mcp-0.8.22.dist-info/RECORD +153 -0
  151. foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
  152. foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
  153. foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,630 @@
1
+ """
2
+ Cursor Agent CLI provider implementation.
3
+
4
+ Adapts the `cursor-agent` command-line tool to the ProviderContext contract,
5
+ including availability checks, streaming normalization, and response parsing.
6
+ Enforces read-only restrictions via Cursor's permission configuration system.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ from .base import (
23
+ ProviderCapability,
24
+ ProviderContext,
25
+ ProviderExecutionError,
26
+ ProviderHooks,
27
+ ProviderMetadata,
28
+ ProviderRequest,
29
+ ProviderResult,
30
+ ProviderStatus,
31
+ ProviderTimeoutError,
32
+ ProviderUnavailableError,
33
+ StreamChunk,
34
+ TokenUsage,
35
+ )
36
+ from .detectors import detect_provider_availability
37
+ from .registry import register_provider
38
+
39
+ DEFAULT_BINARY = "cursor-agent"
40
+ DEFAULT_TIMEOUT_SECONDS = 360
41
+ AVAILABILITY_OVERRIDE_ENV = "CURSOR_AGENT_CLI_AVAILABLE_OVERRIDE"
42
+ CUSTOM_BINARY_ENV = "CURSOR_AGENT_CLI_BINARY"
43
+
44
+ # Read-only tools allowed for Cursor Agent
45
+ # Note: Cursor Agent uses config files for permissions, not command-line flags
46
+ # These lists serve as documentation and validation
47
+ ALLOWED_TOOLS = [
48
+ # File operations (read-only)
49
+ "Read",
50
+ "Grep",
51
+ "Glob",
52
+ "List",
53
+ # Task delegation
54
+ "Task",
55
+ # Shell commands - file viewing
56
+ "Shell(cat)",
57
+ "Shell(head)",
58
+ "Shell(tail)",
59
+ "Shell(bat)",
60
+ # Shell commands - directory listing/navigation
61
+ "Shell(ls)",
62
+ "Shell(tree)",
63
+ "Shell(pwd)",
64
+ "Shell(which)",
65
+ "Shell(whereis)",
66
+ # Shell commands - search/find
67
+ "Shell(grep)",
68
+ "Shell(rg)",
69
+ "Shell(ag)",
70
+ "Shell(find)",
71
+ "Shell(fd)",
72
+ # Shell commands - git operations (read-only)
73
+ "Shell(git log)",
74
+ "Shell(git show)",
75
+ "Shell(git diff)",
76
+ "Shell(git status)",
77
+ "Shell(git grep)",
78
+ "Shell(git blame)",
79
+ "Shell(git branch)",
80
+ "Shell(git rev-parse)",
81
+ "Shell(git describe)",
82
+ "Shell(git ls-tree)",
83
+ # Shell commands - text processing
84
+ "Shell(wc)",
85
+ "Shell(cut)",
86
+ "Shell(paste)",
87
+ "Shell(column)",
88
+ "Shell(sort)",
89
+ "Shell(uniq)",
90
+ # Shell commands - data formats
91
+ "Shell(jq)",
92
+ "Shell(yq)",
93
+ # Shell commands - file analysis
94
+ "Shell(file)",
95
+ "Shell(stat)",
96
+ "Shell(du)",
97
+ "Shell(df)",
98
+ # Shell commands - checksums/hashing
99
+ "Shell(md5sum)",
100
+ "Shell(shasum)",
101
+ "Shell(sha256sum)",
102
+ "Shell(sha512sum)",
103
+ ]
104
+
105
+ # Tools that should be explicitly blocked
106
+ DISALLOWED_TOOLS = [
107
+ "Write",
108
+ "Edit",
109
+ "Patch",
110
+ "Delete",
111
+ # Web operations (data exfiltration risk)
112
+ "WebFetch",
113
+ # Dangerous file operations
114
+ "Shell(rm)",
115
+ "Shell(rmdir)",
116
+ "Shell(dd)",
117
+ "Shell(mkfs)",
118
+ "Shell(fdisk)",
119
+ # File modifications
120
+ "Shell(touch)",
121
+ "Shell(mkdir)",
122
+ "Shell(mv)",
123
+ "Shell(cp)",
124
+ "Shell(chmod)",
125
+ "Shell(chown)",
126
+ "Shell(sed)",
127
+ "Shell(awk)",
128
+ # Git write operations
129
+ "Shell(git add)",
130
+ "Shell(git commit)",
131
+ "Shell(git push)",
132
+ "Shell(git pull)",
133
+ "Shell(git merge)",
134
+ "Shell(git rebase)",
135
+ "Shell(git reset)",
136
+ "Shell(git checkout)",
137
+ # Package installations
138
+ "Shell(npm install)",
139
+ "Shell(pip install)",
140
+ "Shell(apt install)",
141
+ "Shell(brew install)",
142
+ # System operations
143
+ "Shell(sudo)",
144
+ "Shell(halt)",
145
+ "Shell(reboot)",
146
+ "Shell(shutdown)",
147
+ ]
148
+
149
+ # System prompt warning about Cursor Agent security limitations
150
+ SHELL_COMMAND_WARNING = """
151
+ IMPORTANT SECURITY NOTE: This session is running in read-only mode with the following restrictions:
152
+ 1. File write operations (Write, Edit, Patch, Delete) are disabled via Cursor Agent config
153
+ 2. Only approved read-only shell commands are permitted
154
+ 3. Cursor Agent's security model is weaker than other CLIs - be cautious
155
+ 4. Configuration is enforced via ~/.cursor/cli-config.json (original config backed up and restored automatically)
156
+ 5. Note: This uses allowlist mode for maximum security - only explicitly allowed operations are permitted
157
+ """
158
+
159
+
160
+ class RunnerProtocol(Protocol):
161
+ """Callable signature used for executing cursor-agent CLI commands."""
162
+
163
+ def __call__(
164
+ self,
165
+ command: Sequence[str],
166
+ *,
167
+ timeout: Optional[int] = None,
168
+ env: Optional[Dict[str, str]] = None,
169
+ input_data: Optional[str] = None,
170
+ ) -> subprocess.CompletedProcess[str]:
171
+ raise NotImplementedError
172
+
173
+
174
+ def _default_runner(
175
+ command: Sequence[str],
176
+ *,
177
+ timeout: Optional[int] = None,
178
+ env: Optional[Dict[str, str]] = None,
179
+ input_data: Optional[str] = None,
180
+ ) -> subprocess.CompletedProcess[str]:
181
+ """Invoke the cursor-agent CLI via subprocess."""
182
+ return subprocess.run( # noqa: S603,S607 - intentional CLI invocation
183
+ list(command),
184
+ capture_output=True,
185
+ text=True,
186
+ input=input_data,
187
+ timeout=timeout,
188
+ env=env,
189
+ check=False,
190
+ )
191
+
192
+
193
+ CURSOR_METADATA = ProviderMetadata(
194
+ provider_id="cursor-agent",
195
+ display_name="Cursor Agent CLI",
196
+ models=[], # Model validation delegated to CLI
197
+ default_model="composer-1",
198
+ capabilities={ProviderCapability.TEXT, ProviderCapability.FUNCTION_CALLING, ProviderCapability.STREAMING},
199
+ security_flags={"writes_allowed": False, "read_only": True},
200
+ extra={
201
+ "cli": "cursor-agent",
202
+ "command": "cursor-agent --print --output-format json",
203
+ "allowed_tools": ALLOWED_TOOLS,
204
+ "config_based_permissions": True,
205
+ },
206
+ )
207
+
208
+
209
+ class CursorAgentProvider(ProviderContext):
210
+ """ProviderContext implementation backed by cursor-agent with read-only restrictions."""
211
+
212
+ def __init__(
213
+ self,
214
+ metadata: ProviderMetadata,
215
+ hooks: ProviderHooks,
216
+ *,
217
+ model: Optional[str] = None,
218
+ binary: Optional[str] = None,
219
+ runner: Optional[RunnerProtocol] = None,
220
+ env: Optional[Dict[str, str]] = None,
221
+ timeout: Optional[int] = None,
222
+ ):
223
+ super().__init__(metadata, hooks)
224
+ self._runner = runner or _default_runner
225
+ self._binary = binary or os.environ.get(CUSTOM_BINARY_ENV, DEFAULT_BINARY)
226
+ self._env = env
227
+ self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
228
+ self._model = model or metadata.default_model or "composer-1"
229
+ self._config_backup_path: Optional[Path] = None
230
+ self._original_config_existed: bool = False
231
+ self._cleanup_done: bool = False
232
+
233
+ def __del__(self) -> None:
234
+ """Clean up temporary config directory on provider destruction."""
235
+ self._cleanup_config_file()
236
+
237
+ def _create_readonly_config(self) -> Path:
238
+ """
239
+ Backup and replace ~/.cursor/cli-config.json with read-only permissions.
240
+
241
+ Cursor Agent uses a permission configuration system with the format:
242
+ - {"allow": "Read(**)"}: Allow read access to all paths
243
+ - {"allow": "Shell(command)"}: Allow specific shell commands
244
+ - {"deny": "Write(**)"}: Deny write access
245
+
246
+ Returns:
247
+ Path to the HOME .cursor directory
248
+
249
+ Note:
250
+ This method backs up the original config to a unique timestamped file,
251
+ then writes a read-only config. The backup is restored by _cleanup_config_file().
252
+ """
253
+ # Get HOME .cursor config path
254
+ cursor_dir = Path.home() / ".cursor"
255
+ config_path = cursor_dir / "cli-config.json"
256
+
257
+ # Create unique backup path for thread-safety
258
+ backup_suffix = f".sdd-backup.{os.getpid()}.{int(time.time())}"
259
+ backup_path = Path(str(config_path) + backup_suffix)
260
+
261
+ # Backup original config if it exists
262
+ self._original_config_existed = config_path.exists()
263
+ if self._original_config_existed:
264
+ shutil.copy2(config_path, backup_path)
265
+ self._config_backup_path = backup_path
266
+
267
+ # Build permission list in new format
268
+ permissions = []
269
+
270
+ # Allow read access to all paths
271
+ permissions.append({"allow": "Read(**)"})
272
+ permissions.append({"allow": "Grep(**)"})
273
+ permissions.append({"allow": "Glob(**)"})
274
+ permissions.append({"allow": "List(**)"})
275
+
276
+ # Add allowed shell commands (extract command names from ALLOWED_TOOLS)
277
+ for tool in ALLOWED_TOOLS:
278
+ if tool.startswith("Shell(") and tool.endswith(")"):
279
+ # Extract command: "Shell(git log)" -> "git log"
280
+ command = tool[6:-1]
281
+ # Cursor Agent Shell permissions use first token only
282
+ # "git log" becomes "git" in the config
283
+ base_command = command.split()[0]
284
+ permissions.append({"allow": f"Shell({base_command})"})
285
+
286
+ # Create read-only config file
287
+ cursor_dir.mkdir(parents=True, exist_ok=True)
288
+ config_data = {
289
+ "permissions": permissions,
290
+ "description": "Read-only mode enforced by foundry-mcp",
291
+ "approvalMode": "allowlist", # Use allowlist mode for security
292
+ }
293
+
294
+ with open(config_path, "w") as f:
295
+ json.dump(config_data, f, indent=2)
296
+
297
+ return cursor_dir
298
+
299
+ def _cleanup_config_file(self) -> None:
300
+ """Restore original ~/.cursor/cli-config.json from backup."""
301
+ # Prevent double-cleanup (e.g., from finally block + __del__)
302
+ if hasattr(self, "_cleanup_done") and self._cleanup_done:
303
+ return
304
+
305
+ cursor_dir = Path.home() / ".cursor"
306
+ config_path = cursor_dir / "cli-config.json"
307
+
308
+ try:
309
+ # Restore original config from backup if it existed
310
+ if (
311
+ hasattr(self, "_config_backup_path")
312
+ and self._config_backup_path is not None
313
+ and self._config_backup_path.exists()
314
+ ):
315
+ shutil.move(self._config_backup_path, config_path)
316
+ elif (
317
+ hasattr(self, "_original_config_existed")
318
+ and not self._original_config_existed
319
+ and config_path.exists()
320
+ ):
321
+ # No original config existed - remove our temporary one
322
+ config_path.unlink()
323
+
324
+ # Clean up any leftover backup files
325
+ if (
326
+ hasattr(self, "_config_backup_path")
327
+ and self._config_backup_path is not None
328
+ and self._config_backup_path.exists()
329
+ ):
330
+ self._config_backup_path.unlink()
331
+
332
+ # Clean up any .bad files created by cursor-agent CLI
333
+ bad_config_path = Path(str(config_path) + ".bad")
334
+ if bad_config_path.exists():
335
+ bad_config_path.unlink()
336
+
337
+ except (OSError, FileNotFoundError):
338
+ # Files already removed or don't exist, ignore
339
+ pass
340
+ finally:
341
+ # Mark cleanup as done to prevent double-cleanup
342
+ if hasattr(self, "_cleanup_done"):
343
+ self._cleanup_done = True
344
+ if hasattr(self, "_config_backup_path"):
345
+ self._config_backup_path = None
346
+ if hasattr(self, "_original_config_existed"):
347
+ self._original_config_existed = False
348
+
349
+ def _build_command(
350
+ self,
351
+ request: ProviderRequest,
352
+ model: str,
353
+ ) -> Tuple[List[str], str]:
354
+ """
355
+ Assemble the cursor-agent CLI invocation with read-only config.
356
+
357
+ Args:
358
+ request: Generation request
359
+ model: Model ID to use
360
+
361
+ Returns:
362
+ Tuple of (command args, prompt to pass via stdin)
363
+
364
+ Note:
365
+ Config is read from ~/.cursor/cli-config.json (managed by _create_readonly_config).
366
+ Uses --print mode for non-interactive execution with JSON output.
367
+ Prompt is passed via stdin to avoid CLI argument length limits.
368
+ """
369
+ # cursor-agent in headless mode: --print --output-format json
370
+ command = [self._binary, "--print", "--output-format", "json"]
371
+
372
+ if model:
373
+ command.extend(["--model", model])
374
+
375
+ # Note: cursor-agent doesn't support --temperature or --max-tokens in --print mode
376
+ # These flags are silently ignored if provided
377
+
378
+ extra_flags = (request.metadata or {}).get("cursor_agent_flags")
379
+ if isinstance(extra_flags, list):
380
+ for flag in extra_flags:
381
+ if isinstance(flag, str) and flag.strip():
382
+ command.append(flag.strip())
383
+
384
+ # Build full prompt with system context (passed via stdin)
385
+ full_prompt = request.prompt
386
+ if request.system_prompt:
387
+ full_prompt = f"{request.system_prompt.strip()}\n\n{SHELL_COMMAND_WARNING.strip()}\n\n{request.prompt}"
388
+ else:
389
+ full_prompt = f"{SHELL_COMMAND_WARNING.strip()}\n\n{request.prompt}"
390
+
391
+ return command, full_prompt
392
+
393
+ def _run(
394
+ self,
395
+ command: Sequence[str],
396
+ *,
397
+ timeout: Optional[float],
398
+ input_data: Optional[str] = None,
399
+ ) -> subprocess.CompletedProcess[str]:
400
+ try:
401
+ return self._runner(
402
+ command, timeout=int(timeout) if timeout else None, env=self._env, input_data=input_data
403
+ )
404
+ except FileNotFoundError as exc:
405
+ raise ProviderUnavailableError(
406
+ f"Cursor Agent CLI '{self._binary}' is not available on PATH.",
407
+ provider=self.metadata.provider_id,
408
+ ) from exc
409
+ except subprocess.TimeoutExpired as exc:
410
+ raise ProviderTimeoutError(
411
+ f"Command timed out after {exc.timeout} seconds",
412
+ provider=self.metadata.provider_id,
413
+ ) from exc
414
+
415
+ def _run_with_retry(
416
+ self,
417
+ command: Sequence[str],
418
+ timeout: Optional[float],
419
+ input_data: Optional[str] = None,
420
+ ) -> Tuple[subprocess.CompletedProcess[str], bool]:
421
+ """
422
+ Execute the command and retry without --output-format json when the CLI lacks support.
423
+ """
424
+ completed = self._run(command, timeout=timeout, input_data=input_data)
425
+ if completed.returncode == 0:
426
+ return completed, True
427
+
428
+ stderr_text = (completed.stderr or "").lower()
429
+ # Check if --output-format flag is in command
430
+ has_json_flag = "--output-format" in command
431
+ if has_json_flag and any(phrase in stderr_text for phrase in ("unknown option", "unrecognized option")):
432
+ # Remove --output-format json from command for retry
433
+ retry_command = []
434
+ skip_next = False
435
+ for part in command:
436
+ if skip_next:
437
+ skip_next = False
438
+ continue
439
+ if part == "--output-format":
440
+ skip_next = True # Skip next arg (the "json" value)
441
+ continue
442
+ retry_command.append(part)
443
+
444
+ retry_process = self._run(retry_command, timeout=timeout, input_data=input_data)
445
+ if retry_process.returncode == 0:
446
+ return retry_process, False
447
+
448
+ stderr_text = (retry_process.stderr or stderr_text).strip()
449
+ # Cursor Agent outputs errors to stdout as plain text, not stderr
450
+ stdout_text = (retry_process.stdout or "").strip()
451
+ logger.debug(f"Cursor Agent CLI stderr (retry): {stderr_text or 'no stderr'}")
452
+ error_msg = f"Cursor Agent CLI exited with code {retry_process.returncode}"
453
+ if stdout_text and not stdout_text.startswith("{"):
454
+ # Plain text error in stdout (not JSON response)
455
+ error_msg += f": {stdout_text[:500]}"
456
+ elif stderr_text:
457
+ error_msg += f": {stderr_text[:500]}"
458
+ raise ProviderExecutionError(
459
+ error_msg,
460
+ provider=self.metadata.provider_id,
461
+ )
462
+
463
+ stderr_text = (completed.stderr or "").strip()
464
+ # Cursor Agent outputs errors to stdout as plain text, not stderr
465
+ stdout_text = (completed.stdout or "").strip()
466
+ logger.debug(f"Cursor Agent CLI stderr: {stderr_text or 'no stderr'}")
467
+ error_msg = f"Cursor Agent CLI exited with code {completed.returncode}"
468
+ if stdout_text and not stdout_text.startswith("{"):
469
+ # Plain text error in stdout (not JSON response)
470
+ error_msg += f": {stdout_text[:500]}"
471
+ elif stderr_text:
472
+ error_msg += f": {stderr_text[:500]}"
473
+ raise ProviderExecutionError(
474
+ error_msg,
475
+ provider=self.metadata.provider_id,
476
+ )
477
+
478
+ def _parse_json_payload(self, raw: str) -> Dict[str, Any]:
479
+ text = raw.strip()
480
+ if not text:
481
+ raise ProviderExecutionError(
482
+ "Cursor Agent CLI returned empty output.",
483
+ provider=self.metadata.provider_id,
484
+ )
485
+ try:
486
+ payload = json.loads(text)
487
+ except json.JSONDecodeError as exc:
488
+ logger.debug(f"Cursor Agent CLI JSON parse error: {exc}")
489
+ raise ProviderExecutionError(
490
+ "Cursor Agent CLI returned invalid JSON response",
491
+ provider=self.metadata.provider_id,
492
+ ) from exc
493
+ if not isinstance(payload, dict):
494
+ raise ProviderExecutionError(
495
+ "Cursor Agent CLI returned an unexpected payload.",
496
+ provider=self.metadata.provider_id,
497
+ )
498
+ return payload
499
+
500
+ def _usage_from_payload(self, payload: Dict[str, Any]) -> TokenUsage:
501
+ usage = payload.get("usage") or {}
502
+ return TokenUsage(
503
+ input_tokens=int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0),
504
+ output_tokens=int(usage.get("output_tokens") or usage.get("completion_tokens") or 0),
505
+ total_tokens=int(usage.get("total_tokens") or 0),
506
+ )
507
+
508
+ def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
509
+ if not stream or not content:
510
+ return
511
+ self._emit_stream_chunk(StreamChunk(content=content, index=0))
512
+
513
+ def _execute(self, request: ProviderRequest) -> ProviderResult:
514
+ if request.attachments:
515
+ raise ProviderExecutionError(
516
+ "Cursor Agent CLI does not support attachments.",
517
+ provider=self.metadata.provider_id,
518
+ )
519
+
520
+ # Resolve model: request.model takes precedence, then metadata, then instance default
521
+ model = (
522
+ request.model
523
+ or (str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else None)
524
+ or self._model
525
+ )
526
+
527
+ # Backup and replace HOME config with read-only version
528
+ self._create_readonly_config()
529
+
530
+ try:
531
+ # Build command (config is read from ~/.cursor/cli-config.json)
532
+ # Prompt is passed via stdin to avoid CLI argument length limits
533
+ command, prompt = self._build_command(request, model)
534
+ timeout = request.timeout or self._timeout
535
+ completed, json_mode = self._run_with_retry(command, timeout, input_data=prompt)
536
+ finally:
537
+ # Always restore original config, even if command fails
538
+ self._cleanup_config_file()
539
+
540
+ if json_mode:
541
+ payload = self._parse_json_payload(completed.stdout)
542
+ # cursor-agent returns content in "result" field
543
+ content = str(payload.get("result") or payload.get("content") or "").strip()
544
+ if not content and payload.get("messages"):
545
+ content = " ".join(
546
+ str(message.get("content") or "") for message in payload["messages"] if isinstance(message, dict)
547
+ ).strip()
548
+ if not content:
549
+ content = (payload.get("raw") or "").strip()
550
+ usage = self._usage_from_payload(payload)
551
+ self._emit_stream_if_requested(content, stream=request.stream)
552
+ return ProviderResult(
553
+ content=content,
554
+ provider_id=self.metadata.provider_id,
555
+ model_used=f"{self.metadata.provider_id}:{payload.get('model') or model}",
556
+ status=ProviderStatus.SUCCESS,
557
+ tokens=usage,
558
+ stderr=(completed.stderr or "").strip() or None,
559
+ raw_payload=payload,
560
+ )
561
+
562
+ # Fallback mode (no JSON flag)
563
+ content = completed.stdout.strip()
564
+ self._emit_stream_if_requested(content, stream=request.stream)
565
+ metadata = {
566
+ "raw_text": content,
567
+ "json_mode": False,
568
+ }
569
+ return ProviderResult(
570
+ content=content,
571
+ provider_id=self.metadata.provider_id,
572
+ model_used=f"{self.metadata.provider_id}:{model}",
573
+ status=ProviderStatus.SUCCESS,
574
+ tokens=TokenUsage(),
575
+ stderr=(completed.stderr or "").strip() or None,
576
+ raw_payload=metadata,
577
+ )
578
+
579
+
580
+ def is_cursor_agent_available() -> bool:
581
+ """Cursor Agent CLI availability check."""
582
+ return detect_provider_availability("cursor-agent")
583
+
584
+
585
+ def create_provider(
586
+ *,
587
+ hooks: ProviderHooks,
588
+ model: Optional[str] = None,
589
+ dependencies: Optional[Dict[str, object]] = None,
590
+ overrides: Optional[Dict[str, object]] = None,
591
+ ) -> CursorAgentProvider:
592
+ """
593
+ Factory used by the provider registry.
594
+ """
595
+ dependencies = dependencies or {}
596
+ overrides = overrides or {}
597
+ runner = dependencies.get("runner")
598
+ env = dependencies.get("env")
599
+ binary = overrides.get("binary") or dependencies.get("binary")
600
+ timeout = overrides.get("timeout")
601
+ selected_model = overrides.get("model") if overrides.get("model") else model
602
+
603
+ return CursorAgentProvider(
604
+ metadata=CURSOR_METADATA,
605
+ hooks=hooks,
606
+ model=selected_model, # type: ignore[arg-type]
607
+ binary=binary, # type: ignore[arg-type]
608
+ runner=runner if runner is not None else None, # type: ignore[arg-type]
609
+ env=env if env is not None else None, # type: ignore[arg-type]
610
+ timeout=timeout if timeout is not None else None, # type: ignore[arg-type]
611
+ )
612
+
613
+
614
+ register_provider(
615
+ "cursor-agent",
616
+ factory=create_provider,
617
+ metadata=CURSOR_METADATA,
618
+ availability_check=is_cursor_agent_available,
619
+ description="Cursor Agent CLI adapter with read-only restrictions via config files",
620
+ tags=("cli", "text", "function_calling", "read-only"),
621
+ replace=True,
622
+ )
623
+
624
+
625
+ __all__ = [
626
+ "CursorAgentProvider",
627
+ "create_provider",
628
+ "is_cursor_agent_available",
629
+ "CURSOR_METADATA",
630
+ ]