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/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)
|