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/runner.py ADDED
@@ -0,0 +1,303 @@
1
+ """Bounded POSIX process supervision shared by every CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import re
9
+ import signal
10
+ import time
11
+ from collections import deque
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from clink.contracts import BackendError
17
+
18
+ logger = logging.getLogger("clink.runner")
19
+ STREAM_BYTES = 10 * 1024 * 1024
20
+ QUEUE_LIMIT = 8
21
+ QUEUE_SECONDS = 30.0
22
+ CLEANUP_SECONDS = 5.0
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ProcessOutput:
27
+ """Ephemeral streams; callers must never persist these in normal metadata."""
28
+
29
+ stdout: str
30
+ stderr: str
31
+ returncode: int
32
+ stdout_bytes: int
33
+ stderr_bytes: int
34
+ queue_seconds: float
35
+
36
+
37
+ class ProcessPool:
38
+ """A bounded FIFO launch pool with cancellation-safe permit ownership."""
39
+
40
+ def __init__(self, maximum: int = 2):
41
+ if type(maximum) is not int or maximum < 1:
42
+ raise BackendError("invalid_config", "CLINK_MAX_CONCURRENT must be a positive integer.")
43
+ self.maximum = maximum
44
+ self.running = 0
45
+ self._waiters: deque[asyncio.Future[None]] = deque()
46
+ self._children: set[asyncio.subprocess.Process] = set()
47
+ self._closed = False
48
+ self._executions: set[asyncio.Task[Any]] = set()
49
+
50
+ async def _acquire(self, deadline: float) -> float:
51
+ start = time.monotonic()
52
+ if self._closed:
53
+ raise BackendError("cancelled", "The CLI backend is shutting down.")
54
+ if start >= deadline:
55
+ raise BackendError("timeout", "The request deadline expired before launch.")
56
+ if self.running < self.maximum and not self._waiters:
57
+ self.running += 1
58
+ return 0.0
59
+ if len(self._waiters) >= QUEUE_LIMIT:
60
+ raise BackendError("queue_full", "The delegated-process queue is full.", "Submit a new request later.")
61
+ waiter = asyncio.get_running_loop().create_future()
62
+ self._waiters.append(waiter)
63
+ returned = False
64
+ try:
65
+ limit = min(QUEUE_SECONDS, deadline - start)
66
+ await asyncio.wait_for(asyncio.shield(waiter), limit)
67
+ if self._closed:
68
+ raise BackendError("cancelled", "The CLI backend is shutting down.")
69
+ if time.monotonic() >= deadline:
70
+ raise BackendError("timeout", "The request deadline expired while queued.")
71
+ elapsed = time.monotonic() - start
72
+ returned = True
73
+ return elapsed
74
+ except asyncio.TimeoutError as exc:
75
+ code = "timeout" if time.monotonic() >= deadline else "queue_timeout"
76
+ raise BackendError(code, "The delegated-process queue wait expired.") from exc
77
+ finally:
78
+ if not returned:
79
+ if waiter in self._waiters:
80
+ self._waiters.remove(waiter)
81
+ waiter.cancel()
82
+ elif waiter.done() and not waiter.cancelled() and waiter.exception() is None:
83
+ # Cancellation can race a release that already transferred ownership.
84
+ self._release()
85
+
86
+ def _release(self) -> None:
87
+ while self._waiters:
88
+ waiter = self._waiters.popleft()
89
+ if not waiter.done():
90
+ waiter.set_result(None)
91
+ return
92
+ self.running -= 1
93
+
94
+ @staticmethod
95
+ async def _read(stream: asyncio.StreamReader) -> bytes:
96
+ chunks: list[bytes] = []
97
+ size = 0
98
+ while True:
99
+ chunk = await stream.read(65536)
100
+ if not chunk:
101
+ return b"".join(chunks)
102
+ size += len(chunk)
103
+ if size > STREAM_BYTES:
104
+ raise BackendError("output_limit", "A CLI output stream exceeded the 10 MiB byte limit.")
105
+ chunks.append(chunk)
106
+
107
+ @staticmethod
108
+ async def _write(stream: asyncio.StreamWriter, content: bytes) -> None:
109
+ try:
110
+ stream.write(content)
111
+ await stream.drain()
112
+ except (BrokenPipeError, ConnectionResetError):
113
+ # A child that exits before consuming stdin is still classified by exit/envelope.
114
+ pass
115
+ finally:
116
+ stream.close()
117
+
118
+ @staticmethod
119
+ def _signal_group(process: asyncio.subprocess.Process, sig: int) -> bool:
120
+ try:
121
+ os.killpg(process.pid, sig)
122
+ return True
123
+ except ProcessLookupError:
124
+ return False
125
+
126
+ async def _cleanup(self, process: asyncio.subprocess.Process) -> None:
127
+ if self._signal_group(process, signal.SIGTERM):
128
+ # A dead group leader can leave descendants alive; inspect the group, not returncode.
129
+ until = time.monotonic() + CLEANUP_SECONDS
130
+ while time.monotonic() < until:
131
+ try:
132
+ os.killpg(process.pid, 0)
133
+ except ProcessLookupError:
134
+ break
135
+ await asyncio.sleep(0.05)
136
+ self._signal_group(process, signal.SIGKILL)
137
+ await process.wait()
138
+
139
+ async def execute(self, *args: Any, **kwargs: Any) -> ProcessOutput:
140
+ task = asyncio.current_task()
141
+ assert task is not None
142
+ self._executions.add(task)
143
+ try:
144
+ return await self._execute(*args, **kwargs)
145
+ finally:
146
+ self._executions.discard(task)
147
+
148
+ async def _execute(
149
+ self,
150
+ command: tuple[str, ...],
151
+ *,
152
+ content: bytes,
153
+ cwd: str,
154
+ env: dict[str, str],
155
+ deadline: float,
156
+ request_id: str,
157
+ metadata: dict[str, Any] | None = None,
158
+ prelaunch: Callable[[], None] | None = None,
159
+ on_started: Callable[[], None] | None = None,
160
+ ) -> ProcessOutput:
161
+ if os.name != "posix":
162
+ raise BackendError("unsupported_platform", "Delegation requires verified POSIX process-group cleanup.")
163
+ queue_seconds = await self._acquire(deadline)
164
+ process = None
165
+ tasks: list[asyncio.Task[Any]] = []
166
+ start = time.monotonic()
167
+ outcome = "cancelled"
168
+ safe_metadata = {
169
+ key: value if isinstance(value, str) and re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,63}", value) else "unknown"
170
+ for key, value in (metadata or {}).items()
171
+ if key in {"client", "role", "tool"}
172
+ }
173
+ try:
174
+ if self._closed:
175
+ raise BackendError("cancelled", "The CLI backend is shutting down.")
176
+ if start >= deadline:
177
+ raise BackendError("timeout", "The request deadline expired before launch.")
178
+ if prelaunch is not None:
179
+ prelaunch()
180
+ if time.monotonic() >= deadline:
181
+ raise BackendError("timeout", "The request deadline expired during launch admission checks.")
182
+ if self._closed:
183
+ raise BackendError("cancelled", "The CLI backend is shutting down.")
184
+ try:
185
+ spawn = asyncio.create_task(
186
+ asyncio.create_subprocess_exec(
187
+ *command,
188
+ stdin=asyncio.subprocess.PIPE,
189
+ stdout=asyncio.subprocess.PIPE,
190
+ stderr=asyncio.subprocess.PIPE,
191
+ cwd=cwd,
192
+ env=env,
193
+ start_new_session=True,
194
+ limit=65536,
195
+ )
196
+ )
197
+ try:
198
+ process = await asyncio.shield(spawn)
199
+ except asyncio.CancelledError:
200
+ # The OS spawn may already have happened. Finish acquiring its handle before cleanup.
201
+ while not spawn.done():
202
+ try:
203
+ await asyncio.shield(spawn)
204
+ except asyncio.CancelledError:
205
+ continue
206
+ process = spawn.result()
207
+ self._children.add(process)
208
+ if on_started is not None:
209
+ on_started()
210
+ raise
211
+ except FileNotFoundError as exc:
212
+ raise BackendError("cli_missing", "The admitted CLI executable is unavailable.") from exc
213
+ except (PermissionError, OSError) as exc:
214
+ raise BackendError("invalid_config", "The CLI executable could not be launched.") from exc
215
+ self._children.add(process)
216
+ if on_started is not None:
217
+ on_started()
218
+ assert process.stdin is not None and process.stdout is not None and process.stderr is not None
219
+ tasks = [
220
+ asyncio.create_task(self._read(process.stdout)),
221
+ asyncio.create_task(self._read(process.stderr)),
222
+ asyncio.create_task(self._write(process.stdin, content)),
223
+ asyncio.create_task(process.wait()),
224
+ ]
225
+ try:
226
+ stdout, stderr, _, returncode = await asyncio.wait_for(
227
+ asyncio.gather(*tasks), max(0.0, deadline - time.monotonic())
228
+ )
229
+ except asyncio.TimeoutError as exc:
230
+ raise BackendError("timeout", "The delegated request deadline expired.") from exc
231
+ outcome = "completed" if returncode == 0 else "nonzero_exit"
232
+ try:
233
+ stdout_text = stdout.decode("utf-8")
234
+ except UnicodeDecodeError:
235
+ raise BackendError("parse_error", "The CLI output envelope was not valid UTF-8.") from None
236
+ return ProcessOutput(
237
+ stdout_text,
238
+ stderr.decode("utf-8", errors="replace"),
239
+ returncode,
240
+ len(stdout),
241
+ len(stderr),
242
+ queue_seconds,
243
+ )
244
+ except BackendError as exc:
245
+ outcome = exc.code
246
+ raise
247
+ finally:
248
+
249
+ async def finalize() -> None:
250
+ try:
251
+ if process is not None:
252
+ await self._cleanup(process)
253
+ finally:
254
+ if process is not None:
255
+ self._children.discard(process)
256
+ for task in tasks:
257
+ if not task.done():
258
+ task.cancel()
259
+ if tasks:
260
+ await asyncio.gather(*tasks, return_exceptions=True)
261
+
262
+ # Repeated caller cancellation cannot interrupt process-tree cleanup or leak a permit.
263
+ finalizer = asyncio.create_task(finalize())
264
+ interrupted = False
265
+ try:
266
+ while True:
267
+ try:
268
+ await asyncio.shield(finalizer)
269
+ break
270
+ except asyncio.CancelledError:
271
+ interrupted = True
272
+ if finalizer.done():
273
+ raise
274
+ finally:
275
+ self._release()
276
+ logger.info(
277
+ "cli_process request_id=%s outcome=%s duration_seconds=%.3f queue_seconds=%.3f client=%s role=%s tool=%s",
278
+ request_id,
279
+ outcome,
280
+ time.monotonic() - start,
281
+ queue_seconds,
282
+ safe_metadata.get("client", "unknown"),
283
+ safe_metadata.get("role", "unknown"),
284
+ safe_metadata.get("tool", "unknown"),
285
+ )
286
+ if interrupted:
287
+ raise asyncio.CancelledError
288
+
289
+ async def shutdown(self) -> None:
290
+ """Stop admission, cancel all queued/spawning/running owners, and await cleanup."""
291
+ self._closed = True
292
+ for waiter in self._waiters:
293
+ if not waiter.done():
294
+ waiter.set_exception(BackendError("cancelled", "The CLI backend is shutting down."))
295
+ self._waiters.clear()
296
+ current = asyncio.current_task()
297
+ owners = tuple(task for task in self._executions if task is not current)
298
+ for task in owners:
299
+ task.cancel()
300
+ if owners:
301
+ await asyncio.gather(*owners, return_exceptions=True)
302
+ if self._children:
303
+ await asyncio.gather(*(self._cleanup(child) for child in tuple(self._children)), return_exceptions=True)