cc-plugin-codex 0.1.4__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.
- cc_plugin_codex/__init__.py +5 -0
- cc_plugin_codex/claude.py +284 -0
- cc_plugin_codex/cli_contract.py +122 -0
- cc_plugin_codex/config.py +172 -0
- cc_plugin_codex/context.py +210 -0
- cc_plugin_codex/jobs.py +561 -0
- cc_plugin_codex/normalize.py +243 -0
- cc_plugin_codex/preflight.py +94 -0
- cc_plugin_codex/py.typed +0 -0
- cc_plugin_codex/schemas.py +344 -0
- cc_plugin_codex/server.py +1656 -0
- cc_plugin_codex-0.1.4.dist-info/METADATA +223 -0
- cc_plugin_codex-0.1.4.dist-info/RECORD +16 -0
- cc_plugin_codex-0.1.4.dist-info/WHEEL +4 -0
- cc_plugin_codex-0.1.4.dist-info/entry_points.txt +2 -0
- cc_plugin_codex-0.1.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Build per-tool prompts and normalize claude's JSON envelope into the contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from cc_plugin_codex import cli_contract
|
|
9
|
+
from cc_plugin_codex.claude import contract_changed_error
|
|
10
|
+
from cc_plugin_codex.schemas import (
|
|
11
|
+
Confidence,
|
|
12
|
+
ContextSummary,
|
|
13
|
+
ErrorInfo,
|
|
14
|
+
ErrorResult,
|
|
15
|
+
Finding,
|
|
16
|
+
Meta,
|
|
17
|
+
RawResponse,
|
|
18
|
+
Severity,
|
|
19
|
+
SuccessResult,
|
|
20
|
+
Usage,
|
|
21
|
+
Verdict,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_SCHEMA_INSTRUCTION = (
|
|
25
|
+
"Respond with ONLY a single JSON object (no prose, no code fence) with keys: "
|
|
26
|
+
'"summary" (string), "verdict" (one of pass|concerns|fail|unknown), '
|
|
27
|
+
'"confidence" (one of low|medium|high), "findings" (array of objects with '
|
|
28
|
+
"severity[critical|high|medium|low|nit], title, file, line, line_end (optional "
|
|
29
|
+
"end line for multi-line findings), evidence, risk, recommendation), "
|
|
30
|
+
'"questions" (array of strings), "assumptions" (array of strings), '
|
|
31
|
+
'"next_steps" (array of strings: concrete actions to take next).'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_LEAD = {
|
|
35
|
+
"claude_ask": "Give an independent second opinion on the following question.",
|
|
36
|
+
"claude_review_changes": "Review the following code changes for correctness, "
|
|
37
|
+
"regressions, security, and missing tests.",
|
|
38
|
+
"claude_adversarial_review": "Attack the following plan/claim. Find the strongest "
|
|
39
|
+
"counterarguments, failure modes, and risks.",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_VALID_VERDICT = {"pass", "concerns", "fail", "unknown"}
|
|
43
|
+
_VALID_CONFIDENCE = {"low", "medium", "high"}
|
|
44
|
+
_VALID_SEVERITY = {"critical", "high", "medium", "low", "nit"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _str_list(value: Any) -> list[str]:
|
|
48
|
+
return [str(x) for x in value if x] if isinstance(value, list) else []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_prompt(tool: str, payload: dict[str, Any], context_text: str) -> str:
|
|
52
|
+
parts = [_LEAD.get(tool, _LEAD["claude_ask"])]
|
|
53
|
+
if tool == "claude_ask":
|
|
54
|
+
parts.append(payload["prompt"])
|
|
55
|
+
if payload.get("context"):
|
|
56
|
+
parts.append(f"\nAdditional context:\n{payload['context']}")
|
|
57
|
+
elif tool == "claude_review_changes":
|
|
58
|
+
if payload.get("focus"):
|
|
59
|
+
parts.append(f"Focus especially on: {payload['focus']}.")
|
|
60
|
+
parts.append(f"\nChanges (scope={payload.get('scope')}):\n{context_text}")
|
|
61
|
+
elif tool == "claude_adversarial_review":
|
|
62
|
+
parts.append(f"\nTarget:\n{payload['target']}")
|
|
63
|
+
if payload.get("evidence"):
|
|
64
|
+
parts.append(f"\nEvidence:\n{payload['evidence']}")
|
|
65
|
+
if context_text:
|
|
66
|
+
parts.append(f"\nRelated changes:\n{context_text}")
|
|
67
|
+
parts.append("\n" + _SCHEMA_INSTRUCTION)
|
|
68
|
+
return "\n".join(parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def extract_json(text: str) -> dict | None:
|
|
72
|
+
decoder = json.JSONDecoder()
|
|
73
|
+
|
|
74
|
+
def scan(candidate: str) -> dict | None:
|
|
75
|
+
for idx, char in enumerate(candidate):
|
|
76
|
+
if char != "{":
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
parsed, _ = decoder.raw_decode(candidate[idx:])
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
continue
|
|
82
|
+
if isinstance(parsed, dict):
|
|
83
|
+
return parsed
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
fence_start = 0
|
|
87
|
+
while True:
|
|
88
|
+
start = text.find("```", fence_start)
|
|
89
|
+
if start < 0:
|
|
90
|
+
break
|
|
91
|
+
body_start = text.find("\n", start + 3)
|
|
92
|
+
if body_start < 0:
|
|
93
|
+
break
|
|
94
|
+
end = text.find("```", body_start + 1)
|
|
95
|
+
if end < 0:
|
|
96
|
+
break
|
|
97
|
+
parsed = scan(text[body_start + 1 : end])
|
|
98
|
+
if parsed is not None:
|
|
99
|
+
return parsed
|
|
100
|
+
fence_start = end + 3
|
|
101
|
+
|
|
102
|
+
return scan(text)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _clamp(value: Any, allowed: set[str], default: str) -> str:
|
|
106
|
+
return value if value in allowed else default
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _clean_findings(raw: Any) -> list[Finding]:
|
|
110
|
+
findings: list[Finding] = []
|
|
111
|
+
if not isinstance(raw, list):
|
|
112
|
+
return findings
|
|
113
|
+
for f in raw:
|
|
114
|
+
if not isinstance(f, dict):
|
|
115
|
+
continue
|
|
116
|
+
if not all(f.get(k) for k in ("title", "evidence", "risk", "recommendation")):
|
|
117
|
+
continue # drop incomplete findings rather than fabricate fields
|
|
118
|
+
line = f.get("line")
|
|
119
|
+
line_end = f.get("line_end")
|
|
120
|
+
findings.append(
|
|
121
|
+
Finding(
|
|
122
|
+
severity=cast("Severity", _clamp(f.get("severity"), _VALID_SEVERITY, "low")),
|
|
123
|
+
title=str(f["title"]),
|
|
124
|
+
file=str(f["file"]) if f.get("file") else None,
|
|
125
|
+
line=line if isinstance(line, int) else None,
|
|
126
|
+
line_end=line_end if isinstance(line_end, int) else None,
|
|
127
|
+
evidence=str(f["evidence"]),
|
|
128
|
+
risk=str(f["risk"]),
|
|
129
|
+
recommendation=str(f["recommendation"]),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return findings
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _error(info: ErrorInfo, meta: Meta) -> dict:
|
|
136
|
+
return ErrorResult(error=info, meta=meta).model_dump(mode="json", exclude_none=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply_cost_usage(meta: Meta, env: dict) -> None:
|
|
140
|
+
"""Plumb total_cost_usd / usage from a claude JSON envelope onto meta.
|
|
141
|
+
|
|
142
|
+
Used on both the success path and the non-zero-exit error path, so a failed
|
|
143
|
+
paid call (e.g. budget_exceeded) still reports what it spent when available."""
|
|
144
|
+
cost = env.get("total_cost_usd")
|
|
145
|
+
if isinstance(cost, (int, float)):
|
|
146
|
+
meta.cost_usd = float(cost)
|
|
147
|
+
raw_usage = env.get("usage")
|
|
148
|
+
if isinstance(raw_usage, dict):
|
|
149
|
+
meta.usage = Usage(
|
|
150
|
+
input_tokens=raw_usage.get("input_tokens"),
|
|
151
|
+
output_tokens=raw_usage.get("output_tokens"),
|
|
152
|
+
cache_read_input_tokens=raw_usage.get("cache_read_input_tokens"),
|
|
153
|
+
cache_creation_input_tokens=raw_usage.get("cache_creation_input_tokens"),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def normalize_envelope(
|
|
158
|
+
tool: str,
|
|
159
|
+
stdout: str,
|
|
160
|
+
meta: Meta,
|
|
161
|
+
detail: str,
|
|
162
|
+
context_summary: ContextSummary | None = None,
|
|
163
|
+
) -> dict:
|
|
164
|
+
try:
|
|
165
|
+
env = json.loads(stdout)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
return _error(
|
|
168
|
+
ErrorInfo(
|
|
169
|
+
code="invalid_json",
|
|
170
|
+
message="claude did not return valid JSON.",
|
|
171
|
+
repair="Retry; if it persists, reduce context size.",
|
|
172
|
+
),
|
|
173
|
+
meta,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Plumb cost and usage onto meta regardless of success/error path.
|
|
177
|
+
apply_cost_usage(meta, env)
|
|
178
|
+
|
|
179
|
+
if env.get("is_error") or env.get("subtype") not in cli_contract.SUCCESS_SUBTYPES:
|
|
180
|
+
detail = (env.get("result") or "").strip() or (env.get("subtype") or "unknown error")
|
|
181
|
+
# A drift signature can arrive as a zero-exit is_error envelope (not just a
|
|
182
|
+
# nonzero exit), so classify it the same way here.
|
|
183
|
+
if cli_contract.is_contract_drift(env.get("result"), env.get("subtype")):
|
|
184
|
+
return _error(contract_changed_error(), meta)
|
|
185
|
+
return _error(
|
|
186
|
+
ErrorInfo(
|
|
187
|
+
code="nonzero_exit",
|
|
188
|
+
message=f"claude reported an error: {detail[:200]}",
|
|
189
|
+
repair="Inspect the error; retry with a smaller or corrected request.",
|
|
190
|
+
),
|
|
191
|
+
meta,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
text = env.get("result", "") or ""
|
|
195
|
+
raw = RawResponse(
|
|
196
|
+
text=text if detail == "full" else None,
|
|
197
|
+
session_id=env.get("session_id"),
|
|
198
|
+
model=next(iter(env.get("modelUsage") or {}), None),
|
|
199
|
+
)
|
|
200
|
+
inner = extract_json(text)
|
|
201
|
+
|
|
202
|
+
# If Claude was blocked by denied tools AND produced nothing usable, surface it.
|
|
203
|
+
denials = env.get("permission_denials") or []
|
|
204
|
+
if denials and (inner is None and not text.strip()):
|
|
205
|
+
return _error(
|
|
206
|
+
ErrorInfo(
|
|
207
|
+
code="claude_permission_error",
|
|
208
|
+
message=f"claude was denied required tools: {str(denials)[:160]}",
|
|
209
|
+
repair="Use access=toolless, or allow the needed read-only tools.",
|
|
210
|
+
),
|
|
211
|
+
meta,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if inner is None:
|
|
215
|
+
result = SuccessResult(
|
|
216
|
+
tool=tool,
|
|
217
|
+
summary=text.strip()[:500] or "(no content)",
|
|
218
|
+
verdict="unknown",
|
|
219
|
+
confidence="low",
|
|
220
|
+
raw_response=raw,
|
|
221
|
+
context_summary=context_summary if detail == "full" else None,
|
|
222
|
+
meta=meta,
|
|
223
|
+
)
|
|
224
|
+
if denials:
|
|
225
|
+
result.meta.permission_denials = denials
|
|
226
|
+
return result.model_dump(mode="json", exclude_none=True)
|
|
227
|
+
|
|
228
|
+
result = SuccessResult(
|
|
229
|
+
tool=tool,
|
|
230
|
+
summary=str(inner.get("summary", "")),
|
|
231
|
+
verdict=cast("Verdict", _clamp(inner.get("verdict"), _VALID_VERDICT, "unknown")),
|
|
232
|
+
confidence=cast("Confidence", _clamp(inner.get("confidence"), _VALID_CONFIDENCE, "low")),
|
|
233
|
+
findings=_clean_findings(inner.get("findings", [])),
|
|
234
|
+
questions=_str_list(inner.get("questions")),
|
|
235
|
+
assumptions=_str_list(inner.get("assumptions")),
|
|
236
|
+
next_steps=_str_list(inner.get("next_steps")),
|
|
237
|
+
raw_response=raw,
|
|
238
|
+
context_summary=context_summary if detail == "full" else None,
|
|
239
|
+
meta=meta,
|
|
240
|
+
)
|
|
241
|
+
if denials:
|
|
242
|
+
result.meta.permission_denials = denials
|
|
243
|
+
return result.model_dump(mode="json", exclude_none=True)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Feature-detect which `claude` flags exist, by parsing `claude --help` once.
|
|
2
|
+
|
|
3
|
+
Only the HELP_GATED flags (depth/cosmetic) are gated on this probe: dropping one
|
|
4
|
+
when absent keeps the server working across a minor upstream change. The
|
|
5
|
+
guarantee-bearing ALWAYS_SEND flags are never gated here — their removal is caught
|
|
6
|
+
loudly at run time (cli_contract_changed), not silently pre-empted, because
|
|
7
|
+
`--help` parsing is fuzzy and a false negative must never drop a safety/cost flag.
|
|
8
|
+
|
|
9
|
+
Everything degrades, nothing crashes: any probe failure yields help_parsed=False,
|
|
10
|
+
which makes is_supported() return True for every flag (fail open == today's
|
|
11
|
+
behavior)."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from cc_plugin_codex import cli_contract
|
|
21
|
+
|
|
22
|
+
_LONG_FLAG_RE = re.compile(r"--[a-z][a-z0-9-]+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class FlagSupport:
|
|
27
|
+
supported: frozenset[str]
|
|
28
|
+
help_parsed: bool # False => probe failed; callers must fail open
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Process-level cache: (monotonic_timestamp, FlagSupport). A long-lived MCP server
|
|
32
|
+
# re-probes after HELP_CACHE_TTL_SECONDS so an in-place `claude` upgrade is noticed.
|
|
33
|
+
_cache: tuple[float, FlagSupport] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _probe_help() -> str:
|
|
37
|
+
"""Return the combined `claude --help` text, or "" on any failure. Never raises."""
|
|
38
|
+
try:
|
|
39
|
+
proc = subprocess.run(
|
|
40
|
+
[cli_contract.CLAUDE_BIN, *cli_contract.HELP_ARGS],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
timeout=10,
|
|
44
|
+
check=False,
|
|
45
|
+
)
|
|
46
|
+
except (OSError, subprocess.SubprocessError):
|
|
47
|
+
return ""
|
|
48
|
+
return f"{proc.stdout}\n{proc.stderr}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_supported(help_text: str) -> frozenset[str]:
|
|
52
|
+
"""Extract long-flag names from help text. Deliberately tolerant: this only
|
|
53
|
+
governs HELP_GATED flags, where a stray/missing match drops a harmless flag."""
|
|
54
|
+
return frozenset(_LONG_FLAG_RE.findall(help_text))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def flag_support(force: bool = False) -> FlagSupport:
|
|
58
|
+
"""Cached FlagSupport for the installed `claude`. force=True bypasses the cache
|
|
59
|
+
(used by tests / diagnostics)."""
|
|
60
|
+
global _cache # noqa: PLW0603 — intentional process-level memoization of the help probe
|
|
61
|
+
now = time.monotonic()
|
|
62
|
+
if not force and _cache is not None:
|
|
63
|
+
stamped, value = _cache
|
|
64
|
+
if now - stamped < cli_contract.HELP_CACHE_TTL_SECONDS:
|
|
65
|
+
return value
|
|
66
|
+
help_text = _probe_help()
|
|
67
|
+
if not help_text.strip():
|
|
68
|
+
value = FlagSupport(supported=frozenset(), help_parsed=False)
|
|
69
|
+
else:
|
|
70
|
+
value = FlagSupport(supported=_parse_supported(help_text), help_parsed=True)
|
|
71
|
+
_cache = (now, value)
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def reset_cache() -> None:
|
|
76
|
+
"""Drop the cached probe (used by tests)."""
|
|
77
|
+
global _cache # noqa: PLW0603 — resets the intentional module-level cache
|
|
78
|
+
_cache = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_supported(flag: str, fs: FlagSupport) -> bool:
|
|
82
|
+
"""Whether `flag` may be sent. Fails OPEN: when the probe could not run
|
|
83
|
+
(help_parsed=False) every flag is treated as supported, preserving today's
|
|
84
|
+
behavior."""
|
|
85
|
+
return (not fs.help_parsed) or (flag in fs.supported)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def missing_expected_flags(fs: FlagSupport) -> list[str]:
|
|
89
|
+
"""Guarantee-bearing ALWAYS_SEND flags that `--help` did not list. Empty when
|
|
90
|
+
the probe could not run (so we never warn on a failed probe). Diagnostic only —
|
|
91
|
+
surfaced by claude_status, it does NOT gate execution."""
|
|
92
|
+
if not fs.help_parsed:
|
|
93
|
+
return []
|
|
94
|
+
return sorted(f for f in cli_contract.ALWAYS_SEND_FLAGS if f not in fs.supported)
|
cc_plugin_codex/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Pydantic models for the normalized tool result contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
9
|
+
|
|
10
|
+
# Bump this whenever the agent-visible surface changes: tool names, input or
|
|
11
|
+
# output schemas, the ErrorCode set, the config_mode/access/scope/detail value
|
|
12
|
+
# sets, or the capability guarantees in CAPABILITY_SUMMARY. Clients cache by it.
|
|
13
|
+
FINGERPRINT = "cc-plugin-codex/0.1/schema-12"
|
|
14
|
+
|
|
15
|
+
Severity = Literal["critical", "high", "medium", "low", "nit"]
|
|
16
|
+
Verdict = Literal["pass", "concerns", "fail", "unknown"]
|
|
17
|
+
Confidence = Literal["low", "medium", "high"]
|
|
18
|
+
ConfigMode = Literal["inherit", "scoped", "bare"]
|
|
19
|
+
Access = Literal["toolless", "readonly"]
|
|
20
|
+
Scope = Literal["working_tree", "staged", "branch"]
|
|
21
|
+
Detail = Literal["summary", "full"]
|
|
22
|
+
Effort = Literal["low", "medium", "high", "xhigh", "max"]
|
|
23
|
+
# Lifecycle states for a background job. Terminal: done|failed|cancelled|timeout.
|
|
24
|
+
# (TTL-expired records are deleted and reported as job_not_found, not a state.)
|
|
25
|
+
JobState = Literal["running", "done", "failed", "cancelled", "timeout"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def workspace_warning_for(source: str | None, cwd: str) -> str | None:
|
|
29
|
+
"""Warning when the workspace was resolved from the server's own cwd.
|
|
30
|
+
|
|
31
|
+
The MCP server process launches from its install directory, so a cwd-resolved
|
|
32
|
+
workspace silently reviews the wrong repo. Surfacing this (rather than failing)
|
|
33
|
+
lets agents notice and pass workspace_root without breaking existing callers.
|
|
34
|
+
Shared by the sync meta builder and the background-job meta rebuild so the two
|
|
35
|
+
paths cannot drift."""
|
|
36
|
+
if source == "cwd":
|
|
37
|
+
return (
|
|
38
|
+
f"workspace resolved from the server's own cwd ({cwd}); pass "
|
|
39
|
+
"workspace_root (or configure an MCP root) to be sure the review "
|
|
40
|
+
"targets the intended repository"
|
|
41
|
+
)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ErrorCode = Literal[
|
|
46
|
+
"claude_not_found",
|
|
47
|
+
"claude_auth_required",
|
|
48
|
+
"api_key_missing",
|
|
49
|
+
"api_key_invalid",
|
|
50
|
+
"unsupported_config_mode",
|
|
51
|
+
"unsupported_access",
|
|
52
|
+
"invalid_scope",
|
|
53
|
+
"invalid_base",
|
|
54
|
+
"invalid_workspace_root",
|
|
55
|
+
"workspace_outside_roots",
|
|
56
|
+
"context_too_large",
|
|
57
|
+
"timeout",
|
|
58
|
+
"budget_exceeded",
|
|
59
|
+
"claude_permission_error",
|
|
60
|
+
"nonzero_exit",
|
|
61
|
+
"invalid_json",
|
|
62
|
+
"internal_error",
|
|
63
|
+
# The installed `claude` rejected a flag/value this plugin sends — its CLI
|
|
64
|
+
# contract drifted and the plugin likely needs an update.
|
|
65
|
+
"cli_contract_changed",
|
|
66
|
+
# Background-job lifecycle errors (claude_job_result for a non-done job):
|
|
67
|
+
"job_not_found",
|
|
68
|
+
"job_running",
|
|
69
|
+
"job_cancelled",
|
|
70
|
+
"job_timeout",
|
|
71
|
+
"job_failed",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Usage(BaseModel):
|
|
76
|
+
model_config = ConfigDict(extra="forbid")
|
|
77
|
+
input_tokens: int | None = None
|
|
78
|
+
output_tokens: int | None = None
|
|
79
|
+
cache_read_input_tokens: int | None = None
|
|
80
|
+
cache_creation_input_tokens: int | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Finding(BaseModel):
|
|
84
|
+
model_config = ConfigDict(extra="forbid")
|
|
85
|
+
severity: Severity
|
|
86
|
+
title: str
|
|
87
|
+
file: str | None = None
|
|
88
|
+
line: int | None = None
|
|
89
|
+
line_end: int | None = None # end line when the finding spans a range (line = start)
|
|
90
|
+
evidence: str
|
|
91
|
+
risk: str
|
|
92
|
+
recommendation: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RawResponse(BaseModel):
|
|
96
|
+
model_config = ConfigDict(extra="forbid")
|
|
97
|
+
text: str | None = None
|
|
98
|
+
session_id: str | None = None
|
|
99
|
+
model: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ContextSummary(BaseModel):
|
|
103
|
+
model_config = ConfigDict(extra="forbid")
|
|
104
|
+
files_changed: int = 0
|
|
105
|
+
lines_added: int = 0
|
|
106
|
+
lines_removed: int = 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Meta(BaseModel):
|
|
110
|
+
model_config = ConfigDict(extra="forbid")
|
|
111
|
+
cwd: str
|
|
112
|
+
workspace_source: str | None = None # how cwd was resolved: param|roots|cwd
|
|
113
|
+
workspace_warning: str | None = None # set when cwd was resolved from server cwd
|
|
114
|
+
config_mode: ConfigMode
|
|
115
|
+
access: Access
|
|
116
|
+
scope: str | None = None
|
|
117
|
+
base: str | None = None
|
|
118
|
+
timeout_seconds: int
|
|
119
|
+
elapsed_ms: int
|
|
120
|
+
# The effective (env-defaulted + clamped) value passed to claude as
|
|
121
|
+
# --max-budget-usd. It is a best-effort stop threshold, not a hard cap; compare
|
|
122
|
+
# against cost_usd to see how close actual spend came.
|
|
123
|
+
requested_max_budget_usd: float | None = None
|
|
124
|
+
truncated: bool = False
|
|
125
|
+
truncation_hint: str | None = None
|
|
126
|
+
command_exit_code: int | None = None
|
|
127
|
+
permission_denials: list | None = None
|
|
128
|
+
# Optional `claude` flags this server dropped because the installed CLI did not
|
|
129
|
+
# advertise them in --help (e.g. ["--effort"]). Empty in the common case;
|
|
130
|
+
# informational — guarantee-bearing flags are never dropped, only depth/cosmetic ones.
|
|
131
|
+
compat_warnings: list[str] = Field(default_factory=list)
|
|
132
|
+
redacted_paths: list[str] = Field(default_factory=list)
|
|
133
|
+
cost_usd: float | None = None
|
|
134
|
+
usage: Usage | None = None
|
|
135
|
+
job_id: str | None = None # set on background-job results; None for sync calls
|
|
136
|
+
request_id: str = Field(default_factory=lambda: uuid4().hex)
|
|
137
|
+
fingerprint: str = FINGERPRINT
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class SuccessResult(BaseModel):
|
|
141
|
+
model_config = ConfigDict(extra="forbid")
|
|
142
|
+
ok: Literal[True] = True
|
|
143
|
+
tool: str
|
|
144
|
+
summary: str
|
|
145
|
+
verdict: Verdict
|
|
146
|
+
confidence: Confidence
|
|
147
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
148
|
+
questions: list[str] = Field(default_factory=list)
|
|
149
|
+
assumptions: list[str] = Field(default_factory=list)
|
|
150
|
+
next_steps: list[str] = Field(default_factory=list)
|
|
151
|
+
raw_response: RawResponse = Field(default_factory=RawResponse)
|
|
152
|
+
context_summary: ContextSummary | None = None
|
|
153
|
+
meta: Meta
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ErrorInfo(BaseModel):
|
|
157
|
+
model_config = ConfigDict(extra="forbid")
|
|
158
|
+
code: ErrorCode
|
|
159
|
+
message: str
|
|
160
|
+
repair: str
|
|
161
|
+
offending_param: str | None = None
|
|
162
|
+
retryable: bool = False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ErrorResult(BaseModel):
|
|
166
|
+
model_config = ConfigDict(extra="forbid")
|
|
167
|
+
ok: Literal[False] = False
|
|
168
|
+
error: ErrorInfo
|
|
169
|
+
meta: Meta
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ResolvedDefaults(BaseModel):
|
|
173
|
+
model_config = ConfigDict(extra="forbid")
|
|
174
|
+
config_mode: ConfigMode
|
|
175
|
+
access: Access
|
|
176
|
+
model: str | None = None
|
|
177
|
+
effort: Effort
|
|
178
|
+
max_budget_usd: float
|
|
179
|
+
timeout_seconds: int
|
|
180
|
+
budget_bounds: list[float] # [min, max] clamp range for max_budget_usd
|
|
181
|
+
timeout_bounds: list[int] # [min, max] clamp range for timeout_seconds
|
|
182
|
+
practical_min_budget_hint: str
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class StatusResult(BaseModel):
|
|
186
|
+
model_config = ConfigDict(extra="forbid")
|
|
187
|
+
ok: Literal[True] = True
|
|
188
|
+
claude_found: bool
|
|
189
|
+
claude_version: str | None = None
|
|
190
|
+
# Readiness probes (all free — no paid Claude call):
|
|
191
|
+
claude_authenticated: bool | None = None # None = could not determine
|
|
192
|
+
auth_detail: str | None = None
|
|
193
|
+
version_supported: bool | None = None # major is in supported_majors()
|
|
194
|
+
# Set when version_supported is False: a major outside the tested range is
|
|
195
|
+
# advisory, not fatal — tools may still work, so we warn instead of blocking.
|
|
196
|
+
version_warning: str | None = None
|
|
197
|
+
# Set when `claude --help` did not list a guarantee-bearing flag this plugin
|
|
198
|
+
# sends — an early, free signal that the CLI contract drifted.
|
|
199
|
+
flags_warning: str | None = None
|
|
200
|
+
ready: bool = False # found AND authenticated (version is advisory, not gating)
|
|
201
|
+
config_modes_available: dict
|
|
202
|
+
resolved_defaults: ResolvedDefaults
|
|
203
|
+
caveat: str
|
|
204
|
+
fingerprint: str = FINGERPRINT
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ToolCapability(BaseModel):
|
|
208
|
+
model_config = ConfigDict(extra="forbid")
|
|
209
|
+
name: str
|
|
210
|
+
cost: Literal["free", "paid"]
|
|
211
|
+
use_when: str
|
|
212
|
+
required_params: list[str] = Field(default_factory=list)
|
|
213
|
+
key_optional_params: list[str] = Field(default_factory=list)
|
|
214
|
+
returns: str
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class CapabilitiesResult(BaseModel):
|
|
218
|
+
model_config = ConfigDict(extra="forbid")
|
|
219
|
+
ok: Literal[True] = True
|
|
220
|
+
name: str
|
|
221
|
+
version: str
|
|
222
|
+
fingerprint: str = FINGERPRINT
|
|
223
|
+
transport: str
|
|
224
|
+
stability: str
|
|
225
|
+
paid_tools: list[str]
|
|
226
|
+
free_tools: list[str]
|
|
227
|
+
tool_details: list[ToolCapability] = Field(default_factory=list)
|
|
228
|
+
config_modes: list[str]
|
|
229
|
+
access_modes: list[str]
|
|
230
|
+
scope: list[str] # what this server is for
|
|
231
|
+
negative_scope: list[str] # what it deliberately does NOT do
|
|
232
|
+
prerequisites: list[str]
|
|
233
|
+
deprecation_policy: str
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class JobStarted(BaseModel):
|
|
237
|
+
"""Returned by the *_async tools: a handle to poll, not a result."""
|
|
238
|
+
|
|
239
|
+
model_config = ConfigDict(extra="forbid")
|
|
240
|
+
ok: Literal[True] = True
|
|
241
|
+
job_id: str
|
|
242
|
+
kind: str # the tool the job runs, e.g. claude_review_changes
|
|
243
|
+
status: JobState = "running"
|
|
244
|
+
started_at: str # ISO-8601 UTC
|
|
245
|
+
deadline_seconds: int # wall-clock cap after which a poll reaps the job
|
|
246
|
+
poll_after_ms: int = 1000
|
|
247
|
+
ttl_seconds: int
|
|
248
|
+
expires_at: str | None = None
|
|
249
|
+
meta: Meta
|
|
250
|
+
fingerprint: str = FINGERPRINT
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class JobStatus(BaseModel):
|
|
254
|
+
"""Returned by claude_job_status: lifecycle state without the full result."""
|
|
255
|
+
|
|
256
|
+
model_config = ConfigDict(extra="forbid")
|
|
257
|
+
ok: Literal[True] = True
|
|
258
|
+
job_id: str
|
|
259
|
+
kind: str
|
|
260
|
+
status: JobState
|
|
261
|
+
started_at: str
|
|
262
|
+
elapsed_ms: int
|
|
263
|
+
deadline_seconds: int
|
|
264
|
+
poll_after_ms: int = 1000
|
|
265
|
+
ttl_seconds: int
|
|
266
|
+
expires_at: str | None = None
|
|
267
|
+
result_available: bool = False # true once status == done
|
|
268
|
+
cost_usd: float | None = None # populated for terminal jobs that spent
|
|
269
|
+
detail: str | None = None # short human hint (e.g. failure reason)
|
|
270
|
+
fingerprint: str = FINGERPRINT
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class DryRunResult(BaseModel):
|
|
274
|
+
"""Free preview of what a diff review WOULD send — no Claude call, no spend."""
|
|
275
|
+
|
|
276
|
+
model_config = ConfigDict(extra="forbid")
|
|
277
|
+
ok: Literal[True] = True
|
|
278
|
+
tool: Literal["claude_review_dry_run"] = "claude_review_dry_run"
|
|
279
|
+
cwd: str
|
|
280
|
+
workspace_source: str | None = None
|
|
281
|
+
workspace_warning: str | None = None
|
|
282
|
+
scope: str
|
|
283
|
+
base: str | None = None
|
|
284
|
+
context_summary: ContextSummary
|
|
285
|
+
diff_bytes: int # full UTF-8 size of the redacted diff that would be sent
|
|
286
|
+
max_diff_bytes: int # the server's truncation threshold
|
|
287
|
+
truncated: bool = False # true when diff_bytes > max_diff_bytes
|
|
288
|
+
truncation_hint: str | None = None
|
|
289
|
+
redacted_paths_count: int = 0
|
|
290
|
+
redacted_paths: list[str] = Field(default_factory=list)
|
|
291
|
+
fingerprint: str = FINGERPRINT
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class JobSummary(BaseModel):
|
|
295
|
+
model_config = ConfigDict(extra="forbid")
|
|
296
|
+
job_id: str
|
|
297
|
+
kind: str
|
|
298
|
+
status: JobState
|
|
299
|
+
started_at: str
|
|
300
|
+
elapsed_ms: int
|
|
301
|
+
result_available: bool = False
|
|
302
|
+
expires_at: str | None = None
|
|
303
|
+
cost_usd: float | None = None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class JobListResult(BaseModel):
|
|
307
|
+
"""Returned by claude_job_list: the workspace's known jobs, newest first."""
|
|
308
|
+
|
|
309
|
+
model_config = ConfigDict(extra="forbid")
|
|
310
|
+
ok: Literal[True] = True
|
|
311
|
+
jobs: list[JobSummary] = Field(default_factory=list)
|
|
312
|
+
fingerprint: str = FINGERPRINT
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _object_union_schema(adapter: TypeAdapter) -> dict:
|
|
316
|
+
"""Wrap a model union's anyOf in a top-level object schema.
|
|
317
|
+
|
|
318
|
+
MCP/FastMCP require an output schema whose top level is ``type: object``;
|
|
319
|
+
a bare ``anyOf`` is rejected. We keep the discriminating ``ok`` key visible
|
|
320
|
+
at the top and carry the full branch schemas (and their $defs) underneath.
|
|
321
|
+
"""
|
|
322
|
+
union = adapter.json_schema()
|
|
323
|
+
return {
|
|
324
|
+
"type": "object",
|
|
325
|
+
"properties": {
|
|
326
|
+
"ok": {"type": "boolean", "description": "true = success result, false = error result"},
|
|
327
|
+
},
|
|
328
|
+
"required": ["ok"],
|
|
329
|
+
"anyOf": union["anyOf"],
|
|
330
|
+
"$defs": union.get("$defs", {}),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# Advertised output schemas (convention: a discriminated ok:true|false union).
|
|
335
|
+
RESULT_SCHEMA = _object_union_schema(TypeAdapter(SuccessResult | ErrorResult))
|
|
336
|
+
STATUS_SCHEMA = StatusResult.model_json_schema()
|
|
337
|
+
CAPABILITIES_SCHEMA = CapabilitiesResult.model_json_schema()
|
|
338
|
+
# A failed *_async launch returns the error envelope; an empty diff returns a
|
|
339
|
+
# SuccessResult without starting a job.
|
|
340
|
+
JOB_STARTED_SCHEMA = _object_union_schema(TypeAdapter(JobStarted | SuccessResult | ErrorResult))
|
|
341
|
+
JOB_STATUS_SCHEMA = _object_union_schema(TypeAdapter(JobStatus | ErrorResult))
|
|
342
|
+
# Dry-run and job-list can fail (bad scope/base/workspace), so advertise the union.
|
|
343
|
+
DRY_RUN_SCHEMA = _object_union_schema(TypeAdapter(DryRunResult | ErrorResult))
|
|
344
|
+
JOB_LIST_SCHEMA = _object_union_schema(TypeAdapter(JobListResult | ErrorResult))
|