clink-cli 0.2.1__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.
- clink/__init__.py +19 -0
- clink/agents/__init__.py +30 -0
- clink/agents/base.py +70 -0
- clink/agents/claude.py +7 -0
- clink/agents/codex.py +7 -0
- clink/agents/gemini.py +7 -0
- clink/allowed_roots.py +356 -0
- clink/backend.py +765 -0
- clink/cli.py +594 -0
- clink/constants.py +78 -0
- clink/contracts.py +167 -0
- clink/files.py +70 -0
- clink/generation.py +274 -0
- clink/generation_capabilities.py +207 -0
- clink/generation_contracts.py +214 -0
- clink/generation_policy.py +174 -0
- clink/models.py +243 -0
- clink/native.py +409 -0
- clink/native_adapters.py +244 -0
- clink/native_contracts.py +68 -0
- clink/native_generation.py +421 -0
- clink/native_generation_claude_policy.py +59 -0
- clink/native_generation_contracts.py +264 -0
- clink/native_generation_families.py +218 -0
- clink/native_generation_family_policy.py +14 -0
- clink/native_generation_files.py +153 -0
- clink/native_generation_policy.py +82 -0
- clink/native_policy.py +86 -0
- clink/parsers/__init__.py +34 -0
- clink/parsers/agy.py +53 -0
- clink/parsers/agy_native.py +161 -0
- clink/parsers/agy_native_generation.py +189 -0
- clink/parsers/base.py +209 -0
- clink/parsers/claude.py +104 -0
- clink/parsers/claude_native.py +113 -0
- clink/parsers/claude_native_generation.py +346 -0
- clink/parsers/codex.py +172 -0
- clink/parsers/codex_generation.py +212 -0
- clink/parsers/codex_native.py +236 -0
- clink/parsers/codex_native_generation.py +367 -0
- clink/parsers/gemini.py +110 -0
- clink/parsers/grok.py +63 -0
- clink/parsers/grok_native.py +91 -0
- clink/parsers/grok_native_generation.py +157 -0
- clink/policy.py +180 -0
- clink/registry.py +544 -0
- clink/resources/CODEX-LICENSE +201 -0
- clink/resources/CODEX-NOTICE +6 -0
- clink/resources/PROVENANCE.md +16 -0
- clink/resources/codex-0.166.0-models-direct.json +1284 -0
- clink/resources/codex-0.166.0-models-generation.json +1269 -0
- clink/resources/native-agy-1.4.12-admission.json +746 -0
- clink/resources/native-claude-2.1.304-admission.json +99 -0
- clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
- clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
- clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
- clink/resources/native-grok-1.0.70-admission.json +644 -0
- clink/runner.py +303 -0
- clink/setup.py +399 -0
- clink/tasks.py +695 -0
- clink_cli-0.2.1.dist-info/METADATA +225 -0
- clink_cli-0.2.1.dist-info/RECORD +141 -0
- clink_cli-0.2.1.dist-info/WHEEL +5 -0
- clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
- clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
- clink_cli-0.2.1.dist-info/top_level.txt +7 -0
- conf/__init__.py +1 -0
- conf/cli_clients/agy.json +40 -0
- conf/cli_clients/claude.json +39 -0
- conf/cli_clients/codex.json +40 -0
- conf/cli_clients/gemini.json +23 -0
- conf/cli_clients/grok.json +40 -0
- config.py +40 -0
- server.py +896 -0
- systemprompts/__init__.py +35 -0
- systemprompts/analyze_prompt.py +87 -0
- systemprompts/chat_prompt.py +57 -0
- systemprompts/clink/codex_codereviewer.txt +8 -0
- systemprompts/clink/default.txt +8 -0
- systemprompts/clink/default_codereviewer.txt +8 -0
- systemprompts/clink/default_planner.txt +7 -0
- systemprompts/clink/native_codereviewer.txt +1 -0
- systemprompts/clink/native_default.txt +1 -0
- systemprompts/clink/native_planner.txt +1 -0
- systemprompts/codereview_prompt.py +91 -0
- systemprompts/consensus_prompt.py +120 -0
- systemprompts/debug_prompt.py +150 -0
- systemprompts/docgen_prompt.py +252 -0
- systemprompts/generate_code_prompt.py +181 -0
- systemprompts/planner_prompt.py +127 -0
- systemprompts/precommit_prompt.py +179 -0
- systemprompts/refactor_prompt.py +323 -0
- systemprompts/secaudit_prompt.py +420 -0
- systemprompts/testgen_prompt.py +133 -0
- systemprompts/thinkdeep_prompt.py +55 -0
- systemprompts/tracer_prompt.py +145 -0
- tools/__init__.py +43 -0
- tools/analyze.py +583 -0
- tools/apilookup.py +97 -0
- tools/challenge.py +198 -0
- tools/chat.py +187 -0
- tools/clink.py +130 -0
- tools/codereview.py +724 -0
- tools/consensus.py +790 -0
- tools/debug.py +573 -0
- tools/docgen.py +577 -0
- tools/listmodels.py +48 -0
- tools/models.py +373 -0
- tools/planner.py +533 -0
- tools/precommit.py +776 -0
- tools/refactor.py +645 -0
- tools/secaudit.py +685 -0
- tools/shared/__init__.py +19 -0
- tools/shared/base_models.py +167 -0
- tools/shared/base_tool.py +659 -0
- tools/shared/exceptions.py +20 -0
- tools/shared/schema_builders.py +159 -0
- tools/simple/__init__.py +18 -0
- tools/simple/base.py +539 -0
- tools/testgen.py +513 -0
- tools/thinkdeep.py +586 -0
- tools/tracer.py +763 -0
- tools/version.py +53 -0
- tools/workflow/__init__.py +22 -0
- tools/workflow/base.py +448 -0
- tools/workflow/schema_builders.py +166 -0
- tools/workflow/workflow_mixin.py +1195 -0
- utils/__init__.py +21 -0
- utils/call_state.py +349 -0
- utils/client_info.py +293 -0
- utils/consensus_manifest.py +131 -0
- utils/conversation_memory.py +582 -0
- utils/env.py +111 -0
- utils/file_types.py +271 -0
- utils/file_utils.py +803 -0
- utils/image_utils.py +94 -0
- utils/security_config.py +163 -0
- utils/storage_backend.py +177 -0
- utils/token_utils.py +54 -0
clink/parsers/base.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Parser interfaces for clink runner outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
13
|
+
|
|
14
|
+
MAX_METADATA_NUMBER = 2**53 - 1
|
|
15
|
+
MAX_SOURCE_URLS = 20
|
|
16
|
+
MAX_SOURCE_URL_LENGTH = 2048
|
|
17
|
+
_MODEL_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
|
18
|
+
_AUTHENTICATION_SIGNATURE = re.compile(
|
|
19
|
+
r"\b(?:authentication[_ -](?:required|error|failed)|invalid[_ -](?:api[_ -]key|authentication)|"
|
|
20
|
+
r"unauthenticated|not[_ -](?:logged[_ -]in|authenticated)|please (?:log|sign) in|"
|
|
21
|
+
r"401 unauthorized|(?:http|status(?: code)?)\s*[:=]?\s*401)\b",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
_RATE_LIMIT_SIGNATURE = re.compile(
|
|
25
|
+
r"\b(?:rate[_ -]limit(?:ed|[_ -](?:exceeded|error))?|too[_ -]many[_ -]requests|"
|
|
26
|
+
r"quota[_ -](?:has[_ -]been[_ -])?exceeded|resource_exhausted|"
|
|
27
|
+
r"429 too many requests|(?:http|status(?: code)?)\s*[:=]?\s*429)\b",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ParsedCLIResponse:
|
|
34
|
+
"""Result of parsing CLI stdout/stderr."""
|
|
35
|
+
|
|
36
|
+
content: str
|
|
37
|
+
metadata: dict[str, Any]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ParserError(RuntimeError):
|
|
41
|
+
"""Raised when CLI output cannot be parsed into a structured response."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, code: str = "parse_error") -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.code = code
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cli_failure(
|
|
49
|
+
*diagnostics: object, message: str = "CLI reported a failure", code: str = "parse_error"
|
|
50
|
+
) -> ParserError:
|
|
51
|
+
"""Classify known failure signatures without returning diagnostic text."""
|
|
52
|
+
|
|
53
|
+
# Only inspect bounded error fields, never arbitrary session/assistant bodies.
|
|
54
|
+
pending = [(value, 0) for value in diagnostics]
|
|
55
|
+
texts: list[str] = []
|
|
56
|
+
while pending and len(texts) < 32:
|
|
57
|
+
value, depth = pending.pop(0)
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
texts.append(value[:8192])
|
|
60
|
+
elif isinstance(value, int) and not isinstance(value, bool):
|
|
61
|
+
if value == 401:
|
|
62
|
+
texts.append("status 401")
|
|
63
|
+
elif value == 429:
|
|
64
|
+
texts.append("status 429")
|
|
65
|
+
elif isinstance(value, dict) and depth < 3:
|
|
66
|
+
pending.extend(
|
|
67
|
+
(value[key], depth + 1)
|
|
68
|
+
for key in ("error", "errors", "code", "type", "message", "status")
|
|
69
|
+
if key in value
|
|
70
|
+
)
|
|
71
|
+
elif isinstance(value, list) and depth < 3:
|
|
72
|
+
pending.extend((part, depth + 1) for part in value[:16])
|
|
73
|
+
|
|
74
|
+
text = "\n".join(texts)
|
|
75
|
+
if _AUTHENTICATION_SIGNATURE.search(text):
|
|
76
|
+
return ParserError("CLI authentication is required", code="authentication_required")
|
|
77
|
+
if _RATE_LIMIT_SIGNATURE.search(text):
|
|
78
|
+
return ParserError("CLI rate limit was reached", code="rate_limited")
|
|
79
|
+
return ParserError(message, code=code)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_json(text: str, *, allow_codex_web_search_ids: bool = False) -> object:
|
|
83
|
+
"""Decode strict JSON without preserving raw input in parser exceptions."""
|
|
84
|
+
|
|
85
|
+
duplicate_ids: list[dict[str, Any]] = []
|
|
86
|
+
|
|
87
|
+
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
88
|
+
result: dict[str, Any] = {}
|
|
89
|
+
for key, value in pairs:
|
|
90
|
+
if key in result:
|
|
91
|
+
if (
|
|
92
|
+
allow_codex_web_search_ids
|
|
93
|
+
and key == "id"
|
|
94
|
+
and isinstance(result[key], str)
|
|
95
|
+
and isinstance(value, str)
|
|
96
|
+
):
|
|
97
|
+
duplicate_ids.append(result)
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError("Duplicate JSON key")
|
|
100
|
+
result[key] = value
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
def reject_constant(_value: str) -> None:
|
|
104
|
+
raise ValueError("Non-finite JSON constant")
|
|
105
|
+
|
|
106
|
+
invalid = False
|
|
107
|
+
try:
|
|
108
|
+
result = json.loads(text, object_pairs_hook=unique_object, parse_constant=reject_constant)
|
|
109
|
+
except (ValueError, RecursionError):
|
|
110
|
+
invalid = True
|
|
111
|
+
if invalid:
|
|
112
|
+
raise ParserError("CLI returned malformed JSON")
|
|
113
|
+
if duplicate_ids:
|
|
114
|
+
# Codex rust-v0.166.0 flattens WebSearchItem.id into ThreadItem, which
|
|
115
|
+
# already has its own id. Both are opaque and never enter metadata.
|
|
116
|
+
# No other duplicate field, item type, or location is admitted.
|
|
117
|
+
item = result.get("item") if isinstance(result, dict) else None
|
|
118
|
+
if not (
|
|
119
|
+
isinstance(result, dict)
|
|
120
|
+
and result.get("type") in ("item.started", "item.updated", "item.completed")
|
|
121
|
+
and isinstance(item, dict)
|
|
122
|
+
and item.get("type") == "web_search"
|
|
123
|
+
and len(duplicate_ids) == 1
|
|
124
|
+
and duplicate_ids[0] is item
|
|
125
|
+
):
|
|
126
|
+
raise ParserError("CLI returned malformed JSON")
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def bounded_counts(value: object, fields: tuple[str, ...]) -> dict[str, int]:
|
|
131
|
+
"""Copy only finite nonnegative integer telemetry from an explicit allowlist."""
|
|
132
|
+
|
|
133
|
+
if not isinstance(value, dict):
|
|
134
|
+
return {}
|
|
135
|
+
return {
|
|
136
|
+
key: count
|
|
137
|
+
for key in fields
|
|
138
|
+
if isinstance((count := value.get(key)), int)
|
|
139
|
+
and not isinstance(count, bool)
|
|
140
|
+
and 0 <= count <= MAX_METADATA_NUMBER
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def bounded_number(value: object) -> int | float | None:
|
|
145
|
+
"""Return a bounded, nonnegative telemetry number, excluding booleans."""
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
isinstance(value, (int, float))
|
|
149
|
+
and not isinstance(value, bool)
|
|
150
|
+
and 0 <= value <= MAX_METADATA_NUMBER
|
|
151
|
+
and math.isfinite(value)
|
|
152
|
+
):
|
|
153
|
+
return value
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def model_identifier(value: object) -> str | None:
|
|
158
|
+
"""Accept only bounded native model identifiers, excluding free-form bodies."""
|
|
159
|
+
|
|
160
|
+
if isinstance(value, str) and _MODEL_IDENTIFIER.fullmatch(value):
|
|
161
|
+
return value
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def source_url(value: object) -> str | None:
|
|
166
|
+
"""Keep bounded HTTP(S) source locations without credentials or URL tokens."""
|
|
167
|
+
|
|
168
|
+
if (
|
|
169
|
+
not isinstance(value, str)
|
|
170
|
+
or not value
|
|
171
|
+
or len(value) > MAX_SOURCE_URL_LENGTH
|
|
172
|
+
or "\\" in value
|
|
173
|
+
or any(character.isspace() or unicodedata.category(character) in ("Cc", "Cf", "Cs") for character in value)
|
|
174
|
+
):
|
|
175
|
+
return None
|
|
176
|
+
try:
|
|
177
|
+
parts = urlsplit(value)
|
|
178
|
+
if (
|
|
179
|
+
parts.scheme not in ("http", "https")
|
|
180
|
+
or not parts.hostname
|
|
181
|
+
or parts.username is not None
|
|
182
|
+
or parts.password is not None
|
|
183
|
+
):
|
|
184
|
+
return None
|
|
185
|
+
hostname = parts.hostname
|
|
186
|
+
if "%" in hostname:
|
|
187
|
+
return None
|
|
188
|
+
if ":" in hostname:
|
|
189
|
+
ipaddress.IPv6Address(hostname)
|
|
190
|
+
else:
|
|
191
|
+
ascii_host = hostname.encode("idna").decode("ascii").rstrip(".")
|
|
192
|
+
if len(ascii_host) > 253 or not all(
|
|
193
|
+
re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in ascii_host.split(".")
|
|
194
|
+
):
|
|
195
|
+
return None
|
|
196
|
+
# Accessing port validates bracketed hosts and the numeric port range.
|
|
197
|
+
_ = parts.port
|
|
198
|
+
except (ValueError, UnicodeError):
|
|
199
|
+
return None
|
|
200
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class BaseParser:
|
|
204
|
+
"""Base interface for CLI output parsers."""
|
|
205
|
+
|
|
206
|
+
name: str = "base"
|
|
207
|
+
|
|
208
|
+
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
|
|
209
|
+
raise NotImplementedError("Parsers must implement parse()")
|
clink/parsers/claude.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Parser for Claude CLI JSON output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import (
|
|
8
|
+
BaseParser,
|
|
9
|
+
ParsedCLIResponse,
|
|
10
|
+
ParserError,
|
|
11
|
+
bounded_counts,
|
|
12
|
+
bounded_number,
|
|
13
|
+
cli_failure,
|
|
14
|
+
load_json,
|
|
15
|
+
model_identifier,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_USAGE_FIELDS = ("input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClaudeJSONParser(BaseParser):
|
|
22
|
+
"""Require a successful terminal result from Claude's JSON envelope."""
|
|
23
|
+
|
|
24
|
+
name = "claude_json"
|
|
25
|
+
|
|
26
|
+
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
|
|
27
|
+
if not stdout.strip():
|
|
28
|
+
raise cli_failure(stderr, message="Claude CLI returned empty stdout")
|
|
29
|
+
|
|
30
|
+
loaded = load_json(stdout)
|
|
31
|
+
if isinstance(loaded, dict):
|
|
32
|
+
events = [loaded]
|
|
33
|
+
elif isinstance(loaded, list) and loaded and all(isinstance(event, dict) for event in loaded):
|
|
34
|
+
events = loaded
|
|
35
|
+
else:
|
|
36
|
+
raise ParserError("Claude CLI JSON must be an object or a nonempty array of objects")
|
|
37
|
+
|
|
38
|
+
for index, event in enumerate(events):
|
|
39
|
+
event_type = event.get("type")
|
|
40
|
+
subtype = event.get("subtype")
|
|
41
|
+
if (
|
|
42
|
+
not isinstance(event_type, str)
|
|
43
|
+
or not event_type
|
|
44
|
+
or (subtype is not None and not isinstance(subtype, str))
|
|
45
|
+
):
|
|
46
|
+
raise ParserError("Claude CLI events must contain valid type fields")
|
|
47
|
+
if "status" in event and not isinstance(event["status"], str):
|
|
48
|
+
raise ParserError("Claude CLI event status must be textual")
|
|
49
|
+
if (
|
|
50
|
+
event_type == "error"
|
|
51
|
+
or event.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
|
|
52
|
+
or event.get("is_error", False) is not False
|
|
53
|
+
or event.get("error") is not None
|
|
54
|
+
or event.get("errors")
|
|
55
|
+
or (isinstance(subtype, str) and subtype.lower().startswith("error"))
|
|
56
|
+
):
|
|
57
|
+
raise cli_failure(event, event.get("result"), stderr, message="Claude CLI reported a failed result")
|
|
58
|
+
if event_type == "result" and index != len(events) - 1:
|
|
59
|
+
raise ParserError("Claude CLI emitted events after its terminal result")
|
|
60
|
+
message = event.get("message")
|
|
61
|
+
blocks = message.get("content") if isinstance(message, dict) else None
|
|
62
|
+
if isinstance(blocks, list):
|
|
63
|
+
for block in blocks:
|
|
64
|
+
if not isinstance(block, dict):
|
|
65
|
+
continue
|
|
66
|
+
subtype = block.get("subtype")
|
|
67
|
+
if block.get("type") == "error" or (
|
|
68
|
+
block.get("type") == "tool_result"
|
|
69
|
+
and (
|
|
70
|
+
block.get("is_error", False) is not False
|
|
71
|
+
or block.get("error") is not None
|
|
72
|
+
or (isinstance(subtype, str) and subtype.lower().startswith("error"))
|
|
73
|
+
)
|
|
74
|
+
):
|
|
75
|
+
raise cli_failure(block, stderr, message="Claude CLI tool execution failed")
|
|
76
|
+
|
|
77
|
+
payload = events[-1]
|
|
78
|
+
if payload["type"] != "result" or payload.get("subtype") != "success":
|
|
79
|
+
raise cli_failure(payload, stderr, message="Claude CLI did not include a successful terminal result")
|
|
80
|
+
if payload.get("status", "success") not in ("completed", "success"):
|
|
81
|
+
raise ParserError("Claude CLI terminal status did not indicate success")
|
|
82
|
+
|
|
83
|
+
result = payload.get("result")
|
|
84
|
+
if not isinstance(result, str):
|
|
85
|
+
raise ParserError("Claude CLI result must be textual")
|
|
86
|
+
if not result.strip():
|
|
87
|
+
raise cli_failure(stderr, message="Claude CLI result was empty", code="empty_response")
|
|
88
|
+
|
|
89
|
+
metadata: dict[str, Any] = {}
|
|
90
|
+
for field in ("duration_ms", "duration_api_ms", "num_turns"):
|
|
91
|
+
number = bounded_number(payload.get(field))
|
|
92
|
+
if number is not None:
|
|
93
|
+
metadata[field] = number
|
|
94
|
+
usage = bounded_counts(payload.get("usage"), _USAGE_FIELDS)
|
|
95
|
+
if usage:
|
|
96
|
+
metadata["usage"] = usage
|
|
97
|
+
|
|
98
|
+
model_usage = payload.get("modelUsage")
|
|
99
|
+
if isinstance(model_usage, dict) and len(model_usage) == 1:
|
|
100
|
+
actual_model = model_identifier(next(iter(model_usage)))
|
|
101
|
+
if actual_model and isinstance(model_usage[actual_model], dict):
|
|
102
|
+
metadata["actual_model"] = actual_model
|
|
103
|
+
|
|
104
|
+
return ParsedCLIResponse(content=result.strip(), metadata=metadata)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Parse native Claude NDJSON without retaining raw events or tool arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from clink.native_contracts import ParsedNativeResponse
|
|
9
|
+
from clink.parsers.base import ParserError, bounded_counts, bounded_number, cli_failure, load_json, model_identifier
|
|
10
|
+
|
|
11
|
+
RESULT_CHARACTERS = 20000
|
|
12
|
+
MAX_EVENTS = 16384
|
|
13
|
+
_SESSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z")
|
|
14
|
+
_SUBTYPE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}\Z")
|
|
15
|
+
_USAGE_FIELDS = ("input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_session_id(value: str | None) -> str | None:
|
|
19
|
+
if value is not None and (not isinstance(value, str) or not _SESSION.fullmatch(value)):
|
|
20
|
+
raise ParserError("Claude native session identity is invalid")
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClaudeNativeParser:
|
|
25
|
+
"""Use terminal status; recoverable tool errors do not invalidate success."""
|
|
26
|
+
|
|
27
|
+
def parse(self, stdout: str, stderr: str = "") -> ParsedNativeResponse:
|
|
28
|
+
lines = [line for line in stdout.splitlines() if line.strip()]
|
|
29
|
+
if not lines or len(lines) > MAX_EVENTS:
|
|
30
|
+
raise ParserError("Claude native output was empty or exceeded the event limit")
|
|
31
|
+
terminal: dict[str, Any] | None = None
|
|
32
|
+
session_id = None
|
|
33
|
+
permission_denials = 0
|
|
34
|
+
tool_errors = 0
|
|
35
|
+
fatal = False
|
|
36
|
+
for line in lines:
|
|
37
|
+
event = load_json(line)
|
|
38
|
+
if not isinstance(event, dict) or not isinstance(event.get("type"), str) or not event["type"]:
|
|
39
|
+
raise ParserError("Claude native events must be JSON objects with a type")
|
|
40
|
+
if terminal is not None:
|
|
41
|
+
raise ParserError("Claude native output continued after its terminal result")
|
|
42
|
+
observed_session = event.get("session_id")
|
|
43
|
+
if observed_session is not None:
|
|
44
|
+
validate_session_id(observed_session)
|
|
45
|
+
if session_id is not None and session_id != observed_session:
|
|
46
|
+
raise ParserError("Claude native output changed its session identity")
|
|
47
|
+
session_id = observed_session
|
|
48
|
+
subtype = event.get("subtype")
|
|
49
|
+
if subtype is not None and (not isinstance(subtype, str) or not _SUBTYPE.fullmatch(subtype)):
|
|
50
|
+
raise ParserError("Claude native event subtype is invalid")
|
|
51
|
+
if event["type"] == "system" and subtype == "permission_denied":
|
|
52
|
+
permission_denials += 1
|
|
53
|
+
elif event["type"] == "error":
|
|
54
|
+
fatal = True
|
|
55
|
+
elif event["type"] == "result":
|
|
56
|
+
terminal = event
|
|
57
|
+
message = event.get("message")
|
|
58
|
+
blocks = message.get("content") if isinstance(message, dict) else None
|
|
59
|
+
if isinstance(blocks, list):
|
|
60
|
+
tool_errors += sum(
|
|
61
|
+
1
|
|
62
|
+
for block in blocks
|
|
63
|
+
if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("is_error") is True
|
|
64
|
+
)
|
|
65
|
+
if terminal is None:
|
|
66
|
+
raise cli_failure(stderr, message="Claude native output had no terminal result")
|
|
67
|
+
if not isinstance(terminal.get("is_error"), bool):
|
|
68
|
+
raise ParserError("Claude native terminal error flag is invalid")
|
|
69
|
+
terminal_status = terminal.get("status", "success")
|
|
70
|
+
if not isinstance(terminal_status, str):
|
|
71
|
+
raise ParserError("Claude native terminal status is invalid")
|
|
72
|
+
denials = terminal.get("permission_denials", [])
|
|
73
|
+
if not isinstance(denials, list):
|
|
74
|
+
raise ParserError("Claude native permission denials must be an array")
|
|
75
|
+
# A denial can appear both in the stream and in the terminal summary.
|
|
76
|
+
permission_denials = max(permission_denials, len(denials))
|
|
77
|
+
metadata: dict[str, Any] = {
|
|
78
|
+
"tool_error_count": tool_errors,
|
|
79
|
+
"permission_denial_count": permission_denials,
|
|
80
|
+
"truncated": False,
|
|
81
|
+
}
|
|
82
|
+
for name in ("duration_ms", "duration_api_ms", "num_turns"):
|
|
83
|
+
number = bounded_number(terminal.get(name))
|
|
84
|
+
if number is not None:
|
|
85
|
+
metadata[name] = number
|
|
86
|
+
if usage := bounded_counts(terminal.get("usage"), _USAGE_FIELDS):
|
|
87
|
+
metadata["usage"] = usage
|
|
88
|
+
model_usage = terminal.get("modelUsage")
|
|
89
|
+
if isinstance(model_usage, dict) and len(model_usage) == 1:
|
|
90
|
+
actual_model = model_identifier(next(iter(model_usage)))
|
|
91
|
+
if actual_model is not None and isinstance(model_usage[actual_model], dict):
|
|
92
|
+
metadata["actual_model"] = actual_model
|
|
93
|
+
subtype = terminal.get("subtype")
|
|
94
|
+
if isinstance(subtype, str):
|
|
95
|
+
metadata["terminal_subtype"] = subtype
|
|
96
|
+
success = (
|
|
97
|
+
subtype == "success"
|
|
98
|
+
and terminal["is_error"] is False
|
|
99
|
+
and terminal_status in {"success", "completed"}
|
|
100
|
+
and terminal.get("error") is None
|
|
101
|
+
and not terminal.get("errors")
|
|
102
|
+
and not fatal
|
|
103
|
+
)
|
|
104
|
+
if success and session_id is None:
|
|
105
|
+
raise ParserError("Claude native successful result omitted its session identity")
|
|
106
|
+
state = "permission_needed" if permission_denials else "completed" if success else "failed"
|
|
107
|
+
content = terminal.get("result") if subtype == "success" else terminal.get("result", "")
|
|
108
|
+
if not isinstance(content, str):
|
|
109
|
+
raise ParserError("Claude native result must be textual")
|
|
110
|
+
metadata["truncated"] = len(content) > RESULT_CHARACTERS
|
|
111
|
+
if state == "failed":
|
|
112
|
+
metadata["error_code"] = cli_failure(terminal, stderr, code="native_error").code
|
|
113
|
+
return ParsedNativeResponse(state, content[:RESULT_CHARACTERS], session_id, metadata)
|