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