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,515 @@
1
+ """
2
+ Provider availability detection utilities.
3
+
4
+ Centralizes CLI discovery strategies (PATH resolution, environment overrides,
5
+ and health probes) so provider modules and higher-level tooling can share
6
+ consistent logic.
7
+
8
+ Environment Variables:
9
+ FOUNDRY_PROVIDER_TEST_MODE: When set to "1"/"true"/"yes", bypasses real CLI
10
+ probes and returns availability based on environment override only.
11
+ Useful for CI/CD environments where CLIs may not be installed.
12
+
13
+ FOUNDRY_TOOL_PATH: Additional PATH directories for binary resolution.
14
+
15
+ Per-provider overrides (e.g., FOUNDRY_GEMINI_AVAILABLE_OVERRIDE):
16
+ Set to "1"/"true"/"yes" to force availability, "0"/"false"/"no" to force
17
+ unavailability. Takes precedence over actual CLI detection.
18
+
19
+ Example:
20
+ >>> from foundry_mcp.core.providers.detectors import detect_provider_availability
21
+ >>> detect_provider_availability("gemini")
22
+ True
23
+ >>> from foundry_mcp.core.providers.detectors import get_provider_statuses
24
+ >>> get_provider_statuses()
25
+ {'gemini': True, 'codex': False, 'cursor-agent': True, 'claude': True, 'opencode': False}
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import os
32
+ import shutil
33
+ import subprocess
34
+ import time
35
+ from dataclasses import dataclass, field
36
+ from typing import Dict, Iterable, Optional, Sequence, Tuple
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # Cache for provider availability: {provider_id: (is_available, timestamp)}
41
+ _AVAILABILITY_CACHE: Dict[str, Tuple[bool, float]] = {}
42
+
43
+ def _get_cache_ttl() -> float:
44
+ """Get cache TTL from config or default to 3600s."""
45
+ try:
46
+ from foundry_mcp.config import get_config
47
+ return float(get_config().providers.get("availability_cache_ttl", 3600))
48
+ except Exception:
49
+ return 3600.0
50
+
51
+ # Environment variable for test mode (bypasses real CLI probes)
52
+ _TEST_MODE_ENV = "FOUNDRY_PROVIDER_TEST_MODE"
53
+
54
+ # Environment variable for additional tool PATH directories
55
+ _TOOL_PATH_ENV = "FOUNDRY_TOOL_PATH"
56
+
57
+
58
+ def _coerce_bool(value: Optional[str]) -> Optional[bool]:
59
+ """
60
+ Convert an environment variable string to a boolean.
61
+
62
+ Args:
63
+ value: String value from environment variable
64
+
65
+ Returns:
66
+ True for "1", "true", "yes", "on" (case-insensitive)
67
+ False for "0", "false", "no", "off" (case-insensitive)
68
+ None for any other value or if value is None
69
+ """
70
+ if value is None:
71
+ return None
72
+ lowered = value.strip().lower()
73
+ if lowered in {"1", "true", "yes", "on"}:
74
+ return True
75
+ if lowered in {"0", "false", "no", "off"}:
76
+ return False
77
+ return None
78
+
79
+
80
+ def _is_test_mode() -> bool:
81
+ """Check if test mode is enabled (bypasses real CLI probes)."""
82
+ return _coerce_bool(os.environ.get(_TEST_MODE_ENV)) is True
83
+
84
+
85
+ def _resolve_executable(binary: str) -> Optional[str]:
86
+ """
87
+ Resolve a binary name to its full path.
88
+
89
+ Checks FOUNDRY_TOOL_PATH first (if set), then falls back to system PATH.
90
+
91
+ Args:
92
+ binary: Binary name to resolve (e.g., "gemini", "codex")
93
+
94
+ Returns:
95
+ Full path to the binary if found, None otherwise
96
+ """
97
+ configured_path = os.environ.get(_TOOL_PATH_ENV)
98
+ if configured_path:
99
+ return shutil.which(binary, path=configured_path)
100
+ return shutil.which(binary)
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class ProviderDetector:
105
+ """
106
+ Configuration describing how to detect a provider CLI.
107
+
108
+ This dataclass encapsulates all information needed to detect whether
109
+ a provider's CLI tool is available and functional.
110
+
111
+ Attributes:
112
+ provider_id: Canonical provider identifier (e.g., "gemini", "codex")
113
+ binary_name: Default binary name to search for in PATH
114
+ override_env: Environment variable to force availability (True/False)
115
+ binary_env: Environment variable to override the binary path/name
116
+ probe_args: Arguments for health probe command (default: ("--version",))
117
+ probe_timeout: Timeout in seconds for health probe (default: 5)
118
+
119
+ Example:
120
+ >>> detector = ProviderDetector(
121
+ ... provider_id="gemini",
122
+ ... binary_name="gemini",
123
+ ... override_env="FOUNDRY_GEMINI_AVAILABLE_OVERRIDE",
124
+ ... binary_env="FOUNDRY_GEMINI_BINARY",
125
+ ... probe_args=("--help",),
126
+ ... )
127
+ >>> detector.is_available()
128
+ True
129
+ """
130
+
131
+ provider_id: str
132
+ binary_name: str
133
+ override_env: Optional[str] = None
134
+ binary_env: Optional[str] = None
135
+ probe_args: Sequence[str] = field(default_factory=lambda: ("--version",))
136
+ probe_timeout: int = 5
137
+
138
+ def resolve_binary(self) -> Optional[str]:
139
+ """
140
+ Resolve the binary path for this provider.
141
+
142
+ Checks the binary_env environment variable first, then falls back
143
+ to resolving binary_name via PATH.
144
+
145
+ Returns:
146
+ Full path to the binary if found, None otherwise
147
+ """
148
+ binary = self.binary_name
149
+ if self.binary_env:
150
+ env_binary = os.environ.get(self.binary_env)
151
+ if env_binary:
152
+ binary = env_binary
153
+ return _resolve_executable(binary)
154
+
155
+ def _run_probe(self, executable: str) -> bool:
156
+ """
157
+ Run a health probe against the CLI binary.
158
+
159
+ Args:
160
+ executable: Full path to the binary
161
+
162
+ Returns:
163
+ True if probe succeeds, False otherwise
164
+ """
165
+ if not self.probe_args:
166
+ return True
167
+
168
+ try:
169
+ subprocess.run(
170
+ [executable, *self.probe_args],
171
+ stdout=subprocess.DEVNULL,
172
+ stderr=subprocess.DEVNULL,
173
+ timeout=self.probe_timeout,
174
+ check=True,
175
+ )
176
+ return True
177
+ except (OSError, subprocess.SubprocessError) as exc:
178
+ logger.debug(
179
+ "Probe for provider '%s' failed via %s: %s",
180
+ self.provider_id,
181
+ executable,
182
+ exc,
183
+ )
184
+ return False
185
+
186
+ def is_available(self, *, use_probe: bool = True) -> bool:
187
+ """
188
+ Check whether this provider is available (with caching).
189
+
190
+ Resolution order:
191
+ 1. Check override_env (if set, returns its boolean value - takes precedence)
192
+ 2. In test mode, return False (no real CLI available)
193
+ 3. Check cache (if valid)
194
+ 4. Resolve binary via PATH
195
+ 5. Optionally run health probe
196
+
197
+ Args:
198
+ use_probe: When True, run health probe after finding binary.
199
+ When False, only check PATH resolution.
200
+
201
+ Returns:
202
+ True if provider is available, False otherwise
203
+ """
204
+ # Check environment override first (takes precedence over cache)
205
+ if self.override_env:
206
+ override = _coerce_bool(os.environ.get(self.override_env))
207
+ if override is not None:
208
+ logger.debug(
209
+ "Provider '%s' availability override: %s",
210
+ self.provider_id,
211
+ override,
212
+ )
213
+ return override
214
+
215
+ # In test mode, return False unless overridden above
216
+ if _is_test_mode():
217
+ logger.debug(
218
+ "Provider '%s' unavailable (test mode enabled)",
219
+ self.provider_id,
220
+ )
221
+ return False
222
+
223
+ # Check cache (only for non-overridden, non-test-mode cases)
224
+ cache_key = f"{self.provider_id}:{use_probe}"
225
+ cached = _AVAILABILITY_CACHE.get(cache_key)
226
+ if cached is not None:
227
+ is_avail, cached_time = cached
228
+ if time.time() - cached_time < _get_cache_ttl():
229
+ return is_avail
230
+
231
+ # Resolve binary path
232
+ executable = self.resolve_binary()
233
+ if not executable:
234
+ logger.debug(
235
+ "Provider '%s' unavailable (binary not found in PATH)",
236
+ self.provider_id,
237
+ )
238
+ _AVAILABILITY_CACHE[cache_key] = (False, time.time())
239
+ return False
240
+
241
+ # Skip probe if not requested
242
+ if not use_probe:
243
+ _AVAILABILITY_CACHE[cache_key] = (True, time.time())
244
+ return True
245
+
246
+ # Run health probe
247
+ result = self._run_probe(executable)
248
+ _AVAILABILITY_CACHE[cache_key] = (result, time.time())
249
+ return result
250
+
251
+ def get_unavailability_reason(self, *, use_probe: bool = True) -> Optional[str]:
252
+ """
253
+ Return a human-readable reason why this provider is unavailable.
254
+
255
+ Useful for diagnostic messages when a provider cannot be used.
256
+ Returns None if the provider is available.
257
+
258
+ Args:
259
+ use_probe: When True, include probe failures in reasons.
260
+ When False, only check PATH/override status.
261
+
262
+ Returns:
263
+ String describing why unavailable, or None if available
264
+
265
+ Example:
266
+ >>> detector = ProviderDetector(
267
+ ... provider_id="missing",
268
+ ... binary_name="nonexistent-binary",
269
+ ... )
270
+ >>> detector.get_unavailability_reason()
271
+ "Binary 'nonexistent-binary' not found in PATH"
272
+ """
273
+ # Check environment override
274
+ if self.override_env:
275
+ override = _coerce_bool(os.environ.get(self.override_env))
276
+ if override is False:
277
+ return f"Explicitly disabled via {self.override_env}=0"
278
+ if override is True:
279
+ return None # Available via override
280
+
281
+ # Check test mode
282
+ if _is_test_mode():
283
+ return f"Test mode enabled ({_TEST_MODE_ENV}=1) and no override set"
284
+
285
+ # Check binary resolution
286
+ executable = self.resolve_binary()
287
+ if not executable:
288
+ return f"Binary '{self.binary_name}' not found in PATH"
289
+
290
+ # Check probe if requested
291
+ if use_probe and not self._run_probe(executable):
292
+ probe_cmd = f"{executable} {' '.join(self.probe_args)}"
293
+ return f"Health probe failed: {probe_cmd}"
294
+
295
+ return None # Available
296
+
297
+
298
+ # =============================================================================
299
+ # Default Provider Detectors
300
+ # =============================================================================
301
+
302
+ _DEFAULT_DETECTORS: tuple[ProviderDetector, ...] = (
303
+ ProviderDetector(
304
+ provider_id="gemini",
305
+ binary_name="gemini",
306
+ override_env="FOUNDRY_GEMINI_AVAILABLE_OVERRIDE",
307
+ binary_env="FOUNDRY_GEMINI_BINARY",
308
+ probe_args=("--help",),
309
+ ),
310
+ ProviderDetector(
311
+ provider_id="codex",
312
+ binary_name="codex",
313
+ override_env="FOUNDRY_CODEX_AVAILABLE_OVERRIDE",
314
+ binary_env="FOUNDRY_CODEX_BINARY",
315
+ probe_args=("--version",),
316
+ ),
317
+ ProviderDetector(
318
+ provider_id="cursor-agent",
319
+ binary_name="cursor-agent",
320
+ override_env="FOUNDRY_CURSOR_AGENT_AVAILABLE_OVERRIDE",
321
+ binary_env="FOUNDRY_CURSOR_AGENT_BINARY",
322
+ probe_args=("--version",),
323
+ ),
324
+ ProviderDetector(
325
+ provider_id="claude",
326
+ binary_name="claude",
327
+ override_env="FOUNDRY_CLAUDE_AVAILABLE_OVERRIDE",
328
+ binary_env="FOUNDRY_CLAUDE_BINARY",
329
+ probe_args=("--version",),
330
+ ),
331
+ ProviderDetector(
332
+ provider_id="opencode",
333
+ binary_name="opencode",
334
+ override_env="FOUNDRY_OPENCODE_AVAILABLE_OVERRIDE",
335
+ binary_env="FOUNDRY_OPENCODE_BINARY",
336
+ probe_args=("--version",),
337
+ ),
338
+ )
339
+
340
+ # Global detector registry
341
+ _DETECTORS: Dict[str, ProviderDetector] = {}
342
+
343
+
344
+ def _reset_default_detectors() -> None:
345
+ """Reset the detector registry to default detectors."""
346
+ _DETECTORS.clear()
347
+ for detector in _DEFAULT_DETECTORS:
348
+ _DETECTORS[detector.provider_id] = detector
349
+
350
+
351
+ # =============================================================================
352
+ # Public API
353
+ # =============================================================================
354
+
355
+
356
+ def register_detector(detector: ProviderDetector, *, replace: bool = False) -> None:
357
+ """
358
+ Register a detector configuration.
359
+
360
+ Args:
361
+ detector: ProviderDetector instance to register
362
+ replace: If True, overwrite existing registration. If False (default),
363
+ raise ValueError if provider_id already exists.
364
+
365
+ Raises:
366
+ ValueError: If provider_id already registered and replace=False
367
+
368
+ Example:
369
+ >>> custom_detector = ProviderDetector(
370
+ ... provider_id="my-provider",
371
+ ... binary_name="my-cli",
372
+ ... )
373
+ >>> register_detector(custom_detector)
374
+ """
375
+ if detector.provider_id in _DETECTORS and not replace:
376
+ raise ValueError(f"Detector for '{detector.provider_id}' already exists")
377
+ _DETECTORS[detector.provider_id] = detector
378
+ logger.debug("Registered detector for provider '%s'", detector.provider_id)
379
+
380
+
381
+ def get_detector(provider_id: str) -> Optional[ProviderDetector]:
382
+ """
383
+ Return the detector for a provider ID.
384
+
385
+ Args:
386
+ provider_id: Provider identifier (e.g., "gemini", "codex")
387
+
388
+ Returns:
389
+ ProviderDetector if registered, None otherwise
390
+ """
391
+ return _DETECTORS.get(provider_id)
392
+
393
+
394
+ def detect_provider_availability(provider_id: str, *, use_probe: bool = True) -> bool:
395
+ """
396
+ Check whether a provider is available.
397
+
398
+ Args:
399
+ provider_id: Provider identifier (e.g., "gemini", "codex", "cursor-agent")
400
+ use_probe: When True, run health probe. When False, only check PATH.
401
+
402
+ Returns:
403
+ True if provider is available, False otherwise
404
+
405
+ Raises:
406
+ KeyError: If no detector registered for provider_id
407
+
408
+ Example:
409
+ >>> detect_provider_availability("gemini")
410
+ True
411
+ >>> detect_provider_availability("nonexistent")
412
+ KeyError: "No detector registered for provider 'nonexistent'"
413
+ """
414
+ detector = get_detector(provider_id)
415
+ if detector is None:
416
+ raise KeyError(f"No detector registered for provider '{provider_id}'")
417
+ return detector.is_available(use_probe=use_probe)
418
+
419
+
420
+ def get_provider_statuses(*, use_probe: bool = True) -> Dict[str, bool]:
421
+ """
422
+ Return availability map for all registered detectors.
423
+
424
+ Args:
425
+ use_probe: When True, run health probes. When False, only check PATH.
426
+
427
+ Returns:
428
+ Dict mapping provider_id to availability boolean
429
+
430
+ Example:
431
+ >>> get_provider_statuses()
432
+ {'gemini': True, 'codex': False, 'cursor-agent': True, 'claude': True, 'opencode': False}
433
+ """
434
+ return {
435
+ provider_id: detector.is_available(use_probe=use_probe)
436
+ for provider_id, detector in _DETECTORS.items()
437
+ }
438
+
439
+
440
+ def get_provider_unavailability_reasons(
441
+ *, use_probe: bool = True
442
+ ) -> Dict[str, Optional[str]]:
443
+ """
444
+ Return unavailability reasons for all registered detectors.
445
+
446
+ Useful for diagnostic messages when providers cannot be used.
447
+ Available providers have None as their reason.
448
+
449
+ Args:
450
+ use_probe: When True, include probe failures in reasons.
451
+ When False, only check PATH/override status.
452
+
453
+ Returns:
454
+ Dict mapping provider_id to reason string (None if available)
455
+
456
+ Example:
457
+ >>> get_provider_unavailability_reasons()
458
+ {
459
+ 'gemini': None, # available
460
+ 'codex': "Binary 'codex' not found in PATH",
461
+ 'cursor-agent': None, # available
462
+ 'claude': None, # available
463
+ 'opencode': "Health probe failed: /usr/bin/opencode --version"
464
+ }
465
+ """
466
+ return {
467
+ provider_id: detector.get_unavailability_reason(use_probe=use_probe)
468
+ for provider_id, detector in _DETECTORS.items()
469
+ }
470
+
471
+
472
+ def list_detectors() -> Iterable[ProviderDetector]:
473
+ """
474
+ Return all registered detector configurations.
475
+
476
+ Returns:
477
+ Tuple of registered ProviderDetector instances
478
+
479
+ Example:
480
+ >>> for detector in list_detectors():
481
+ ... print(detector.provider_id)
482
+ gemini
483
+ codex
484
+ cursor-agent
485
+ claude
486
+ opencode
487
+ """
488
+ return tuple(_DETECTORS.values())
489
+
490
+
491
+ def reset_detectors() -> None:
492
+ """
493
+ Reset detectors to the default set.
494
+
495
+ Primarily used by tests to restore a clean state.
496
+ Also clears the availability cache to ensure fresh detection.
497
+ """
498
+ _AVAILABILITY_CACHE.clear()
499
+ _reset_default_detectors()
500
+
501
+
502
+ # Initialize with default detectors
503
+ _reset_default_detectors()
504
+
505
+
506
+ __all__ = [
507
+ "ProviderDetector",
508
+ "register_detector",
509
+ "get_detector",
510
+ "detect_provider_availability",
511
+ "get_provider_statuses",
512
+ "get_provider_unavailability_reasons",
513
+ "list_detectors",
514
+ "reset_detectors",
515
+ ]