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/cli.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Bounded programmatic generation CLI; stdout contains a single terminal result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from functools import partial
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from clink.allowed_roots import (
|
|
21
|
+
Enrollment,
|
|
22
|
+
EnrollmentError,
|
|
23
|
+
allow_command,
|
|
24
|
+
approved,
|
|
25
|
+
canonical_directory,
|
|
26
|
+
prepare_enrollment,
|
|
27
|
+
)
|
|
28
|
+
from clink.contracts import BackendError
|
|
29
|
+
from clink.generation import GenerationService
|
|
30
|
+
from clink.generation_contracts import (
|
|
31
|
+
RAW_REQUEST_BYTES,
|
|
32
|
+
RESULT_BYTES,
|
|
33
|
+
STDIN_SECONDS,
|
|
34
|
+
GenerationInputError,
|
|
35
|
+
canonical_json,
|
|
36
|
+
parse_request,
|
|
37
|
+
result_exit_code,
|
|
38
|
+
terminal_result,
|
|
39
|
+
)
|
|
40
|
+
from clink.native_generation import NativeGenerationService
|
|
41
|
+
from clink.native_generation_contracts import parse_native_request, terminal_native_result
|
|
42
|
+
from clink.registry import ClinkRegistry, RegistryLoadError
|
|
43
|
+
|
|
44
|
+
_APPROVAL_CANCELLED: ContextVar[asyncio.Event | None] = ContextVar("clink_approval_cancelled", default=None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _EnrollmentDeclined(Exception):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _approval_guard(deadline: float | None = None) -> Callable[[], None]:
|
|
52
|
+
parent_pid = os.getppid()
|
|
53
|
+
cancelled = _APPROVAL_CANCELLED.get()
|
|
54
|
+
|
|
55
|
+
def check() -> None:
|
|
56
|
+
if os.getppid() != parent_pid or (cancelled is not None and cancelled.is_set()):
|
|
57
|
+
raise asyncio.CancelledError
|
|
58
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
59
|
+
raise BackendError("timeout", "The generation deadline expired during directory approval.")
|
|
60
|
+
|
|
61
|
+
return check
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _terminal_answer(fd: int) -> bytes:
|
|
65
|
+
answer = bytearray()
|
|
66
|
+
while len(answer) <= 1024:
|
|
67
|
+
try:
|
|
68
|
+
chunk = os.read(fd, 256)
|
|
69
|
+
except BlockingIOError:
|
|
70
|
+
await asyncio.sleep(0.05)
|
|
71
|
+
continue
|
|
72
|
+
if not chunk:
|
|
73
|
+
return b""
|
|
74
|
+
answer.extend(chunk)
|
|
75
|
+
if b"\n" in answer:
|
|
76
|
+
return bytes(answer).split(b"\n", 1)[0]
|
|
77
|
+
return b""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def confirm_enrollment(
|
|
81
|
+
plan: Enrollment, *, yes: bool = False, deadline: float | None = None, standalone: bool = False
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""Ask once on a separate foreground terminal; never consume request stdin."""
|
|
84
|
+
if plan.already_allowed:
|
|
85
|
+
return True
|
|
86
|
+
if plan.broad:
|
|
87
|
+
print(f"Warning: {plan.entry} grants access to a broad directory and all its descendants.", file=sys.stderr)
|
|
88
|
+
if yes:
|
|
89
|
+
return True
|
|
90
|
+
remedy = allow_command(
|
|
91
|
+
str(plan.target), plan.cli_name, plan.registry.project_dir, plan.registry.consumer_config_dir
|
|
92
|
+
)
|
|
93
|
+
descriptor = None
|
|
94
|
+
try:
|
|
95
|
+
if not sys.stderr.isatty() or os.environ.get("CI") or (standalone and not sys.stdin.isatty()):
|
|
96
|
+
raise OSError
|
|
97
|
+
descriptor = os.open("/dev/tty", os.O_RDWR | os.O_NONBLOCK | os.O_NOCTTY | os.O_CLOEXEC)
|
|
98
|
+
if os.tcgetpgrp(descriptor) != os.getpgrp():
|
|
99
|
+
raise OSError
|
|
100
|
+
except OSError:
|
|
101
|
+
if descriptor is not None:
|
|
102
|
+
os.close(descriptor)
|
|
103
|
+
raise EnrollmentError(f"Terminal approval is unavailable. Preapprove with: {remedy}") from None
|
|
104
|
+
try:
|
|
105
|
+
print(
|
|
106
|
+
f"{plan.entry} is not in the allowed paths for {plan.cli_name}. "
|
|
107
|
+
"Allow Clink to use this folder from now on? [y/N] ",
|
|
108
|
+
end="",
|
|
109
|
+
file=sys.stderr,
|
|
110
|
+
flush=True,
|
|
111
|
+
)
|
|
112
|
+
remaining = max(0, deadline - time.monotonic()) if deadline is not None else None
|
|
113
|
+
answer = await asyncio.wait_for(_terminal_answer(descriptor), remaining)
|
|
114
|
+
return answer.strip().lower() in {b"y", b"yes"}
|
|
115
|
+
finally:
|
|
116
|
+
os.close(descriptor)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def prepare_native_generation(request: Any, registry: ClinkRegistry | None) -> ClinkRegistry:
|
|
120
|
+
guard = _approval_guard(request.deadline)
|
|
121
|
+
registry = registry if registry is not None else ClinkRegistry()
|
|
122
|
+
if registry.project_dir is not None:
|
|
123
|
+
raise BackendError("invalid_config", "Native generation requires independent user or explicit configuration.")
|
|
124
|
+
if approved(registry, request.cli, Path(request.project_dir), native=True):
|
|
125
|
+
guard()
|
|
126
|
+
return registry
|
|
127
|
+
plan = prepare_enrollment(registry, request.cli, request.project_dir, native=True)
|
|
128
|
+
if not await confirm_enrollment(plan, deadline=request.deadline):
|
|
129
|
+
raise _EnrollmentDeclined
|
|
130
|
+
if time.monotonic() >= request.deadline:
|
|
131
|
+
raise asyncio.TimeoutError
|
|
132
|
+
await asyncio.sleep(0)
|
|
133
|
+
refreshed = plan.persist(guard=guard)
|
|
134
|
+
print(f"Added {plan.entry} to allowed paths in {json.dumps(str(plan.destination))}.", file=sys.stderr)
|
|
135
|
+
guard()
|
|
136
|
+
await asyncio.sleep(0)
|
|
137
|
+
guard()
|
|
138
|
+
return refreshed
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _allow_result(args: argparse.Namespace, target: str, status: str, saved: bool, code: str | None) -> None:
|
|
142
|
+
if getattr(args, "json", False):
|
|
143
|
+
result = {
|
|
144
|
+
"schema_version": 1,
|
|
145
|
+
"cli": args.cli,
|
|
146
|
+
"project_dir": target,
|
|
147
|
+
"status": status,
|
|
148
|
+
"grant_saved": saved,
|
|
149
|
+
"error_code": code,
|
|
150
|
+
}
|
|
151
|
+
encoded = canonical_json(result)
|
|
152
|
+
if len(encoded) > RESULT_BYTES:
|
|
153
|
+
raise EnrollmentError("Directory approval result exceeds the output limit.", grant_saved=saved)
|
|
154
|
+
sys.stdout.write(encoded.decode("utf-8") + "\n")
|
|
155
|
+
sys.stdout.flush()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _allow(args: argparse.Namespace) -> int:
|
|
159
|
+
guard = _approval_guard()
|
|
160
|
+
target = args.path
|
|
161
|
+
saved = False
|
|
162
|
+
publishing = False
|
|
163
|
+
consumer = getattr(args, "consumer_config_dir", None)
|
|
164
|
+
project = Path(args.project) if args.project else None
|
|
165
|
+
|
|
166
|
+
def mark_saved() -> None:
|
|
167
|
+
nonlocal saved
|
|
168
|
+
saved = True
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
target = str(canonical_directory(args.path))
|
|
172
|
+
registry = ClinkRegistry(project_dir=project, consumer_config_dir=consumer)
|
|
173
|
+
plan = prepare_enrollment(registry, args.cli, args.path)
|
|
174
|
+
target = str(plan.target)
|
|
175
|
+
if not await confirm_enrollment(plan, yes=args.yes, standalone=True):
|
|
176
|
+
print("Nothing was linked.", file=sys.stderr)
|
|
177
|
+
_allow_result(args, target, "failed", False, "cancelled")
|
|
178
|
+
return 0
|
|
179
|
+
if plan.already_allowed:
|
|
180
|
+
saved = True
|
|
181
|
+
status = "already_allowed"
|
|
182
|
+
print(f"Already allowed: {plan.entry}", file=sys.stderr)
|
|
183
|
+
else:
|
|
184
|
+
await asyncio.sleep(0)
|
|
185
|
+
publishing = True
|
|
186
|
+
plan.persist(guard=guard, published=mark_saved)
|
|
187
|
+
guard()
|
|
188
|
+
status = "allowed"
|
|
189
|
+
print(
|
|
190
|
+
f"Added {plan.entry} to allowed paths in {json.dumps(str(plan.destination))}. "
|
|
191
|
+
"Restart an existing MCP server to reload.",
|
|
192
|
+
file=sys.stderr,
|
|
193
|
+
)
|
|
194
|
+
_allow_result(args, target, status, saved, None)
|
|
195
|
+
return 0
|
|
196
|
+
except (EnrollmentError, RegistryLoadError, KeyError) as exc:
|
|
197
|
+
known = exc.grant_saved if isinstance(exc, EnrollmentError) else (True if saved else False)
|
|
198
|
+
print(
|
|
199
|
+
f"clink allow: {exc}; allowed_roots entry {json.dumps(target)}. "
|
|
200
|
+
f"Operator command: {allow_command(target, args.cli, project, consumer)}",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
203
|
+
if known is not None:
|
|
204
|
+
_allow_result(
|
|
205
|
+
args,
|
|
206
|
+
target,
|
|
207
|
+
"failed",
|
|
208
|
+
saved or known,
|
|
209
|
+
exc.error_code if isinstance(exc, EnrollmentError) else "invalid_config",
|
|
210
|
+
)
|
|
211
|
+
return 1
|
|
212
|
+
except (asyncio.CancelledError, KeyboardInterrupt) as exc:
|
|
213
|
+
print("Directory approval was cancelled; inspect configuration before retrying.", file=sys.stderr)
|
|
214
|
+
# Publication is synchronous and its callback runs before fsync/reload. If an
|
|
215
|
+
# unexpected publisher cannot establish its outcome, omit the envelope.
|
|
216
|
+
known = getattr(exc, "grant_saved", None)
|
|
217
|
+
if saved or not publishing or known is not None:
|
|
218
|
+
_allow_result(args, target, "failed", saved or bool(known), "cancelled")
|
|
219
|
+
return 1
|
|
220
|
+
except Exception:
|
|
221
|
+
print(
|
|
222
|
+
"clink allow: Enrollment failed internally. Inspect operator configuration before retrying.",
|
|
223
|
+
file=sys.stderr,
|
|
224
|
+
)
|
|
225
|
+
if saved or not publishing:
|
|
226
|
+
_allow_result(args, target, "failed", saved, "internal_error")
|
|
227
|
+
return 1
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def read_request_fd(fd: int) -> bytes:
|
|
231
|
+
"""Read one bounded body without a blocking worker thread that can outlive cancellation."""
|
|
232
|
+
loop = asyncio.get_running_loop()
|
|
233
|
+
regular = stat.S_ISREG(os.fstat(fd).st_mode)
|
|
234
|
+
was_blocking = os.get_blocking(fd)
|
|
235
|
+
os.set_blocking(fd, False)
|
|
236
|
+
chunks: list[bytes] = []
|
|
237
|
+
size = 0
|
|
238
|
+
try:
|
|
239
|
+
while True:
|
|
240
|
+
if not regular:
|
|
241
|
+
ready = loop.create_future()
|
|
242
|
+
|
|
243
|
+
def readable(future: asyncio.Future[None] = ready) -> None:
|
|
244
|
+
if not future.done():
|
|
245
|
+
future.set_result(None)
|
|
246
|
+
|
|
247
|
+
loop.add_reader(fd, readable)
|
|
248
|
+
try:
|
|
249
|
+
await ready
|
|
250
|
+
finally:
|
|
251
|
+
loop.remove_reader(fd)
|
|
252
|
+
try:
|
|
253
|
+
chunk = os.read(fd, min(65536, RAW_REQUEST_BYTES + 1 - size))
|
|
254
|
+
except BlockingIOError:
|
|
255
|
+
continue
|
|
256
|
+
if not chunk:
|
|
257
|
+
return b"".join(chunks)
|
|
258
|
+
size += len(chunk)
|
|
259
|
+
if size > RAW_REQUEST_BYTES:
|
|
260
|
+
raise GenerationInputError("input_limit", "The raw request exceeds the 1 MiB byte limit.")
|
|
261
|
+
chunks.append(chunk)
|
|
262
|
+
if regular:
|
|
263
|
+
await asyncio.sleep(0)
|
|
264
|
+
finally:
|
|
265
|
+
os.set_blocking(fd, was_blocking)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def _generate(started_at: float, fd: int, registry: ClinkRegistry | None = None) -> dict[str, Any]:
|
|
269
|
+
service: GenerationService | NativeGenerationService | None = None
|
|
270
|
+
request = None
|
|
271
|
+
make_result = terminal_result
|
|
272
|
+
try:
|
|
273
|
+
raw = await asyncio.wait_for(read_request_fd(fd), max(0, started_at + STDIN_SECONDS - time.monotonic()))
|
|
274
|
+
try:
|
|
275
|
+
envelope = json.loads(raw)
|
|
276
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
277
|
+
envelope = None
|
|
278
|
+
if (
|
|
279
|
+
isinstance(envelope, dict)
|
|
280
|
+
and type(envelope.get("schema_version")) is int
|
|
281
|
+
and envelope["schema_version"] in (2, 3)
|
|
282
|
+
):
|
|
283
|
+
make_result = partial(terminal_native_result, schema_version=envelope["schema_version"])
|
|
284
|
+
request = parse_native_request(raw, started_at=started_at)
|
|
285
|
+
if os.environ.get("CLINK_DELEGATION_DEPTH", "0") != "0":
|
|
286
|
+
raise BackendError("recursion_blocked", "Nested native generation delegation is prohibited.")
|
|
287
|
+
service = NativeGenerationService()
|
|
288
|
+
if registry is not None:
|
|
289
|
+
service.registry = registry
|
|
290
|
+
service.registry = await prepare_native_generation(request, service.registry)
|
|
291
|
+
else:
|
|
292
|
+
request = parse_request(raw, started_at=started_at)
|
|
293
|
+
service = GenerationService()
|
|
294
|
+
if registry is not None:
|
|
295
|
+
service.registry = registry
|
|
296
|
+
result = await service.run(request)
|
|
297
|
+
except asyncio.TimeoutError:
|
|
298
|
+
result = make_result(
|
|
299
|
+
started_at=started_at,
|
|
300
|
+
code="timeout",
|
|
301
|
+
status="timed_out",
|
|
302
|
+
request=request,
|
|
303
|
+
message=(
|
|
304
|
+
"The generation deadline expired during directory approval."
|
|
305
|
+
if request is not None
|
|
306
|
+
else "Standard input did not reach EOF within ten seconds."
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
except GenerationInputError as exc:
|
|
310
|
+
result = make_result(started_at=started_at, request_id=exc.request_id, code=exc.code, message=str(exc))
|
|
311
|
+
except BackendError as exc:
|
|
312
|
+
result = make_result(
|
|
313
|
+
started_at=started_at,
|
|
314
|
+
request=request,
|
|
315
|
+
code=exc.code,
|
|
316
|
+
message=str(exc),
|
|
317
|
+
status="timed_out" if exc.code == "timeout" else "failed",
|
|
318
|
+
)
|
|
319
|
+
except (EnrollmentError, RegistryLoadError, KeyError) as exc:
|
|
320
|
+
entry = json.dumps(request.project_dir) if request is not None else "unavailable"
|
|
321
|
+
remedy = (
|
|
322
|
+
allow_command(
|
|
323
|
+
request.project_dir,
|
|
324
|
+
request.cli,
|
|
325
|
+
consumer_config_dir=registry.consumer_config_dir if registry is not None else None,
|
|
326
|
+
)
|
|
327
|
+
if request is not None
|
|
328
|
+
else "clink allow PATH --cli NAME --yes"
|
|
329
|
+
)
|
|
330
|
+
print(f"clink generate: {exc}; allowed_roots entry {entry}. Operator command: {remedy}", file=sys.stderr)
|
|
331
|
+
message = f"Directory approval is required. Preapprove with: {remedy}"
|
|
332
|
+
if len(message) > 1024:
|
|
333
|
+
message = (
|
|
334
|
+
f"Directory approval is required. Run clink allow <project_dir> --cli {request.cli} --yes "
|
|
335
|
+
"using the canonical request project_dir. See stderr for the complete command."
|
|
336
|
+
)
|
|
337
|
+
if isinstance(exc, EnrollmentError) and exc.grant_saved:
|
|
338
|
+
message = (
|
|
339
|
+
"The directory grant was saved, but generation did not start. "
|
|
340
|
+
"Inspect operator configuration and stderr before retrying."
|
|
341
|
+
)
|
|
342
|
+
result = make_result(
|
|
343
|
+
started_at=started_at,
|
|
344
|
+
request=request,
|
|
345
|
+
code="path_not_allowed",
|
|
346
|
+
message=message,
|
|
347
|
+
)
|
|
348
|
+
except _EnrollmentDeclined:
|
|
349
|
+
result = make_result(
|
|
350
|
+
started_at=started_at,
|
|
351
|
+
request=request,
|
|
352
|
+
status="cancelled",
|
|
353
|
+
code="cancelled",
|
|
354
|
+
message="Nothing was linked.",
|
|
355
|
+
)
|
|
356
|
+
except asyncio.CancelledError:
|
|
357
|
+
result = make_result(
|
|
358
|
+
started_at=started_at,
|
|
359
|
+
request=request,
|
|
360
|
+
status="cancelled",
|
|
361
|
+
code="cancelled",
|
|
362
|
+
message="The generation attempt was cancelled after controlled cleanup.",
|
|
363
|
+
)
|
|
364
|
+
except Exception:
|
|
365
|
+
result = make_result(
|
|
366
|
+
started_at=started_at, request=request, code="internal_error", message="Generation failed internally."
|
|
367
|
+
)
|
|
368
|
+
finally:
|
|
369
|
+
if service is not None:
|
|
370
|
+
# The pool already shields child cleanup; finish shutdown before publishing a result.
|
|
371
|
+
shutdown = asyncio.create_task(service.shutdown())
|
|
372
|
+
while not shutdown.done():
|
|
373
|
+
try:
|
|
374
|
+
await asyncio.shield(shutdown)
|
|
375
|
+
except asyncio.CancelledError:
|
|
376
|
+
continue
|
|
377
|
+
await shutdown
|
|
378
|
+
result["duration_ms"] = max(0, int((time.monotonic() - started_at) * 1000))
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def run_generate(
|
|
383
|
+
started_at: float, parent_pid: int, fd: int, registry: ClinkRegistry | None = None
|
|
384
|
+
) -> dict[str, Any]:
|
|
385
|
+
return await _supervise(_generate(started_at, fd, registry), parent_pid, started_at=started_at)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _supervise(operation: Any, parent_pid: int, *, started_at: float | None = None) -> Any:
|
|
389
|
+
loop = asyncio.get_running_loop()
|
|
390
|
+
cancelled = asyncio.Event()
|
|
391
|
+
cancellation_token = _APPROVAL_CANCELLED.set(cancelled)
|
|
392
|
+
attempt = asyncio.create_task(operation)
|
|
393
|
+
|
|
394
|
+
def cancel() -> None:
|
|
395
|
+
cancelled.set()
|
|
396
|
+
if not attempt.done():
|
|
397
|
+
attempt.cancel()
|
|
398
|
+
|
|
399
|
+
async def watch_parent() -> None:
|
|
400
|
+
while not attempt.done():
|
|
401
|
+
if os.getppid() != parent_pid:
|
|
402
|
+
cancel()
|
|
403
|
+
return
|
|
404
|
+
await asyncio.sleep(0.1)
|
|
405
|
+
|
|
406
|
+
installed = []
|
|
407
|
+
watcher = asyncio.create_task(watch_parent())
|
|
408
|
+
try:
|
|
409
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
410
|
+
previous = signal.getsignal(sig)
|
|
411
|
+
|
|
412
|
+
def cancel_signal(_number: int, _frame: Any) -> None:
|
|
413
|
+
cancel()
|
|
414
|
+
loop.call_soon_threadsafe(lambda: None)
|
|
415
|
+
|
|
416
|
+
signal.signal(sig, cancel_signal)
|
|
417
|
+
installed.append((sig, previous))
|
|
418
|
+
try:
|
|
419
|
+
return await attempt
|
|
420
|
+
except asyncio.CancelledError:
|
|
421
|
+
if started_at is None:
|
|
422
|
+
print("Directory approval was cancelled; inspect configuration before retrying.", file=sys.stderr)
|
|
423
|
+
return 1
|
|
424
|
+
return terminal_result(
|
|
425
|
+
started_at=started_at,
|
|
426
|
+
status="cancelled",
|
|
427
|
+
code="cancelled",
|
|
428
|
+
message="The generation attempt was cancelled before submission.",
|
|
429
|
+
)
|
|
430
|
+
finally:
|
|
431
|
+
watcher.cancel()
|
|
432
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
433
|
+
await watcher
|
|
434
|
+
for sig, previous in installed:
|
|
435
|
+
signal.signal(sig, previous)
|
|
436
|
+
_APPROVAL_CANCELLED.reset(cancellation_token)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class _InvocationError(GenerationInputError):
|
|
440
|
+
def __init__(self, allow_args: argparse.Namespace | None) -> None:
|
|
441
|
+
super().__init__(
|
|
442
|
+
"invalid_invocation",
|
|
443
|
+
"Use clink generate --request-json - or clink allow PATH --cli NAME [--project PATH] [--yes] [--json].",
|
|
444
|
+
)
|
|
445
|
+
self.allow_args = allow_args
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class _ArgumentParser(argparse.ArgumentParser):
|
|
449
|
+
_error_namespace: argparse.Namespace | None = None
|
|
450
|
+
|
|
451
|
+
def parse_known_args(
|
|
452
|
+
self, args: list[str] | None = None, namespace: argparse.Namespace | None = None
|
|
453
|
+
) -> tuple[argparse.Namespace, list[str]]:
|
|
454
|
+
namespace = namespace if namespace is not None else argparse.Namespace()
|
|
455
|
+
self._error_namespace = namespace
|
|
456
|
+
return super().parse_known_args(args, namespace)
|
|
457
|
+
|
|
458
|
+
def error(self, _message: str) -> None:
|
|
459
|
+
values = vars(self._error_namespace) if self._error_namespace is not None else {}
|
|
460
|
+
identifiable = (
|
|
461
|
+
values.get("command") == "allow"
|
|
462
|
+
and values.get("json") is True
|
|
463
|
+
and isinstance(values.get("path"), str)
|
|
464
|
+
and isinstance(values.get("cli"), str)
|
|
465
|
+
)
|
|
466
|
+
raise _InvocationError(self._error_namespace if identifiable else None)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def main(argv: list[str] | None = None) -> int:
|
|
470
|
+
started_at = time.monotonic()
|
|
471
|
+
parent_pid = os.getppid()
|
|
472
|
+
parser = _ArgumentParser(prog="clink", description="One bounded text-generation attempt.")
|
|
473
|
+
parser.add_argument(
|
|
474
|
+
"--consumer-config-dir", help="Bind an independent generation-only canonical configuration directory."
|
|
475
|
+
)
|
|
476
|
+
subcommands = parser.add_subparsers(dest="command", required=True, parser_class=_ArgumentParser)
|
|
477
|
+
generate = subcommands.add_parser(
|
|
478
|
+
"generate", help="Read one v1 or explicit native v2/v3 generation request from stdin."
|
|
479
|
+
)
|
|
480
|
+
generate.add_argument("--request-json", required=True, choices=("-",))
|
|
481
|
+
generate.add_argument("--consumer-config-dir", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
482
|
+
allow = subcommands.add_parser("allow", help="Approve and remember a directory for an operator-configured CLI.")
|
|
483
|
+
allow.set_defaults(command="allow")
|
|
484
|
+
allow.add_argument("path")
|
|
485
|
+
allow.add_argument("--cli", required=True)
|
|
486
|
+
allow.add_argument("--project", help="Explicit server project binding to reproduce.")
|
|
487
|
+
allow.add_argument("--consumer-config-dir", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
488
|
+
allow.add_argument("--json", action="store_true", help="Write one schema-one directory grant result to stdout.")
|
|
489
|
+
allow.add_argument("--yes", action="store_true", help="Explicitly preapprove without a terminal question.")
|
|
490
|
+
capabilities = subcommands.add_parser(
|
|
491
|
+
"generation-capabilities", help="Read generation admission without invoking a CLI."
|
|
492
|
+
)
|
|
493
|
+
capabilities.add_argument("--json", required=True, action="store_true")
|
|
494
|
+
capabilities.add_argument("--cli", choices=("codex", "claude", "agy", "grok"))
|
|
495
|
+
capabilities.add_argument("--executable", help="Read one absolute executable identity without starting it.")
|
|
496
|
+
try:
|
|
497
|
+
args = parser.parse_args(argv)
|
|
498
|
+
if args.command == "generation-capabilities":
|
|
499
|
+
from clink.generation_capabilities import generation_capabilities
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
result = asyncio.run(_supervise(generation_capabilities(args.cli, args.executable), parent_pid))
|
|
503
|
+
if not isinstance(result, dict):
|
|
504
|
+
return 1
|
|
505
|
+
encoded = canonical_json(result) + b"\n"
|
|
506
|
+
if len(encoded) > 65536:
|
|
507
|
+
raise ValueError
|
|
508
|
+
sys.stdout.buffer.write(encoded)
|
|
509
|
+
sys.stdout.buffer.flush()
|
|
510
|
+
return 0
|
|
511
|
+
except (ValueError, OSError, KeyboardInterrupt, asyncio.CancelledError):
|
|
512
|
+
print("clink generation-capabilities: Invalid or interrupted read-only preflight.", file=sys.stderr)
|
|
513
|
+
return 2
|
|
514
|
+
if args.command == "allow" and args.consumer_config_dir is not None and args.project is not None:
|
|
515
|
+
if args.json:
|
|
516
|
+
try:
|
|
517
|
+
target = str(canonical_directory(args.path))
|
|
518
|
+
except EnrollmentError:
|
|
519
|
+
target = args.path
|
|
520
|
+
_allow_result(args, target, "failed", False, "invalid_invocation")
|
|
521
|
+
print("clink allow: --consumer-config-dir and --project are mutually exclusive.", file=sys.stderr)
|
|
522
|
+
return 1
|
|
523
|
+
parser.error("--consumer-config-dir and --project are mutually exclusive")
|
|
524
|
+
if args.command == "allow":
|
|
525
|
+
try:
|
|
526
|
+
return asyncio.run(_supervise(_allow(args), parent_pid))
|
|
527
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
528
|
+
print("Directory approval was cancelled; inspect configuration before retrying.", file=sys.stderr)
|
|
529
|
+
return 1
|
|
530
|
+
except Exception:
|
|
531
|
+
print(
|
|
532
|
+
"clink allow: Enrollment failed internally. Inspect operator configuration before retrying.",
|
|
533
|
+
file=sys.stderr,
|
|
534
|
+
)
|
|
535
|
+
return 1
|
|
536
|
+
# Bind before touching stdin. Broken consumer configuration must never wait
|
|
537
|
+
# on an open request pipe or fall back to unrelated operator configuration.
|
|
538
|
+
registry = (
|
|
539
|
+
ClinkRegistry(consumer_config_dir=args.consumer_config_dir)
|
|
540
|
+
if args.consumer_config_dir is not None
|
|
541
|
+
else None
|
|
542
|
+
)
|
|
543
|
+
result = asyncio.run(run_generate(started_at, parent_pid, sys.stdin.fileno(), registry))
|
|
544
|
+
except GenerationInputError as exc:
|
|
545
|
+
if isinstance(exc, _InvocationError) and exc.allow_args is not None:
|
|
546
|
+
try:
|
|
547
|
+
target = str(canonical_directory(exc.allow_args.path))
|
|
548
|
+
except EnrollmentError:
|
|
549
|
+
target = exc.allow_args.path
|
|
550
|
+
_allow_result(exc.allow_args, target, "failed", False, exc.code)
|
|
551
|
+
print(f"clink allow: {exc}", file=sys.stderr)
|
|
552
|
+
return 1
|
|
553
|
+
result = terminal_result(started_at=started_at, code=exc.code, message=str(exc))
|
|
554
|
+
except RegistryLoadError as exc:
|
|
555
|
+
print(f"clink generate: {exc}", file=sys.stderr)
|
|
556
|
+
result = terminal_result(started_at=started_at, code="invalid_config", message=str(exc)[:1024])
|
|
557
|
+
except KeyboardInterrupt:
|
|
558
|
+
result = terminal_result(
|
|
559
|
+
started_at=started_at, status="cancelled", code="cancelled", message="Generation was cancelled."
|
|
560
|
+
)
|
|
561
|
+
except Exception:
|
|
562
|
+
result = terminal_result(started_at=started_at, code="internal_error", message="Generation failed internally.")
|
|
563
|
+
try:
|
|
564
|
+
# Publication follows private resource, pool and event-loop shutdown.
|
|
565
|
+
result["duration_ms"] = max(0, int((time.monotonic() - started_at) * 1000))
|
|
566
|
+
encoded = canonical_json(result) + b"\n"
|
|
567
|
+
if len(encoded) > RESULT_BYTES:
|
|
568
|
+
make_result = (
|
|
569
|
+
partial(terminal_native_result, schema_version=result["schema_version"])
|
|
570
|
+
if result["schema_version"] in (2, 3)
|
|
571
|
+
else terminal_result
|
|
572
|
+
)
|
|
573
|
+
result = make_result(
|
|
574
|
+
started_at=started_at, code="output_limit", message="The serialized result exceeds its limit."
|
|
575
|
+
)
|
|
576
|
+
encoded = canonical_json(result) + b"\n"
|
|
577
|
+
sys.stdout.buffer.write(encoded)
|
|
578
|
+
sys.stdout.buffer.flush()
|
|
579
|
+
except (BrokenPipeError, OSError):
|
|
580
|
+
# All owned work is already cleaned up before writing to the possibly absent caller.
|
|
581
|
+
with contextlib.suppress(OSError):
|
|
582
|
+
# Python may retry a buffered flush during interpreter shutdown.
|
|
583
|
+
# Keep that retry away from the absent caller and preserve our exit code.
|
|
584
|
+
discard = os.open(os.devnull, os.O_WRONLY)
|
|
585
|
+
try:
|
|
586
|
+
os.dup2(discard, sys.stdout.fileno())
|
|
587
|
+
finally:
|
|
588
|
+
os.close(discard)
|
|
589
|
+
return 1
|
|
590
|
+
return result_exit_code(result)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
if __name__ == "__main__":
|
|
594
|
+
raise SystemExit(main())
|
clink/constants.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Internal defaults and constants for clink."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULT_TIMEOUT_SECONDS = 1800
|
|
9
|
+
DEFAULT_STREAM_LIMIT = 10 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
BUILTIN_PROMPTS_DIR = PROJECT_ROOT / "systemprompts" / "clink"
|
|
13
|
+
CONFIG_DIR = PROJECT_ROOT / "conf" / "cli_clients"
|
|
14
|
+
LEGACY_USER_CONFIG_DIR = Path.home() / ".pal" / "cli_clients"
|
|
15
|
+
USER_CONFIG_DIR = Path.home() / ".clink" / "cli_clients"
|
|
16
|
+
|
|
17
|
+
CHILD_ENV_ALLOWLIST = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"PATH",
|
|
20
|
+
"HOME",
|
|
21
|
+
"USER",
|
|
22
|
+
"LOGNAME",
|
|
23
|
+
"TMPDIR",
|
|
24
|
+
"TMP",
|
|
25
|
+
"TEMP",
|
|
26
|
+
"LANG",
|
|
27
|
+
"LC_ALL",
|
|
28
|
+
"LC_CTYPE",
|
|
29
|
+
"TZ",
|
|
30
|
+
"CODEX_HOME",
|
|
31
|
+
"CLAUDE_CONFIG_DIR",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CLIInternalDefaults:
|
|
38
|
+
"""Adapter-owned defaults, never an operator permission override mechanism."""
|
|
39
|
+
|
|
40
|
+
parser: str
|
|
41
|
+
additional_args: tuple[str, ...] = ()
|
|
42
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
43
|
+
default_role_prompt: str | None = None
|
|
44
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
|
|
45
|
+
runner: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Availability and the complete launch policy belong to the backend's verified adapter contract.
|
|
49
|
+
INTERNAL_DEFAULTS: dict[str, CLIInternalDefaults] = {
|
|
50
|
+
"gemini": CLIInternalDefaults(
|
|
51
|
+
parser="gemini_json",
|
|
52
|
+
additional_args=("-o", "json"),
|
|
53
|
+
default_role_prompt=str(BUILTIN_PROMPTS_DIR / "default.txt"),
|
|
54
|
+
runner="gemini",
|
|
55
|
+
),
|
|
56
|
+
"codex": CLIInternalDefaults(
|
|
57
|
+
parser="codex_jsonl",
|
|
58
|
+
additional_args=("exec",),
|
|
59
|
+
default_role_prompt=str(BUILTIN_PROMPTS_DIR / "default.txt"),
|
|
60
|
+
runner="codex",
|
|
61
|
+
),
|
|
62
|
+
"claude": CLIInternalDefaults(
|
|
63
|
+
parser="claude_json",
|
|
64
|
+
additional_args=("--print", "--output-format", "json"),
|
|
65
|
+
default_role_prompt=str(BUILTIN_PROMPTS_DIR / "default.txt"),
|
|
66
|
+
runner="claude",
|
|
67
|
+
),
|
|
68
|
+
"agy": CLIInternalDefaults(
|
|
69
|
+
parser="agy_json",
|
|
70
|
+
default_role_prompt=str(BUILTIN_PROMPTS_DIR / "default.txt"),
|
|
71
|
+
runner="agy",
|
|
72
|
+
),
|
|
73
|
+
"grok": CLIInternalDefaults(
|
|
74
|
+
parser="grok_json",
|
|
75
|
+
default_role_prompt=str(BUILTIN_PROMPTS_DIR / "default.txt"),
|
|
76
|
+
runner="grok",
|
|
77
|
+
),
|
|
78
|
+
}
|