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,472 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude CLI provider implementation.
|
|
3
|
+
|
|
4
|
+
Bridges the `claude` command-line interface to the ProviderContext contract by
|
|
5
|
+
handling availability checks, safe command construction, response parsing, and
|
|
6
|
+
token usage normalization. Restricts to read-only operations for security.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
|
16
|
+
|
|
17
|
+
from .base import (
|
|
18
|
+
ProviderCapability,
|
|
19
|
+
ProviderContext,
|
|
20
|
+
ProviderExecutionError,
|
|
21
|
+
ProviderHooks,
|
|
22
|
+
ProviderMetadata,
|
|
23
|
+
ProviderRequest,
|
|
24
|
+
ProviderResult,
|
|
25
|
+
ProviderStatus,
|
|
26
|
+
ProviderTimeoutError,
|
|
27
|
+
ProviderUnavailableError,
|
|
28
|
+
StreamChunk,
|
|
29
|
+
TokenUsage,
|
|
30
|
+
)
|
|
31
|
+
from .detectors import detect_provider_availability
|
|
32
|
+
from .registry import register_provider
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
DEFAULT_BINARY = "claude"
|
|
37
|
+
DEFAULT_TIMEOUT_SECONDS = 360
|
|
38
|
+
AVAILABILITY_OVERRIDE_ENV = "CLAUDE_CLI_AVAILABLE_OVERRIDE"
|
|
39
|
+
CUSTOM_BINARY_ENV = "CLAUDE_CLI_BINARY"
|
|
40
|
+
|
|
41
|
+
# Read-only tools allowed for Claude provider
|
|
42
|
+
# Core tools
|
|
43
|
+
ALLOWED_TOOLS = [
|
|
44
|
+
# File operations (read-only)
|
|
45
|
+
"Read",
|
|
46
|
+
"Grep",
|
|
47
|
+
"Glob",
|
|
48
|
+
# Task delegation
|
|
49
|
+
"Task",
|
|
50
|
+
# Bash commands - file viewing
|
|
51
|
+
"Bash(cat)",
|
|
52
|
+
"Bash(head:*)",
|
|
53
|
+
"Bash(tail:*)",
|
|
54
|
+
"Bash(bat:*)",
|
|
55
|
+
# Bash commands - directory listing/navigation
|
|
56
|
+
"Bash(ls:*)",
|
|
57
|
+
"Bash(tree:*)",
|
|
58
|
+
"Bash(pwd)",
|
|
59
|
+
"Bash(which:*)",
|
|
60
|
+
"Bash(whereis:*)",
|
|
61
|
+
# Bash commands - search/find
|
|
62
|
+
"Bash(grep:*)",
|
|
63
|
+
"Bash(rg:*)",
|
|
64
|
+
"Bash(ag:*)",
|
|
65
|
+
"Bash(find:*)",
|
|
66
|
+
"Bash(fd:*)",
|
|
67
|
+
# Bash commands - git operations (read-only)
|
|
68
|
+
"Bash(git log:*)",
|
|
69
|
+
"Bash(git show:*)",
|
|
70
|
+
"Bash(git diff:*)",
|
|
71
|
+
"Bash(git status:*)",
|
|
72
|
+
"Bash(git grep:*)",
|
|
73
|
+
"Bash(git blame:*)",
|
|
74
|
+
"Bash(git branch:*)",
|
|
75
|
+
"Bash(git rev-parse:*)",
|
|
76
|
+
"Bash(git describe:*)",
|
|
77
|
+
"Bash(git ls-tree:*)",
|
|
78
|
+
# Bash commands - text processing
|
|
79
|
+
"Bash(wc:*)",
|
|
80
|
+
"Bash(cut:*)",
|
|
81
|
+
"Bash(paste:*)",
|
|
82
|
+
"Bash(column:*)",
|
|
83
|
+
"Bash(sort:*)",
|
|
84
|
+
"Bash(uniq:*)",
|
|
85
|
+
# Bash commands - data formats
|
|
86
|
+
"Bash(jq:*)",
|
|
87
|
+
"Bash(yq:*)",
|
|
88
|
+
# Bash commands - file analysis
|
|
89
|
+
"Bash(file:*)",
|
|
90
|
+
"Bash(stat:*)",
|
|
91
|
+
"Bash(du:*)",
|
|
92
|
+
"Bash(df:*)",
|
|
93
|
+
# Bash commands - checksums/hashing
|
|
94
|
+
"Bash(md5sum:*)",
|
|
95
|
+
"Bash(shasum:*)",
|
|
96
|
+
"Bash(sha256sum:*)",
|
|
97
|
+
"Bash(sha512sum:*)",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
# Tools that should be explicitly blocked
|
|
101
|
+
DISALLOWED_TOOLS = [
|
|
102
|
+
"Write",
|
|
103
|
+
"Edit",
|
|
104
|
+
# Web operations (data exfiltration risk)
|
|
105
|
+
"WebSearch",
|
|
106
|
+
"WebFetch",
|
|
107
|
+
# Dangerous file operations
|
|
108
|
+
"Bash(rm:*)",
|
|
109
|
+
"Bash(rmdir:*)",
|
|
110
|
+
"Bash(dd:*)",
|
|
111
|
+
"Bash(mkfs:*)",
|
|
112
|
+
"Bash(fdisk:*)",
|
|
113
|
+
# File modifications
|
|
114
|
+
"Bash(touch:*)",
|
|
115
|
+
"Bash(mkdir:*)",
|
|
116
|
+
"Bash(mv:*)",
|
|
117
|
+
"Bash(cp:*)",
|
|
118
|
+
"Bash(chmod:*)",
|
|
119
|
+
"Bash(chown:*)",
|
|
120
|
+
"Bash(sed:*)",
|
|
121
|
+
"Bash(awk:*)",
|
|
122
|
+
# Git write operations
|
|
123
|
+
"Bash(git add:*)",
|
|
124
|
+
"Bash(git commit:*)",
|
|
125
|
+
"Bash(git push:*)",
|
|
126
|
+
"Bash(git pull:*)",
|
|
127
|
+
"Bash(git merge:*)",
|
|
128
|
+
"Bash(git rebase:*)",
|
|
129
|
+
"Bash(git reset:*)",
|
|
130
|
+
"Bash(git checkout:*)",
|
|
131
|
+
# Package installations
|
|
132
|
+
"Bash(npm install:*)",
|
|
133
|
+
"Bash(pip install:*)",
|
|
134
|
+
"Bash(apt install:*)",
|
|
135
|
+
"Bash(brew install:*)",
|
|
136
|
+
# System operations
|
|
137
|
+
"Bash(sudo:*)",
|
|
138
|
+
"Bash(halt:*)",
|
|
139
|
+
"Bash(reboot:*)",
|
|
140
|
+
"Bash(shutdown:*)",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
# System prompt warning about shell command limitations
|
|
144
|
+
SHELL_COMMAND_WARNING = """
|
|
145
|
+
IMPORTANT SECURITY NOTE: When using shell commands, be aware of the following restrictions:
|
|
146
|
+
1. Only specific read-only commands are allowed (cat, grep, git log, etc.)
|
|
147
|
+
2. Write operations, file modifications, and destructive commands are blocked
|
|
148
|
+
3. Avoid using piped commands as they may bypass some security checks
|
|
149
|
+
4. Use sequential commands or alternative approaches when possible
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class RunnerProtocol(Protocol):
|
|
154
|
+
"""Callable signature used for executing Claude CLI commands."""
|
|
155
|
+
|
|
156
|
+
def __call__(
|
|
157
|
+
self,
|
|
158
|
+
command: Sequence[str],
|
|
159
|
+
*,
|
|
160
|
+
timeout: Optional[int] = None,
|
|
161
|
+
env: Optional[Dict[str, str]] = None,
|
|
162
|
+
input_data: Optional[str] = None,
|
|
163
|
+
) -> subprocess.CompletedProcess[str]:
|
|
164
|
+
raise NotImplementedError
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _default_runner(
|
|
168
|
+
command: Sequence[str],
|
|
169
|
+
*,
|
|
170
|
+
timeout: Optional[int] = None,
|
|
171
|
+
env: Optional[Dict[str, str]] = None,
|
|
172
|
+
input_data: Optional[str] = None,
|
|
173
|
+
) -> subprocess.CompletedProcess[str]:
|
|
174
|
+
"""Invoke the Claude CLI via subprocess."""
|
|
175
|
+
return subprocess.run( # noqa: S603,S607 - intentional CLI invocation
|
|
176
|
+
list(command),
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True,
|
|
179
|
+
input=input_data,
|
|
180
|
+
timeout=timeout,
|
|
181
|
+
env=env,
|
|
182
|
+
check=False,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
CLAUDE_METADATA = ProviderMetadata(
|
|
187
|
+
provider_id="claude",
|
|
188
|
+
display_name="Anthropic Claude CLI",
|
|
189
|
+
models=[], # Model validation delegated to CLI
|
|
190
|
+
default_model="opus",
|
|
191
|
+
capabilities={
|
|
192
|
+
ProviderCapability.TEXT,
|
|
193
|
+
ProviderCapability.STREAMING,
|
|
194
|
+
ProviderCapability.VISION,
|
|
195
|
+
ProviderCapability.THINKING,
|
|
196
|
+
},
|
|
197
|
+
security_flags={"writes_allowed": False, "read_only": True},
|
|
198
|
+
extra={"cli": "claude", "output_format": "json", "allowed_tools": ALLOWED_TOOLS},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ClaudeProvider(ProviderContext):
|
|
203
|
+
"""ProviderContext implementation backed by the Claude CLI with read-only restrictions."""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
metadata: ProviderMetadata,
|
|
208
|
+
hooks: ProviderHooks,
|
|
209
|
+
*,
|
|
210
|
+
model: Optional[str] = None,
|
|
211
|
+
binary: Optional[str] = None,
|
|
212
|
+
runner: Optional[RunnerProtocol] = None,
|
|
213
|
+
env: Optional[Dict[str, str]] = None,
|
|
214
|
+
timeout: Optional[int] = None,
|
|
215
|
+
):
|
|
216
|
+
super().__init__(metadata, hooks)
|
|
217
|
+
self._runner = runner or _default_runner
|
|
218
|
+
self._binary = binary or os.environ.get(CUSTOM_BINARY_ENV, DEFAULT_BINARY)
|
|
219
|
+
self._env = env
|
|
220
|
+
self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
|
|
221
|
+
self._model = model or metadata.default_model or "opus"
|
|
222
|
+
|
|
223
|
+
def _validate_request(self, request: ProviderRequest) -> None:
|
|
224
|
+
"""Validate and normalize request, ignoring unsupported parameters."""
|
|
225
|
+
unsupported: List[str] = []
|
|
226
|
+
# Note: Claude CLI may not support these parameters via flags
|
|
227
|
+
if request.temperature is not None:
|
|
228
|
+
unsupported.append("temperature")
|
|
229
|
+
if request.max_tokens is not None:
|
|
230
|
+
unsupported.append("max_tokens")
|
|
231
|
+
if request.attachments:
|
|
232
|
+
unsupported.append("attachments")
|
|
233
|
+
if unsupported:
|
|
234
|
+
# Log warning but continue - ignore unsupported parameters
|
|
235
|
+
logger.warning(
|
|
236
|
+
f"Claude CLI ignoring unsupported parameters: {', '.join(unsupported)}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _build_command(
|
|
240
|
+
self, model: str, system_prompt: Optional[str] = None
|
|
241
|
+
) -> List[str]:
|
|
242
|
+
"""
|
|
243
|
+
Build Claude CLI command with read-only tool restrictions.
|
|
244
|
+
|
|
245
|
+
Command structure:
|
|
246
|
+
claude --print --output-format json --allowed-tools Read Grep ... --disallowed-tools Write Edit Bash
|
|
247
|
+
(prompt is passed via stdin to avoid CLI argument length limits)
|
|
248
|
+
"""
|
|
249
|
+
command = [self._binary, "--print", "--output-format", "json"]
|
|
250
|
+
|
|
251
|
+
# Add read-only tool restrictions
|
|
252
|
+
command.extend(["--allowed-tools"] + ALLOWED_TOOLS)
|
|
253
|
+
command.extend(["--disallowed-tools"] + DISALLOWED_TOOLS)
|
|
254
|
+
|
|
255
|
+
# Build system prompt with security warning
|
|
256
|
+
full_system_prompt = system_prompt or ""
|
|
257
|
+
if full_system_prompt:
|
|
258
|
+
full_system_prompt = f"{full_system_prompt.strip()}\n\n{SHELL_COMMAND_WARNING.strip()}"
|
|
259
|
+
else:
|
|
260
|
+
full_system_prompt = SHELL_COMMAND_WARNING.strip()
|
|
261
|
+
|
|
262
|
+
# Add system prompt
|
|
263
|
+
command.extend(["--system-prompt", full_system_prompt])
|
|
264
|
+
|
|
265
|
+
# Add model if specified and not default
|
|
266
|
+
if model and model != self.metadata.default_model:
|
|
267
|
+
command.extend(["--model", model])
|
|
268
|
+
|
|
269
|
+
return command
|
|
270
|
+
|
|
271
|
+
def _run(
|
|
272
|
+
self, command: Sequence[str], timeout: Optional[float], input_data: Optional[str] = None
|
|
273
|
+
) -> subprocess.CompletedProcess[str]:
|
|
274
|
+
try:
|
|
275
|
+
return self._runner(
|
|
276
|
+
command, timeout=int(timeout) if timeout else None, env=self._env, input_data=input_data
|
|
277
|
+
)
|
|
278
|
+
except FileNotFoundError as exc:
|
|
279
|
+
raise ProviderUnavailableError(
|
|
280
|
+
f"Claude CLI '{self._binary}' is not available on PATH.",
|
|
281
|
+
provider=self.metadata.provider_id,
|
|
282
|
+
) from exc
|
|
283
|
+
except subprocess.TimeoutExpired as exc:
|
|
284
|
+
raise ProviderTimeoutError(
|
|
285
|
+
f"Command timed out after {exc.timeout} seconds",
|
|
286
|
+
provider=self.metadata.provider_id,
|
|
287
|
+
) from exc
|
|
288
|
+
|
|
289
|
+
def _parse_output(self, raw: str) -> Dict[str, Any]:
|
|
290
|
+
text = raw.strip()
|
|
291
|
+
if not text:
|
|
292
|
+
raise ProviderExecutionError(
|
|
293
|
+
"Claude CLI returned empty output.",
|
|
294
|
+
provider=self.metadata.provider_id,
|
|
295
|
+
)
|
|
296
|
+
try:
|
|
297
|
+
return json.loads(text)
|
|
298
|
+
except json.JSONDecodeError as exc:
|
|
299
|
+
logger.debug(f"Claude CLI JSON parse error: {exc}")
|
|
300
|
+
raise ProviderExecutionError(
|
|
301
|
+
"Claude CLI returned invalid JSON response",
|
|
302
|
+
provider=self.metadata.provider_id,
|
|
303
|
+
) from exc
|
|
304
|
+
|
|
305
|
+
def _extract_usage(self, payload: Dict[str, Any]) -> TokenUsage:
|
|
306
|
+
"""
|
|
307
|
+
Extract token usage from Claude CLI JSON response.
|
|
308
|
+
|
|
309
|
+
Expected structure:
|
|
310
|
+
{
|
|
311
|
+
"usage": {"input_tokens": 10, "output_tokens": 50, ...},
|
|
312
|
+
"modelUsage": {"claude-sonnet-4-5-20250929": {...}},
|
|
313
|
+
...
|
|
314
|
+
}
|
|
315
|
+
"""
|
|
316
|
+
usage = payload.get("usage") or {}
|
|
317
|
+
return TokenUsage(
|
|
318
|
+
input_tokens=int(usage.get("input_tokens") or 0),
|
|
319
|
+
output_tokens=int(usage.get("output_tokens") or 0),
|
|
320
|
+
cached_input_tokens=int(usage.get("cached_input_tokens") or 0),
|
|
321
|
+
total_tokens=int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _resolve_model(self, request: ProviderRequest) -> str:
|
|
325
|
+
# 1. Check request.model first (from ProviderRequest constructor)
|
|
326
|
+
if request.model:
|
|
327
|
+
return str(request.model)
|
|
328
|
+
# 2. Fallback to metadata override (legacy/alternative path)
|
|
329
|
+
model_override = request.metadata.get("model") if request.metadata else None
|
|
330
|
+
if model_override:
|
|
331
|
+
return str(model_override)
|
|
332
|
+
# 3. Fallback to instance default
|
|
333
|
+
return self._model
|
|
334
|
+
|
|
335
|
+
def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
|
|
336
|
+
if not stream or not content:
|
|
337
|
+
return
|
|
338
|
+
self._emit_stream_chunk(StreamChunk(content=content, index=0))
|
|
339
|
+
|
|
340
|
+
def _extract_error_from_json(self, stdout: str) -> Optional[str]:
|
|
341
|
+
"""
|
|
342
|
+
Extract error message from Claude CLI JSON output.
|
|
343
|
+
|
|
344
|
+
Claude CLI outputs errors as JSON with is_error: true and error in 'result' field.
|
|
345
|
+
Example: {"type":"result","is_error":true,"result":"API Error: 404 {...}"}
|
|
346
|
+
"""
|
|
347
|
+
if not stdout:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
payload = json.loads(stdout.strip())
|
|
352
|
+
except json.JSONDecodeError:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
# Check for error indicator
|
|
356
|
+
if payload.get("is_error"):
|
|
357
|
+
result = payload.get("result", "")
|
|
358
|
+
if result:
|
|
359
|
+
return str(result)
|
|
360
|
+
|
|
361
|
+
# Also check for explicit error field
|
|
362
|
+
error = payload.get("error")
|
|
363
|
+
if error:
|
|
364
|
+
if isinstance(error, dict):
|
|
365
|
+
return error.get("message") or str(error)
|
|
366
|
+
return str(error)
|
|
367
|
+
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
371
|
+
self._validate_request(request)
|
|
372
|
+
model = self._resolve_model(request)
|
|
373
|
+
command = self._build_command(model, system_prompt=request.system_prompt)
|
|
374
|
+
timeout = request.timeout or self._timeout
|
|
375
|
+
# Pass prompt via stdin to avoid CLI argument length limits
|
|
376
|
+
completed = self._run(command, timeout=timeout, input_data=request.prompt)
|
|
377
|
+
|
|
378
|
+
if completed.returncode != 0:
|
|
379
|
+
stderr = (completed.stderr or "").strip()
|
|
380
|
+
logger.debug(f"Claude CLI stderr: {stderr or 'no stderr'}")
|
|
381
|
+
|
|
382
|
+
# Extract error from JSON stdout (Claude outputs errors there with is_error: true)
|
|
383
|
+
json_error = self._extract_error_from_json(completed.stdout)
|
|
384
|
+
|
|
385
|
+
error_msg = f"Claude CLI exited with code {completed.returncode}"
|
|
386
|
+
if json_error:
|
|
387
|
+
error_msg += f": {json_error[:500]}"
|
|
388
|
+
elif stderr:
|
|
389
|
+
error_msg += f": {stderr[:500]}"
|
|
390
|
+
raise ProviderExecutionError(
|
|
391
|
+
error_msg,
|
|
392
|
+
provider=self.metadata.provider_id,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
payload = self._parse_output(completed.stdout)
|
|
396
|
+
|
|
397
|
+
# Extract content from "result" field (as per claude-model-chorus pattern)
|
|
398
|
+
content = str(payload.get("result") or payload.get("content") or "").strip()
|
|
399
|
+
|
|
400
|
+
# Extract model from modelUsage if available
|
|
401
|
+
model_usage = payload.get("modelUsage") or {}
|
|
402
|
+
reported_model = list(model_usage.keys())[0] if model_usage else model
|
|
403
|
+
|
|
404
|
+
usage = self._extract_usage(payload)
|
|
405
|
+
|
|
406
|
+
self._emit_stream_if_requested(content, stream=request.stream)
|
|
407
|
+
|
|
408
|
+
return ProviderResult(
|
|
409
|
+
content=content,
|
|
410
|
+
provider_id=self.metadata.provider_id,
|
|
411
|
+
model_used=f"{self.metadata.provider_id}:{reported_model}",
|
|
412
|
+
status=ProviderStatus.SUCCESS,
|
|
413
|
+
tokens=usage,
|
|
414
|
+
stderr=(completed.stderr or "").strip() or None,
|
|
415
|
+
raw_payload=payload,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def is_claude_available() -> bool:
|
|
420
|
+
"""Claude CLI availability check."""
|
|
421
|
+
return detect_provider_availability("claude")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def create_provider(
|
|
425
|
+
*,
|
|
426
|
+
hooks: ProviderHooks,
|
|
427
|
+
model: Optional[str] = None,
|
|
428
|
+
dependencies: Optional[Dict[str, object]] = None,
|
|
429
|
+
overrides: Optional[Dict[str, object]] = None,
|
|
430
|
+
) -> ClaudeProvider:
|
|
431
|
+
"""
|
|
432
|
+
Factory used by the provider registry.
|
|
433
|
+
|
|
434
|
+
dependencies/overrides allow callers (or tests) to inject runner/env/binary.
|
|
435
|
+
"""
|
|
436
|
+
dependencies = dependencies or {}
|
|
437
|
+
overrides = overrides or {}
|
|
438
|
+
runner = dependencies.get("runner")
|
|
439
|
+
env = dependencies.get("env")
|
|
440
|
+
binary = overrides.get("binary") or dependencies.get("binary")
|
|
441
|
+
timeout = overrides.get("timeout")
|
|
442
|
+
selected_model = overrides.get("model") if overrides.get("model") else model
|
|
443
|
+
|
|
444
|
+
return ClaudeProvider(
|
|
445
|
+
metadata=CLAUDE_METADATA,
|
|
446
|
+
hooks=hooks,
|
|
447
|
+
model=selected_model, # type: ignore[arg-type]
|
|
448
|
+
binary=binary, # type: ignore[arg-type]
|
|
449
|
+
runner=runner if runner is not None else None, # type: ignore[arg-type]
|
|
450
|
+
env=env if env is not None else None, # type: ignore[arg-type]
|
|
451
|
+
timeout=timeout if timeout is not None else None, # type: ignore[arg-type]
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# Register the provider immediately so consumers can resolve it by id.
|
|
456
|
+
register_provider(
|
|
457
|
+
"claude",
|
|
458
|
+
factory=create_provider,
|
|
459
|
+
metadata=CLAUDE_METADATA,
|
|
460
|
+
availability_check=is_claude_available,
|
|
461
|
+
description="Anthropic Claude CLI adapter with read-only tool restrictions",
|
|
462
|
+
tags=("cli", "text", "vision", "thinking", "read-only"),
|
|
463
|
+
replace=True,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
__all__ = [
|
|
468
|
+
"ClaudeProvider",
|
|
469
|
+
"create_provider",
|
|
470
|
+
"is_claude_available",
|
|
471
|
+
"CLAUDE_METADATA",
|
|
472
|
+
]
|