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.
Files changed (141) hide show
  1. clink/__init__.py +19 -0
  2. clink/agents/__init__.py +30 -0
  3. clink/agents/base.py +70 -0
  4. clink/agents/claude.py +7 -0
  5. clink/agents/codex.py +7 -0
  6. clink/agents/gemini.py +7 -0
  7. clink/allowed_roots.py +356 -0
  8. clink/backend.py +765 -0
  9. clink/cli.py +594 -0
  10. clink/constants.py +78 -0
  11. clink/contracts.py +167 -0
  12. clink/files.py +70 -0
  13. clink/generation.py +274 -0
  14. clink/generation_capabilities.py +207 -0
  15. clink/generation_contracts.py +214 -0
  16. clink/generation_policy.py +174 -0
  17. clink/models.py +243 -0
  18. clink/native.py +409 -0
  19. clink/native_adapters.py +244 -0
  20. clink/native_contracts.py +68 -0
  21. clink/native_generation.py +421 -0
  22. clink/native_generation_claude_policy.py +59 -0
  23. clink/native_generation_contracts.py +264 -0
  24. clink/native_generation_families.py +218 -0
  25. clink/native_generation_family_policy.py +14 -0
  26. clink/native_generation_files.py +153 -0
  27. clink/native_generation_policy.py +82 -0
  28. clink/native_policy.py +86 -0
  29. clink/parsers/__init__.py +34 -0
  30. clink/parsers/agy.py +53 -0
  31. clink/parsers/agy_native.py +161 -0
  32. clink/parsers/agy_native_generation.py +189 -0
  33. clink/parsers/base.py +209 -0
  34. clink/parsers/claude.py +104 -0
  35. clink/parsers/claude_native.py +113 -0
  36. clink/parsers/claude_native_generation.py +346 -0
  37. clink/parsers/codex.py +172 -0
  38. clink/parsers/codex_generation.py +212 -0
  39. clink/parsers/codex_native.py +236 -0
  40. clink/parsers/codex_native_generation.py +367 -0
  41. clink/parsers/gemini.py +110 -0
  42. clink/parsers/grok.py +63 -0
  43. clink/parsers/grok_native.py +91 -0
  44. clink/parsers/grok_native_generation.py +157 -0
  45. clink/policy.py +180 -0
  46. clink/registry.py +544 -0
  47. clink/resources/CODEX-LICENSE +201 -0
  48. clink/resources/CODEX-NOTICE +6 -0
  49. clink/resources/PROVENANCE.md +16 -0
  50. clink/resources/codex-0.166.0-models-direct.json +1284 -0
  51. clink/resources/codex-0.166.0-models-generation.json +1269 -0
  52. clink/resources/native-agy-1.4.12-admission.json +746 -0
  53. clink/resources/native-claude-2.1.304-admission.json +99 -0
  54. clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
  55. clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
  56. clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
  57. clink/resources/native-grok-1.0.70-admission.json +644 -0
  58. clink/runner.py +303 -0
  59. clink/setup.py +399 -0
  60. clink/tasks.py +695 -0
  61. clink_cli-0.2.1.dist-info/METADATA +225 -0
  62. clink_cli-0.2.1.dist-info/RECORD +141 -0
  63. clink_cli-0.2.1.dist-info/WHEEL +5 -0
  64. clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
  65. clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
  66. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
  67. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
  68. clink_cli-0.2.1.dist-info/top_level.txt +7 -0
  69. conf/__init__.py +1 -0
  70. conf/cli_clients/agy.json +40 -0
  71. conf/cli_clients/claude.json +39 -0
  72. conf/cli_clients/codex.json +40 -0
  73. conf/cli_clients/gemini.json +23 -0
  74. conf/cli_clients/grok.json +40 -0
  75. config.py +40 -0
  76. server.py +896 -0
  77. systemprompts/__init__.py +35 -0
  78. systemprompts/analyze_prompt.py +87 -0
  79. systemprompts/chat_prompt.py +57 -0
  80. systemprompts/clink/codex_codereviewer.txt +8 -0
  81. systemprompts/clink/default.txt +8 -0
  82. systemprompts/clink/default_codereviewer.txt +8 -0
  83. systemprompts/clink/default_planner.txt +7 -0
  84. systemprompts/clink/native_codereviewer.txt +1 -0
  85. systemprompts/clink/native_default.txt +1 -0
  86. systemprompts/clink/native_planner.txt +1 -0
  87. systemprompts/codereview_prompt.py +91 -0
  88. systemprompts/consensus_prompt.py +120 -0
  89. systemprompts/debug_prompt.py +150 -0
  90. systemprompts/docgen_prompt.py +252 -0
  91. systemprompts/generate_code_prompt.py +181 -0
  92. systemprompts/planner_prompt.py +127 -0
  93. systemprompts/precommit_prompt.py +179 -0
  94. systemprompts/refactor_prompt.py +323 -0
  95. systemprompts/secaudit_prompt.py +420 -0
  96. systemprompts/testgen_prompt.py +133 -0
  97. systemprompts/thinkdeep_prompt.py +55 -0
  98. systemprompts/tracer_prompt.py +145 -0
  99. tools/__init__.py +43 -0
  100. tools/analyze.py +583 -0
  101. tools/apilookup.py +97 -0
  102. tools/challenge.py +198 -0
  103. tools/chat.py +187 -0
  104. tools/clink.py +130 -0
  105. tools/codereview.py +724 -0
  106. tools/consensus.py +790 -0
  107. tools/debug.py +573 -0
  108. tools/docgen.py +577 -0
  109. tools/listmodels.py +48 -0
  110. tools/models.py +373 -0
  111. tools/planner.py +533 -0
  112. tools/precommit.py +776 -0
  113. tools/refactor.py +645 -0
  114. tools/secaudit.py +685 -0
  115. tools/shared/__init__.py +19 -0
  116. tools/shared/base_models.py +167 -0
  117. tools/shared/base_tool.py +659 -0
  118. tools/shared/exceptions.py +20 -0
  119. tools/shared/schema_builders.py +159 -0
  120. tools/simple/__init__.py +18 -0
  121. tools/simple/base.py +539 -0
  122. tools/testgen.py +513 -0
  123. tools/thinkdeep.py +586 -0
  124. tools/tracer.py +763 -0
  125. tools/version.py +53 -0
  126. tools/workflow/__init__.py +22 -0
  127. tools/workflow/base.py +448 -0
  128. tools/workflow/schema_builders.py +166 -0
  129. tools/workflow/workflow_mixin.py +1195 -0
  130. utils/__init__.py +21 -0
  131. utils/call_state.py +349 -0
  132. utils/client_info.py +293 -0
  133. utils/consensus_manifest.py +131 -0
  134. utils/conversation_memory.py +582 -0
  135. utils/env.py +111 -0
  136. utils/file_types.py +271 -0
  137. utils/file_utils.py +803 -0
  138. utils/image_utils.py +94 -0
  139. utils/security_config.py +163 -0
  140. utils/storage_backend.py +177 -0
  141. 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()")
@@ -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)