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