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/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
+ }