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.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- 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
|
+
]
|