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
@@ -0,0 +1,367 @@
1
+ """Validate native generation telemetry without publishing operational output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .base import MAX_METADATA_NUMBER, BaseParser, ParsedCLIResponse, ParserError, cli_failure, load_json
13
+
14
+ MAX_EVENTS = 16384
15
+ _MAX_SOURCE_BYTES = 1024 * 1024
16
+ _USAGE_REQUIRED = frozenset({"input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens"})
17
+ _USAGE_OPTIONAL = frozenset({"cache_write_input_tokens"})
18
+ _ITEM_EVENTS = frozenset({"item.started", "item.updated", "item.completed"})
19
+ _TOOL_ITEMS = frozenset({"command_execution", "file_change", "mcp_tool_call", "collab_tool_call"})
20
+ _STATUSES = frozenset({"in_progress", "completed", "failed"})
21
+ _SESSION = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\Z")
22
+ _EXEC_POLICY_DENIAL = re.compile(
23
+ r"`(?P<command>[^`\r\n]+)` rejected: policy forbids commands starting with `(?P<prefix>[^`\r\n]+)`\Z"
24
+ )
25
+ _COMMAND_APPROVAL_DENIALS = frozenset(
26
+ {
27
+ "approval required by policy, but AskForApproval is set to Never",
28
+ "approval required by policy, but AskForApproval::Granular.sandbox_approval is false",
29
+ "approval required by policy rule, but AskForApproval::Granular.rules is false",
30
+ }
31
+ )
32
+ _MCP_APPROVAL_DENIALS = frozenset(
33
+ {
34
+ "MCP tool call requires approval, but approval policy is never",
35
+ "MCP tool call cancelled: interactive approval is unavailable in exec mode",
36
+ }
37
+ )
38
+
39
+ # This independent contract binds only the new profile. The service validates
40
+ # the private final-message file separately; streamed text is never an answer.
41
+ PARSER_CONTRACT = (
42
+ "codex-native-generation-jsonl/v1\n"
43
+ "source=codex-6ff1e1ca3b30fc8b914720681fcf5fe62e95f856;max-events=16384;jsonl-newline=LF\n"
44
+ "strict-json:no-duplicate-keys-except-pinned-direct-web-search-ids,no-nonfinite-numbers,utf8-text\n"
45
+ "thread.started:{type,thread_id:uuid};turn.started:{type};one-thread,one-turn,in-order\n"
46
+ "item.started|updated|completed:{type,item};item:{id:nonblank-text,type:known,typed-payload}\n"
47
+ "known-items=agent_message,reasoning,command_execution,file_change,mcp_tool_call,collab_tool_call,"
48
+ "web_search,todo_list,error;unknown-fields-rejected;opaque-JsonValue-subtrees-preserved-only-during-validation\n"
49
+ "lifecycle:no-reused-completed-id,no-type-change,no-duplicate-start,no-incomplete-observed-items;"
50
+ "completed-without-start-allowed;pre-turn-completed-error-warnings-allowed\n"
51
+ "final:one-last-turn.completed:{type,usage};no-stream-message-required;content-always-empty;"
52
+ "private-final-file-validation-owned-by-service;no-stream-finalfile-equality\n"
53
+ "usage:required=input_tokens,cached_input_tokens,output_tokens,reasoning_output_tokens;"
54
+ "optional=cache_write_input_tokens;nonnegative-safe-json-integers\n"
55
+ "recoverable-completed-tool-failures-and-item-errors-counted;"
56
+ "exact-source-supported-command-and-MCP-refusals=permission_needed\n"
57
+ "error:{type,message:utf8};turn.failed:{type,error:{message:utf8}};"
58
+ "failed,unknown,malformed,incomplete=protocol_error;known-auth-and-rate-diagnostics-safe-codes\n"
59
+ "metadata:usage,actual_model=null,tool_error_count,permission_denial_count;no-identities-or-bodies\n"
60
+ "identity:sha256(canonical-json(contract,source_sha256(codex_native_generation.py,base.py)));"
61
+ "source-limit=1048576-bytes-each\n"
62
+ )
63
+
64
+
65
+ def _protocol_error(message: str = "Codex native generation output violates its protocol") -> ParserError:
66
+ return ParserError(message, code="protocol_error")
67
+
68
+
69
+ def parser_contract_digest() -> str:
70
+ """Bind the native generation contract and its parser dependencies."""
71
+
72
+ sources = {}
73
+ try:
74
+ for name in ("codex_native_generation.py", "base.py"):
75
+ with Path(__file__).with_name(name).open("rb") as source:
76
+ content = source.read(_MAX_SOURCE_BYTES + 1)
77
+ if len(content) > _MAX_SOURCE_BYTES:
78
+ raise _protocol_error("Codex native generation parser source exceeds its identity limit")
79
+ sources[name] = hashlib.sha256(content).hexdigest()
80
+ except OSError:
81
+ raise _protocol_error("Codex native generation parser source identity is unavailable") from None
82
+ identity = json.dumps(
83
+ {"contract": PARSER_CONTRACT, "source_sha256": sources},
84
+ sort_keys=True,
85
+ separators=(",", ":"),
86
+ ensure_ascii=False,
87
+ )
88
+ return hashlib.sha256(identity.encode("utf-8")).hexdigest()
89
+
90
+
91
+ def _object(
92
+ value: object, required: set[str] | frozenset[str], optional: frozenset[str] = frozenset()
93
+ ) -> dict[str, Any]:
94
+ if not isinstance(value, dict) or not required <= value.keys() or value.keys() - (required | optional):
95
+ raise _protocol_error()
96
+ return value
97
+
98
+
99
+ def _text(value: object, *, nonempty: bool = False) -> str:
100
+ if not isinstance(value, str) or (nonempty and not value.strip()):
101
+ raise _protocol_error()
102
+ try:
103
+ value.encode("utf-8")
104
+ except UnicodeError:
105
+ raise _protocol_error() from None
106
+ return value
107
+
108
+
109
+ def _json_value(value: object) -> None:
110
+ """Validate wire JSON without interpreting tool-owned nested objects."""
111
+
112
+ pending = [value]
113
+ while pending:
114
+ part = pending.pop()
115
+ if isinstance(part, dict):
116
+ for key in part:
117
+ _text(key)
118
+ pending.extend(part.values())
119
+ elif isinstance(part, list):
120
+ pending.extend(part)
121
+ elif isinstance(part, str):
122
+ _text(part)
123
+ elif isinstance(part, float) and not math.isfinite(part):
124
+ raise _protocol_error()
125
+
126
+
127
+ def _error(value: object) -> dict[str, Any]:
128
+ error = _object(value, {"message"})
129
+ _text(error["message"])
130
+ return error
131
+
132
+
133
+ def _status(value: object, *, command: bool = False) -> None:
134
+ if not isinstance(value, str) or value not in (_STATUSES | {"declined"} if command else _STATUSES):
135
+ raise _protocol_error()
136
+
137
+
138
+ def _item(value: object) -> dict[str, Any]:
139
+ if not isinstance(value, dict):
140
+ raise _protocol_error()
141
+ _text(value.get("id"), nonempty=True)
142
+ item_type = _text(value.get("type"))
143
+ common = {"id", "type"}
144
+ if item_type in {"agent_message", "reasoning"}:
145
+ _object(value, common | {"text"})
146
+ _text(value["text"])
147
+ elif item_type == "error":
148
+ _object(value, common | {"message"})
149
+ _text(value["message"])
150
+ elif item_type == "command_execution":
151
+ _object(value, common | {"command", "aggregated_output", "exit_code", "status"})
152
+ _text(value["command"])
153
+ _text(value["aggregated_output"])
154
+ exit_code = value["exit_code"]
155
+ if exit_code is not None and (type(exit_code) is not int or not -(2**31) <= exit_code < 2**31):
156
+ raise _protocol_error()
157
+ _status(value["status"], command=True)
158
+ elif item_type == "file_change":
159
+ _object(value, common | {"changes", "status"})
160
+ _status(value["status"])
161
+ if not isinstance(value["changes"], list):
162
+ raise _protocol_error()
163
+ for change in value["changes"]:
164
+ _object(change, {"path", "kind"})
165
+ _text(change["path"])
166
+ if not isinstance(change["kind"], str) or change["kind"] not in {"add", "delete", "update"}:
167
+ raise _protocol_error()
168
+ elif item_type == "mcp_tool_call":
169
+ _object(value, common | {"server", "tool", "arguments", "result", "error", "status"})
170
+ _text(value["server"])
171
+ _text(value["tool"])
172
+ _status(value["status"])
173
+ if value["error"] is not None:
174
+ _error(value["error"])
175
+ if value["result"] is not None:
176
+ result = _object(value["result"], {"content", "structured_content"}, frozenset({"_meta"}))
177
+ if not isinstance(result["content"], list):
178
+ raise _protocol_error()
179
+ elif item_type == "collab_tool_call":
180
+ _object(
181
+ value, common | {"tool", "sender_thread_id", "receiver_thread_ids", "prompt", "agents_states", "status"}
182
+ )
183
+ if not isinstance(value["tool"], str) or value["tool"] not in {
184
+ "spawn_agent",
185
+ "send_input",
186
+ "wait",
187
+ "close_agent",
188
+ }:
189
+ raise _protocol_error()
190
+ _text(value["sender_thread_id"], nonempty=True)
191
+ if value["prompt"] is not None:
192
+ _text(value["prompt"])
193
+ if not isinstance(value["receiver_thread_ids"], list) or not isinstance(value["agents_states"], dict):
194
+ raise _protocol_error()
195
+ for receiver in value["receiver_thread_ids"]:
196
+ _text(receiver, nonempty=True)
197
+ for agent_id, state in value["agents_states"].items():
198
+ _text(agent_id, nonempty=True)
199
+ _object(state, {"status", "message"})
200
+ if not isinstance(state["status"], str) or state["status"] not in {
201
+ "pending_init",
202
+ "running",
203
+ "interrupted",
204
+ "completed",
205
+ "errored",
206
+ "shutdown",
207
+ "not_found",
208
+ }:
209
+ raise _protocol_error()
210
+ if state["message"] is not None:
211
+ _text(state["message"])
212
+ _status(value["status"])
213
+ elif item_type == "web_search":
214
+ _object(value, common | {"query", "action"}, frozenset({"results"}))
215
+ _text(value["query"])
216
+ # The imported WebSearchAction is tool-owned. The pinned mapper exposes
217
+ # its container, while results explicitly contain arbitrary JsonValue.
218
+ if not isinstance(value["action"], dict):
219
+ raise _protocol_error()
220
+ if "results" in value and not isinstance(value["results"], list):
221
+ raise _protocol_error()
222
+ elif item_type == "todo_list":
223
+ _object(value, common | {"items"})
224
+ if not isinstance(value["items"], list):
225
+ raise _protocol_error()
226
+ for step in value["items"]:
227
+ _object(step, {"text", "completed"})
228
+ _text(step["text"])
229
+ if type(step["completed"]) is not bool:
230
+ raise _protocol_error()
231
+ else:
232
+ raise _protocol_error()
233
+ return value
234
+
235
+
236
+ def _permission_denial(item: dict[str, Any]) -> bool:
237
+ """Recognize only pinned refusal signatures, never arbitrary tool prose."""
238
+
239
+ if item["type"] == "command_execution" and item["status"] == "declined" and item["exit_code"] == -1:
240
+ output = item["aggregated_output"]
241
+ if output in _COMMAND_APPROVAL_DENIALS:
242
+ return True
243
+ marker = _EXEC_POLICY_DENIAL.fullmatch(output)
244
+ return (
245
+ marker is not None
246
+ and marker["command"] == item["command"]
247
+ and bool(marker["command"].strip())
248
+ and bool(marker["prefix"].strip())
249
+ )
250
+ return (
251
+ item["type"] == "mcp_tool_call"
252
+ and item["status"] == "failed"
253
+ and item["result"] is None
254
+ and item["error"] is not None
255
+ and item["error"]["message"] in _MCP_APPROVAL_DENIALS
256
+ )
257
+
258
+
259
+ class CodexNativeGenerationParser(BaseParser):
260
+ """Validate one native turn; leave final-message publication to its owner."""
261
+
262
+ name = "codex_native_generation_jsonl"
263
+
264
+ def parse(self, stdout: str, stderr: str = "") -> ParsedCLIResponse:
265
+ if not isinstance(stdout, str) or not isinstance(stderr, str):
266
+ raise _protocol_error()
267
+ _text(stdout)
268
+ _text(stderr)
269
+ lines = [line for line in stdout.split("\n") if line.strip(" \t\r")]
270
+ if not lines or len(lines) > MAX_EVENTS:
271
+ raise cli_failure(
272
+ stderr, message="Codex native generation output is empty or too large", code="protocol_error"
273
+ )
274
+ thread_started = False
275
+ turn_started = False
276
+ terminal = False
277
+ fatal: dict[str, Any] | None = None
278
+ failed: dict[str, Any] | None = None
279
+ item_types: dict[str, str] = {}
280
+ active_items: set[str] = set()
281
+ completed_items: set[str] = set()
282
+ metadata: dict[str, Any] = {"actual_model": None, "tool_error_count": 0, "permission_denial_count": 0}
283
+
284
+ for line in lines:
285
+ try:
286
+ event = load_json(line, allow_codex_web_search_ids=True)
287
+ except ParserError:
288
+ raise _protocol_error() from None
289
+ _json_value(event)
290
+ if not isinstance(event, dict) or not isinstance(event.get("type"), str) or terminal:
291
+ raise _protocol_error()
292
+ event_type = event["type"]
293
+ # Native terminal handling reconciles outstanding tool/todo items
294
+ # before emitting turn.failed. A prior fatal error still prevents
295
+ # success, but must not discard its safe authentication/rate code.
296
+ if fatal is not None and event_type not in {"item.completed", "turn.failed"}:
297
+ raise _protocol_error()
298
+
299
+ if event_type == "error":
300
+ _object(event, {"type", "message"})
301
+ _text(event["message"])
302
+ fatal = event
303
+ elif event_type == "turn.failed":
304
+ _object(event, {"type", "error"})
305
+ _error(event["error"])
306
+ failed = event
307
+ terminal = True
308
+ elif event_type == "thread.started":
309
+ _object(event, {"type", "thread_id"})
310
+ session = _text(event["thread_id"])
311
+ if thread_started or turn_started or not _SESSION.fullmatch(session):
312
+ raise _protocol_error()
313
+ thread_started = True
314
+ elif event_type == "turn.started":
315
+ _object(event, {"type"})
316
+ if not thread_started or turn_started:
317
+ raise _protocol_error()
318
+ turn_started = True
319
+ elif event_type in _ITEM_EVENTS:
320
+ _object(event, {"type", "item"})
321
+ item = _item(event["item"])
322
+ item_id, item_type = item["id"], item["type"]
323
+ if not thread_started or (
324
+ not turn_started and not (item_type == "error" and event_type == "item.completed")
325
+ ):
326
+ raise _protocol_error()
327
+ if item_id in completed_items or (item_id in item_types and item_types[item_id] != item_type):
328
+ raise _protocol_error()
329
+ if event_type == "item.started" and item_id in item_types:
330
+ raise _protocol_error()
331
+ item_types[item_id] = item_type
332
+ if event_type == "item.completed":
333
+ if item_type in _TOOL_ITEMS and item["status"] == "in_progress":
334
+ raise _protocol_error()
335
+ active_items.discard(item_id)
336
+ completed_items.add(item_id)
337
+ tool_failed = item_type == "error" or (
338
+ item_type in _TOOL_ITEMS
339
+ and (
340
+ item["status"] in {"failed", "declined"}
341
+ or (item_type == "command_execution" and item["exit_code"] not in (None, 0))
342
+ or (item_type == "mcp_tool_call" and item["error"] is not None)
343
+ )
344
+ )
345
+ metadata["tool_error_count"] += int(tool_failed)
346
+ metadata["permission_denial_count"] += int(_permission_denial(item))
347
+ else:
348
+ active_items.add(item_id)
349
+ elif event_type == "turn.completed":
350
+ _object(event, {"type", "usage"})
351
+ if not turn_started or active_items:
352
+ raise _protocol_error()
353
+ usage = _object(event["usage"], _USAGE_REQUIRED, _USAGE_OPTIONAL)
354
+ if any(type(count) is not int or not 0 <= count <= MAX_METADATA_NUMBER for count in usage.values()):
355
+ raise _protocol_error()
356
+ metadata["usage"] = usage.copy()
357
+ terminal = True
358
+ else:
359
+ raise _protocol_error()
360
+
361
+ if metadata["permission_denial_count"]:
362
+ raise ParserError("Codex native generation requires permission", code="permission_needed")
363
+ if failed is not None or fatal is not None or not terminal:
364
+ raise cli_failure(
365
+ failed, fatal, stderr, message="Codex native generation did not complete", code="protocol_error"
366
+ )
367
+ return ParsedCLIResponse(content="", metadata=metadata)
@@ -0,0 +1,110 @@
1
+ """Parser for Gemini CLI JSON output; execution availability is adapter policy."""
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
+ _TOKEN_FIELDS = ("prompt", "candidates", "total", "cached", "thoughts", "tool")
19
+
20
+
21
+ class GeminiJSONParser(BaseParser):
22
+ """Require response text and reject explicit Gemini errors."""
23
+
24
+ name = "gemini_json"
25
+
26
+ def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
27
+ if not stdout.strip():
28
+ raise cli_failure(stderr, message="Gemini CLI returned empty stdout")
29
+
30
+ payload = load_json(stdout)
31
+ if not isinstance(payload, dict):
32
+ raise ParserError("Gemini CLI JSON must be an object")
33
+ if "type" in payload and not isinstance(payload["type"], str):
34
+ raise ParserError("Gemini CLI response type must be textual")
35
+ if "status" in payload and not isinstance(payload["status"], str):
36
+ raise ParserError("Gemini CLI response status must be textual")
37
+ if (
38
+ payload.get("type") in ("error", "failed")
39
+ or payload.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
40
+ or payload.get("error") is not None
41
+ or payload.get("errors")
42
+ or payload.get("is_error", False) is not False
43
+ ):
44
+ raise cli_failure(payload, stderr, message="Gemini CLI reported a failure")
45
+ if payload.get("status", "success") not in ("completed", "success"):
46
+ raise ParserError("Gemini CLI response status did not indicate success")
47
+
48
+ metadata = self._metadata(payload, stderr)
49
+ response = payload.get("response")
50
+ if not isinstance(response, str):
51
+ raise ParserError("Gemini CLI response must be textual")
52
+ if not response.strip():
53
+ raise cli_failure(stderr, message="Gemini CLI response was empty", code="empty_response")
54
+
55
+ return ParsedCLIResponse(content=response.strip(), metadata=metadata)
56
+
57
+ def _metadata(self, payload: dict[str, Any], stderr: str) -> dict[str, Any]:
58
+ stats = payload.get("stats", {})
59
+ if not isinstance(stats, dict):
60
+ raise ParserError("Gemini CLI stats must be an object")
61
+ pending: list[object] = [stats]
62
+ while pending:
63
+ record = pending.pop()
64
+ if isinstance(record, list):
65
+ pending.extend(record)
66
+ elif isinstance(record, dict):
67
+ if "status" in record and not isinstance(record["status"], str):
68
+ raise ParserError("Gemini CLI stats status must be textual")
69
+ if (
70
+ record.get("type") in ("error", "failed")
71
+ or record.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
72
+ or record.get("error") is not None
73
+ or record.get("errors")
74
+ or record.get("is_error", False) is not False
75
+ ):
76
+ raise cli_failure(record, stderr, message="Gemini CLI stats reported a failure")
77
+ for field in ("totalErrors", "totalFail", "fail"):
78
+ if field not in record:
79
+ continue
80
+ errors = record[field]
81
+ if not isinstance(errors, int) or isinstance(errors, bool) or errors < 0:
82
+ raise ParserError("Gemini CLI failure counts must be nonnegative integers")
83
+ if errors:
84
+ raise cli_failure(stderr, message="Gemini CLI stats reported a failure")
85
+ pending.extend(record.values())
86
+ models = stats.get("models", {})
87
+ if not isinstance(models, dict):
88
+ raise ParserError("Gemini CLI model stats must be objects")
89
+
90
+ metadata: dict[str, Any] = {}
91
+ for model_stats in models.values():
92
+ if not isinstance(model_stats, dict):
93
+ raise ParserError("Gemini CLI model stats must be objects")
94
+ api_stats = model_stats.get("api", {})
95
+ if not isinstance(api_stats, dict):
96
+ raise ParserError("Gemini CLI API stats must be an object")
97
+
98
+ if len(models) == 1:
99
+ model_name, model_stats = next(iter(models.items()))
100
+ actual_model = model_identifier(model_name)
101
+ if actual_model:
102
+ metadata["actual_model"] = actual_model
103
+ tokens = bounded_counts(model_stats.get("tokens"), _TOKEN_FIELDS)
104
+ if tokens:
105
+ metadata["usage"] = tokens
106
+ latency = bounded_number(model_stats.get("api", {}).get("totalLatencyMs"))
107
+ if latency is not None:
108
+ metadata["latency_ms"] = latency
109
+
110
+ return metadata
clink/parsers/grok.py ADDED
@@ -0,0 +1,63 @@
1
+ """Parse Grok Build's single-document JSON result without admitting execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .base import BaseParser, ParsedCLIResponse, ParserError, bounded_counts, cli_failure, load_json, model_identifier
8
+
9
+ _USAGE_FIELDS = (
10
+ "input_tokens",
11
+ "output_tokens",
12
+ "cache_read_input_tokens",
13
+ "cache_creation_input_tokens",
14
+ "reasoning_tokens",
15
+ "total_tokens",
16
+ )
17
+
18
+
19
+ class GrokJSONParser(BaseParser):
20
+ """Require a complete end_turn response, rejecting partial or error results."""
21
+
22
+ name = "grok_json"
23
+
24
+ def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
25
+ if not stdout.strip():
26
+ raise cli_failure(stderr, message="Grok CLI returned empty stdout")
27
+
28
+ payload = load_json(stdout)
29
+ if not isinstance(payload, dict):
30
+ raise ParserError("Grok CLI JSON must be an object")
31
+ if (
32
+ payload.get("type") in ("error", "failed")
33
+ or payload.get("error") is not None
34
+ or ("errors" in payload and payload["errors"] is not None and payload["errors"] != [])
35
+ or payload.get("is_error", False) is not False
36
+ ):
37
+ raise cli_failure(payload, stderr, message="Grok CLI reported a failure")
38
+ if "type" in payload or "event" in payload or "status" in payload:
39
+ raise ParserError("Grok CLI returned an unsupported JSON dialect")
40
+ # Installed 1.0.70 documents end_turn, max_tokens, max_turn_requests,
41
+ # refusal and cancelled. Only end_turn establishes a complete answer.
42
+ if payload.get("stopReason") != "end_turn":
43
+ raise ParserError("Grok CLI did not include a complete terminal stop reason")
44
+
45
+ text = payload.get("text")
46
+ if not isinstance(text, str):
47
+ raise ParserError("Grok CLI response must be textual")
48
+ if not text.strip():
49
+ raise cli_failure(stderr, message="Grok CLI response was empty", code="empty_response")
50
+
51
+ metadata: dict[str, Any] = bounded_counts(payload, ("num_turns",))
52
+ usage = bounded_counts(payload.get("usage"), _USAGE_FIELDS)
53
+ if usage:
54
+ metadata["usage"] = usage
55
+
56
+ model_usage = payload.get("modelUsage")
57
+ if isinstance(model_usage, dict) and len(model_usage) == 1:
58
+ model_name, model_stats = next(iter(model_usage.items()))
59
+ actual_model = model_identifier(model_name)
60
+ if actual_model and actual_model != "unknown" and isinstance(model_stats, dict):
61
+ metadata["actual_model"] = actual_model
62
+
63
+ return ParsedCLIResponse(content=text.strip(), metadata=metadata)
@@ -0,0 +1,91 @@
1
+ """Parse Grok native JSON and documented headless refusal diagnostics."""
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, cli_failure, load_json, model_identifier
10
+
11
+ RESULT_CHARACTERS = 20000
12
+ MAX_EVENTS = 16384
13
+ _SESSION = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\Z")
14
+ _PERMISSION_REFUSAL = re.compile(
15
+ r"permission required: [^\r\n]+ was not run, as a headless run cannot ask for approval\Z"
16
+ )
17
+ _STOP_REASONS = {"end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"}
18
+ _USAGE_FIELDS = (
19
+ "input_tokens",
20
+ "output_tokens",
21
+ "cache_read_input_tokens",
22
+ "cache_creation_input_tokens",
23
+ "reasoning_tokens",
24
+ "total_tokens",
25
+ )
26
+
27
+
28
+ def validate_session_id(value: str | None) -> str | None:
29
+ """Accept only the UUID identity consumed by Grok's native session flags."""
30
+
31
+ if value is not None and (not isinstance(value, str) or not _SESSION.fullmatch(value)):
32
+ raise ParserError("Grok native session identity is invalid")
33
+ return value
34
+
35
+
36
+ class GrokNativeParser:
37
+ """Keep native execution outcomes separate from advisory answer validity."""
38
+
39
+ def parse(self, stdout: str, stderr: str = "") -> ParsedNativeResponse:
40
+ denials = min(MAX_EVENTS, sum(bool(_PERMISSION_REFUSAL.fullmatch(line)) for line in stderr.splitlines()))
41
+ if not stdout.strip():
42
+ if denials:
43
+ return ParsedNativeResponse(
44
+ "permission_needed", metadata={"permission_denial_count": denials, "truncated": False}
45
+ )
46
+ raise cli_failure(stderr, message="Grok native output was empty")
47
+ payload = load_json(stdout)
48
+ if not isinstance(payload, dict):
49
+ raise ParserError("Grok native JSON must be an object")
50
+ if any(name in payload for name in ("event", "status")) or ("type" in payload and payload["type"] != "error"):
51
+ raise ParserError("Grok native output used an unsupported JSON dialect")
52
+ if "is_error" in payload and not isinstance(payload["is_error"], bool):
53
+ raise ParserError("Grok native error flag must be boolean")
54
+ if "errors" in payload and payload["errors"] is not None and not isinstance(payload["errors"], list):
55
+ raise ParserError("Grok native errors must be an array")
56
+ session_id = validate_session_id(payload.get("sessionId"))
57
+ error = (
58
+ payload.get("type") == "error"
59
+ or payload.get("error") is not None
60
+ or bool(payload.get("errors"))
61
+ or payload.get("is_error") is True
62
+ )
63
+ reason = payload.get("stopReason")
64
+ if reason is not None and (not isinstance(reason, str) or reason not in _STOP_REASONS):
65
+ raise ParserError("Grok native terminal stop reason is invalid")
66
+ if reason is None and not error:
67
+ raise ParserError("Grok native output omitted its terminal stop reason")
68
+ if not error and session_id is None:
69
+ raise ParserError("Grok native terminal result omitted its session identity")
70
+ content = payload.get("text", "") if error else payload.get("text")
71
+ if not isinstance(content, str):
72
+ raise ParserError("Grok native result must be textual")
73
+ state = "permission_needed" if denials else "completed" if reason == "end_turn" and not error else "failed"
74
+ metadata: dict[str, Any] = {
75
+ "permission_denial_count": denials,
76
+ "truncated": len(content) > RESULT_CHARACTERS,
77
+ }
78
+ if reason is not None:
79
+ metadata["terminal_stop_reason"] = reason
80
+ metadata.update(bounded_counts(payload, ("num_turns",)))
81
+ if usage := bounded_counts(payload.get("usage"), _USAGE_FIELDS):
82
+ metadata["usage"] = usage
83
+ model_usage = payload.get("modelUsage")
84
+ if isinstance(model_usage, dict) and len(model_usage) == 1:
85
+ model, stats = next(iter(model_usage.items()))
86
+ actual_model = model_identifier(model)
87
+ if actual_model is not None and actual_model != "unknown" and isinstance(stats, dict):
88
+ metadata["actual_model"] = actual_model
89
+ if state == "failed":
90
+ metadata["error_code"] = cli_failure(payload, stderr, code="native_error").code
91
+ return ParsedNativeResponse(state, content[:RESULT_CHARACTERS], session_id, metadata)