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/registry.py ADDED
@@ -0,0 +1,544 @@
1
+ """Atomically validate operator-owned CLI manifests without launching CLI processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shlex
9
+ import stat
10
+ from collections.abc import Iterable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from pydantic import ValidationError
15
+
16
+ from clink.constants import (
17
+ CHILD_ENV_ALLOWLIST,
18
+ CONFIG_DIR,
19
+ INTERNAL_DEFAULTS,
20
+ LEGACY_USER_CONFIG_DIR,
21
+ USER_CONFIG_DIR,
22
+ CLIInternalDefaults,
23
+ )
24
+ from clink.models import (
25
+ CLIClientConfig,
26
+ CLIRoleConfig,
27
+ ConsumerCLIConfig,
28
+ ResolvedCLIClient,
29
+ ResolvedCLIRole,
30
+ ResolvedGenerationNative,
31
+ ResolvedNativeTask,
32
+ )
33
+ from clink.native_policy import NATIVE_ADAPTERS
34
+ from utils.env import get_env
35
+
36
+ logger = logging.getLogger("clink.registry")
37
+
38
+ CONFIG_ENV_VAR = "CLI_CLIENTS_CONFIG_PATH"
39
+ MAX_PROMPT_BYTES = 1024 * 1024
40
+ MAX_CONFIG_BYTES = 1024 * 1024
41
+ _INFERRED_ADAPTERS = frozenset({"codex", "claude", "gemini"})
42
+ _SAFE_EXTRA_ARGS = {"claude": frozenset({"--verbose"})}
43
+
44
+
45
+ def _reject_constant(_value: str) -> None:
46
+ raise ValueError("non-JSON numeric constant")
47
+
48
+
49
+ class RegistryLoadError(RuntimeError):
50
+ """Configuration failed validation; existing registry state is retained."""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ManifestSource:
55
+ """Private provenance for the winning manifest in a validated snapshot."""
56
+
57
+ path: Path
58
+ content: bytes
59
+ identity: tuple[int, ...]
60
+
61
+
62
+ class ClinkRegistry:
63
+ """Resolve a strict version-one configuration snapshot before publishing it."""
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ project_dir: Path | None = None,
69
+ consumer_config_dir: str | Path | None = None,
70
+ _consumer_identity: tuple[int, int] | None = None,
71
+ ) -> None:
72
+ if project_dir is not None and consumer_config_dir is not None:
73
+ raise RegistryLoadError("--consumer-config-dir and --project are mutually exclusive")
74
+ self._consumer_config_dir = (
75
+ self._canonical_consumer_directory(consumer_config_dir) if consumer_config_dir is not None else None
76
+ )
77
+ if _consumer_identity is not None and self._consumer_config_dir is None:
78
+ raise RegistryLoadError("Consumer identity requires an existing consumer binding")
79
+ self._consumer_identity = _consumer_identity
80
+ if self._consumer_config_dir is not None and self._consumer_identity is None:
81
+ self._consumer_identity = self._directory_identity(self._consumer_config_dir)
82
+ self._startup_cwd = Path.cwd().resolve(strict=True)
83
+ self._project_dir = (
84
+ self._resolve_directory(str(project_dir), self._startup_cwd, "project_dir")
85
+ if project_dir is not None
86
+ else None
87
+ )
88
+ self._clients: dict[str, ResolvedCLIClient] = {}
89
+ self._sources: dict[str, ManifestSource] = {}
90
+ self._load()
91
+
92
+ @property
93
+ def project_dir(self) -> Path | None:
94
+ """Return the canonical server binding captured before the first snapshot."""
95
+ return self._project_dir
96
+
97
+ @property
98
+ def consumer_config_dir(self) -> Path | None:
99
+ """Return the immutable consumer binding, independent of all ordinary sources."""
100
+ return self._consumer_config_dir
101
+
102
+ @property
103
+ def consumer_config_identity(self) -> tuple[int, int] | None:
104
+ """Keep the original directory identity when constructing replacement snapshots."""
105
+ return self._consumer_identity
106
+
107
+ @staticmethod
108
+ def _canonical_consumer_directory(value: str | Path) -> Path:
109
+ try:
110
+ spelling = os.fspath(value)
111
+ path = Path(spelling)
112
+ if not path.is_absolute() or spelling != str(path.resolve(strict=True)) or not path.is_dir():
113
+ raise ValueError
114
+ return path
115
+ except (OSError, RuntimeError, ValueError):
116
+ raise RegistryLoadError("consumer_config_dir must be an absolute existing canonical directory") from None
117
+
118
+ @staticmethod
119
+ def _directory_identity(path: Path) -> tuple[int, int]:
120
+ try:
121
+ info = path.stat()
122
+ return info.st_dev, info.st_ino
123
+ except OSError:
124
+ raise RegistryLoadError("Consumer configuration directory is unavailable") from None
125
+
126
+ def _check_consumer_binding(self) -> None:
127
+ if self._consumer_config_dir is not None:
128
+ path = self._canonical_consumer_directory(self._consumer_config_dir)
129
+ if self._directory_identity(path) != self._consumer_identity:
130
+ raise RegistryLoadError("Consumer configuration directory changed after binding")
131
+
132
+ def _load(self) -> None:
133
+ self._check_consumer_binding()
134
+ clients: dict[str, ResolvedCLIClient] = {}
135
+ provenance: dict[str, ManifestSource] = {}
136
+ for source, required, deprecated in self._config_sources():
137
+ source_clients: dict[str, ResolvedCLIClient] = {}
138
+ paths = tuple(self._source_files(source, required=required))
139
+ if deprecated and paths:
140
+ logger.warning(
141
+ "Legacy CLI configuration directory %s is deprecated; migrate to %s", source, USER_CONFIG_DIR
142
+ )
143
+ for config_path in paths:
144
+ data, manifest_source = self._read_manifest(config_path)
145
+ if "schema_version" not in data and self._consumer_config_dir is None:
146
+ logger.warning(
147
+ "Legacy CLI manifest %s has no schema_version; migrate to schema_version 1", config_path
148
+ )
149
+ if self._consumer_config_dir is not None:
150
+ configuration_scope = "consumer"
151
+ elif required:
152
+ configuration_scope = "explicit"
153
+ elif deprecated:
154
+ configuration_scope = "legacy_user"
155
+ elif source == CONFIG_DIR:
156
+ configuration_scope = "bundled"
157
+ elif source == USER_CONFIG_DIR:
158
+ configuration_scope = "user"
159
+ elif self._project_dir is not None and source == self._project_dir / ".clink" / "cli_clients":
160
+ configuration_scope = "project"
161
+ else:
162
+ configuration_scope = "unknown"
163
+ resolved = self.validate_manifest(
164
+ data, source_path=config_path, configuration_scope=configuration_scope
165
+ )
166
+ key = resolved.name.lower()
167
+ if key in source_clients:
168
+ raise RegistryLoadError(
169
+ f"Duplicate CLI name '{resolved.name}' within configuration source {source}"
170
+ )
171
+ source_clients[key] = resolved
172
+ provenance[key] = manifest_source
173
+ clients.update(source_clients)
174
+ if not clients and self._consumer_config_dir is not None:
175
+ raise RegistryLoadError(
176
+ "No consumer CLI manifests configured; bootstrap an intended generation family first"
177
+ )
178
+ if not clients:
179
+ raise RegistryLoadError(f"No CLI clients configured; add a manifest or set {CONFIG_ENV_VAR}")
180
+ self._clients = clients
181
+ self._sources = provenance
182
+
183
+ def reload(self) -> None:
184
+ """Validate a complete replacement snapshot; ordinary v1 operation reloads on restart."""
185
+ self._load()
186
+
187
+ def list_clients(self) -> list[str]:
188
+ return sorted(client.name for client in self._clients.values())
189
+
190
+ def list_roles(self, cli_name: str) -> list[str]:
191
+ return sorted(self.get_client(cli_name).roles)
192
+
193
+ def get_client(self, cli_name: str) -> ResolvedCLIClient:
194
+ key = cli_name.lower()
195
+ if key not in self._clients:
196
+ available = ", ".join(self.list_clients())
197
+ raise KeyError(f"CLI '{cli_name}' is not configured. Available clients: {available}")
198
+ return self._clients[key]
199
+
200
+ def manifest_source(self, cli_name: str) -> ManifestSource:
201
+ """Return enrollment provenance without expanding public discovery fields."""
202
+ self.get_client(cli_name)
203
+ return self._sources[cli_name.lower()]
204
+
205
+ def _config_sources(self) -> Iterable[tuple[Path, bool, bool]]:
206
+ if self._consumer_config_dir is not None:
207
+ yield self._consumer_config_dir, True, False
208
+ return
209
+ yield CONFIG_DIR, False, False
210
+ yield LEGACY_USER_CONFIG_DIR, False, True
211
+ yield USER_CONFIG_DIR, False, False
212
+ if self._project_dir is not None:
213
+ yield self._project_dir / ".clink" / "cli_clients", False, False
214
+ explicit = get_env(CONFIG_ENV_VAR)
215
+ if explicit:
216
+ yield self._resolve_path(explicit, self._startup_cwd), True, False
217
+
218
+ def _iter_config_files(self) -> Iterable[Path]:
219
+ """Retain the legacy inspection helper with version-one precedence."""
220
+ for source, required, _deprecated in self._config_sources():
221
+ yield from self._source_files(source, required=required)
222
+
223
+ def _source_files(self, source: Path, *, required: bool) -> Iterable[Path]:
224
+ try:
225
+ if self._consumer_config_dir is not None:
226
+ self._check_consumer_binding()
227
+ for path in sorted(source.iterdir()):
228
+ if path.suffix.lower() == ".json":
229
+ if not stat.S_ISREG(path.lstat().st_mode):
230
+ raise RegistryLoadError(
231
+ f"Consumer configuration must be a confined regular JSON file: {path}"
232
+ )
233
+ yield path
234
+ return
235
+ if source.is_file():
236
+ if source.suffix.lower() != ".json":
237
+ raise RegistryLoadError(f"Configuration source must be a JSON file or directory: {source}")
238
+ yield source.resolve(strict=True)
239
+ elif source.is_dir():
240
+ for path in sorted(source.iterdir()):
241
+ if path.suffix.lower() == ".json" and path.is_file():
242
+ yield path.resolve(strict=True)
243
+ elif required or source.exists():
244
+ raise RegistryLoadError(f"Configuration source does not exist or is not a file/directory: {source}")
245
+ except OSError as exc:
246
+ raise RegistryLoadError(f"Cannot read configuration source {source}: {exc.strerror}") from None
247
+
248
+ def _read_manifest(self, source_path: Path) -> tuple[dict, ManifestSource]:
249
+ try:
250
+ flags = os.O_RDONLY
251
+ if os.name == "posix":
252
+ flags |= os.O_NOFOLLOW | os.O_NONBLOCK
253
+ if self._consumer_config_dir is not None:
254
+ self._check_consumer_binding()
255
+ if source_path.parent != self._consumer_config_dir:
256
+ raise RegistryLoadError("Consumer manifest escaped the bound directory")
257
+ if os.name != "posix":
258
+ raise RegistryLoadError("Consumer configuration requires POSIX no-follow file operations")
259
+ parent_fd = os.open(self._consumer_config_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
260
+ try:
261
+ parent_info = os.fstat(parent_fd)
262
+ if (parent_info.st_dev, parent_info.st_ino) != self._consumer_identity:
263
+ raise RegistryLoadError("Consumer configuration directory changed while reading")
264
+ descriptor = os.open(source_path.name, flags, dir_fd=parent_fd)
265
+ finally:
266
+ os.close(parent_fd)
267
+ else:
268
+ descriptor = os.open(source_path, flags)
269
+ with os.fdopen(descriptor, "rb") as stream:
270
+ info = os.fstat(stream.fileno())
271
+ if not stat.S_ISREG(info.st_mode):
272
+ raise RegistryLoadError(f"Configuration is not a regular file: {source_path}")
273
+ content = stream.read(MAX_CONFIG_BYTES + 1)
274
+ after = os.fstat(stream.fileno())
275
+ identity = (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
276
+ if identity != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns):
277
+ raise RegistryLoadError(f"Configuration changed while reading: {source_path}")
278
+ self._check_consumer_binding()
279
+ if len(content) > MAX_CONFIG_BYTES:
280
+ raise RegistryLoadError(f"Configuration file exceeds {MAX_CONFIG_BYTES} bytes: {source_path}")
281
+ data = json.loads(
282
+ content,
283
+ object_pairs_hook=self._unique_json_object,
284
+ parse_constant=_reject_constant,
285
+ )
286
+ except RegistryLoadError as exc:
287
+ raise RegistryLoadError(f"{exc} ({source_path})") from None
288
+ except (OSError, UnicodeError, ValueError, RecursionError) as exc:
289
+ # Do not include JSON source excerpts or arbitrary error values in diagnostics.
290
+ raise RegistryLoadError(
291
+ f"Cannot read valid JSON configuration: {source_path} ({type(exc).__name__})"
292
+ ) from None
293
+ if not isinstance(data, dict):
294
+ raise RegistryLoadError(f"Configuration must be a JSON object: {source_path}")
295
+ return data, ManifestSource(source_path, content, identity)
296
+
297
+ @staticmethod
298
+ def _unique_json_object(pairs: list[tuple[str, object]]) -> dict:
299
+ value: dict = {}
300
+ for key, item in pairs:
301
+ if key in value:
302
+ raise RegistryLoadError("Duplicate JSON field in CLI configuration")
303
+ value[key] = item
304
+ return value
305
+
306
+ def validate_manifest(
307
+ self, data: dict, *, source_path: Path, configuration_scope: str = "unknown"
308
+ ) -> ResolvedCLIClient:
309
+ """Validate both initial and enrollment candidate manifests under the bound schema."""
310
+ try:
311
+ config = (
312
+ ConsumerCLIConfig.model_validate(data)
313
+ if self._consumer_config_dir is not None
314
+ else CLIClientConfig.model_validate(data)
315
+ )
316
+ except ValidationError as exc:
317
+ details = "; ".join(
318
+ f"{'.'.join(str(part) for part in error['loc']) or 'manifest'} ({error['type']})"
319
+ for error in exc.errors(include_input=False)
320
+ )
321
+ raise RegistryLoadError(f"Invalid configuration in {source_path}: {details}") from None
322
+ if isinstance(config, ConsumerCLIConfig):
323
+ if source_path != self._consumer_config_dir / f"{config.name}.json":
324
+ raise RegistryLoadError(f"Consumer manifest filename must match its CLI: {source_path}")
325
+ try:
326
+ command = shlex.split(config.command, posix=True)
327
+ except ValueError:
328
+ raise RegistryLoadError(f"Invalid consumer executable quoting in {source_path}") from None
329
+ if (
330
+ len(command) != 1
331
+ or not Path(command[0]).is_absolute()
332
+ or any(ord(char) < 32 or ord(char) == 127 for char in config.command)
333
+ ):
334
+ raise RegistryLoadError(f"Consumer command must name one quoted absolute executable in {source_path}")
335
+ roots = []
336
+ for root in config.allowed_roots:
337
+ resolved = self._resolve_directory(root, source_path.parent, "allowed_roots")
338
+ if root != str(resolved):
339
+ raise RegistryLoadError(
340
+ f"Consumer allowed_roots must be canonical absolute directories in {source_path}"
341
+ )
342
+ roots.append(resolved)
343
+ defaults = INTERNAL_DEFAULTS[config.adapter]
344
+ return ResolvedCLIClient(
345
+ name=config.name,
346
+ adapter=config.adapter,
347
+ executable=tuple(command),
348
+ working_dir=None,
349
+ allowed_roots=tuple(dict.fromkeys(roots)),
350
+ allowed_roots_explicit=True,
351
+ configuration_scope="consumer",
352
+ timeout_seconds=defaults.timeout_seconds,
353
+ parser=defaults.parser,
354
+ runner=defaults.runner,
355
+ roles={},
356
+ generation_native=ResolvedGenerationNative(
357
+ inherit_env=tuple(config.generation_native.inherit_env),
358
+ required_env=tuple(config.generation_native.required_env),
359
+ ),
360
+ )
361
+ return self._resolve_config(config, source_path=source_path, configuration_scope=configuration_scope)
362
+
363
+ def _resolve_config(
364
+ self, raw: CLIClientConfig, *, source_path: Path, configuration_scope: str = "unknown"
365
+ ) -> ResolvedCLIClient:
366
+ adapter = raw.adapter
367
+ if adapter is None and raw.name.lower() in _INFERRED_ADAPTERS:
368
+ adapter = raw.name.lower()
369
+ if adapter not in INTERNAL_DEFAULTS:
370
+ raise RegistryLoadError(f"CLI '{raw.name}' requires a registered adapter in {source_path}")
371
+ defaults = INTERNAL_DEFAULTS[adapter]
372
+ executable = self._resolve_executable(raw, adapter, source_path)
373
+ self._validate_args(raw.additional_args, adapter, "additional_args", source_path)
374
+ env = self._merge_env(raw, defaults)
375
+ working_dir = (
376
+ self._resolve_directory(raw.working_dir, source_path.parent, "working_dir")
377
+ if raw.working_dir is not None
378
+ else self._project_dir or self._startup_cwd
379
+ )
380
+ if raw.allowed_roots is None:
381
+ roots = (working_dir,)
382
+ else:
383
+ if not raw.allowed_roots:
384
+ raise RegistryLoadError(f"allowed_roots must contain at least one directory in {source_path}")
385
+ roots = tuple(
386
+ dict.fromkeys(
387
+ self._resolve_directory(root, source_path.parent, "allowed_roots") for root in raw.allowed_roots
388
+ )
389
+ )
390
+ if not any(working_dir == root or root in working_dir.parents for root in roots):
391
+ raise RegistryLoadError(f"working_dir is outside allowed_roots in {source_path}")
392
+ native_task = None
393
+ if raw.native_task is not None:
394
+ if adapter not in NATIVE_ADAPTERS:
395
+ raise RegistryLoadError(f"This adapter has no native task profile contract in {source_path}")
396
+ native_roles = raw.native_task.roles
397
+ if raw.native_task.enabled and "default" not in native_roles:
398
+ raise RegistryLoadError(f"An enabled native task profile requires a default role in {source_path}")
399
+ native_task = ResolvedNativeTask(
400
+ enabled=raw.native_task.enabled,
401
+ inherit_env=tuple(raw.native_task.inherit_env),
402
+ required_env=tuple(raw.native_task.required_env),
403
+ roles=self._resolve_role_mapping(native_roles, defaults, source_path, adapter, native=True),
404
+ default_model=raw.native_task.default_model,
405
+ )
406
+ generation_native = None
407
+ if raw.generation_native is not None:
408
+ if adapter not in {"codex", "claude", "agy", "grok"}:
409
+ raise RegistryLoadError(
410
+ f"Native generation supports only Codex, Claude, Antigravity and Grok adapters in {source_path}"
411
+ )
412
+ generation_native = ResolvedGenerationNative(
413
+ inherit_env=tuple(raw.generation_native.inherit_env),
414
+ required_env=tuple(raw.generation_native.required_env),
415
+ )
416
+ return ResolvedCLIClient(
417
+ name=raw.name,
418
+ adapter=adapter,
419
+ executable=executable,
420
+ working_dir=working_dir,
421
+ allowed_roots=roots,
422
+ allowed_roots_explicit=raw.allowed_roots is not None,
423
+ configuration_scope=configuration_scope,
424
+ internal_args=defaults.additional_args,
425
+ config_args=tuple(raw.additional_args),
426
+ env=env,
427
+ timeout_seconds=raw.timeout_seconds or defaults.timeout_seconds,
428
+ parser=defaults.parser,
429
+ runner=defaults.runner,
430
+ roles=self._resolve_roles(raw, defaults, source_path, adapter),
431
+ default_model=raw.default_model,
432
+ native_task=native_task,
433
+ generation_native=generation_native,
434
+ )
435
+
436
+ def _resolve_executable(self, raw: CLIClientConfig, adapter: str, source_path: Path) -> tuple[str, ...]:
437
+ if not raw.command or not raw.command.strip() or "\x00" in raw.command:
438
+ raise RegistryLoadError(f"CLI '{raw.name}' must specify a nonempty command in {source_path}")
439
+ try:
440
+ command = shlex.split(raw.command, posix=True)
441
+ except ValueError:
442
+ raise RegistryLoadError(f"Invalid command quoting in {source_path}") from None
443
+ if not command or not command[0]:
444
+ raise RegistryLoadError(f"CLI '{raw.name}' must specify a nonempty command in {source_path}")
445
+ self._validate_args(command[1:], adapter, "command arguments", source_path)
446
+ executable = command[0]
447
+ if "/" in executable or executable.startswith((".", "~")):
448
+ executable = str(self._resolve_path(executable, source_path.parent))
449
+ return (executable, *command[1:])
450
+
451
+ @staticmethod
452
+ def _validate_args(arguments: Iterable[str], adapter: str, label: str, source_path: Path) -> None:
453
+ allowed = _SAFE_EXTRA_ARGS.get(adapter, frozenset())
454
+ if any(argument not in allowed for argument in arguments):
455
+ raise RegistryLoadError(f"Unsafe or unsupported {label} in {source_path}; use structured model fields")
456
+
457
+ @staticmethod
458
+ def _merge_env(raw: CLIClientConfig, defaults: CLIInternalDefaults) -> dict[str, str]:
459
+ merged = dict(defaults.env)
460
+ merged.update(raw.env)
461
+ for key, value in merged.items():
462
+ if key not in CHILD_ENV_ALLOWLIST:
463
+ raise RegistryLoadError(f"Environment key '{key}' is not permitted for CLI '{raw.name}'")
464
+ if "\x00" in value:
465
+ raise RegistryLoadError(f"Environment key '{key}' contains an invalid value for CLI '{raw.name}'")
466
+ return merged
467
+
468
+ def _resolve_roles(
469
+ self, raw: CLIClientConfig, defaults: CLIInternalDefaults, source_path: Path, adapter: str
470
+ ) -> dict[str, ResolvedCLIRole]:
471
+ roles = dict(raw.roles)
472
+ roles.setdefault("default", CLIRoleConfig())
473
+ return self._resolve_role_mapping(roles, defaults, source_path, adapter)
474
+
475
+ def _resolve_role_mapping(
476
+ self,
477
+ roles: dict[str, CLIRoleConfig],
478
+ defaults: CLIInternalDefaults,
479
+ source_path: Path,
480
+ adapter: str,
481
+ *,
482
+ native: bool = False,
483
+ ) -> dict[str, ResolvedCLIRole]:
484
+ resolved: dict[str, ResolvedCLIRole] = {}
485
+ for name, config in roles.items():
486
+ if native and (config.role_args or config.prompt_path is None):
487
+ raise RegistryLoadError(f"Native roles require prompt_path and no role_args in {source_path}")
488
+ self._validate_args(config.role_args, adapter, f"role_args for role '{name}'", source_path)
489
+ candidate = config.prompt_path if config.prompt_path is not None else defaults.default_role_prompt
490
+ if not candidate:
491
+ raise RegistryLoadError(f"Role '{name}' requires prompt_path in {source_path}")
492
+ prompt_path = self._resolve_path(candidate, source_path.parent)
493
+ try:
494
+ if not prompt_path.is_file():
495
+ raise RegistryLoadError(f"prompt_path is not an existing regular file: {prompt_path}")
496
+ with prompt_path.open("rb") as stream:
497
+ content = stream.read(MAX_PROMPT_BYTES + 1)
498
+ if len(content) > MAX_PROMPT_BYTES:
499
+ raise RegistryLoadError(f"prompt_path exceeds {MAX_PROMPT_BYTES} bytes: {prompt_path}")
500
+ prompt_text = content.decode("utf-8")
501
+ except (OSError, UnicodeError, ValueError) as exc:
502
+ raise RegistryLoadError(f"Cannot read UTF-8 prompt_path {prompt_path} ({type(exc).__name__})") from None
503
+ resolved[name] = ResolvedCLIRole(
504
+ name=name,
505
+ prompt_path=prompt_path,
506
+ prompt_text=prompt_text,
507
+ role_args=tuple(config.role_args),
508
+ description=config.description,
509
+ model=config.model,
510
+ )
511
+ return resolved
512
+
513
+ def _resolve_directory(self, candidate: str, base_dir: Path, label: str) -> Path:
514
+ if not candidate:
515
+ raise RegistryLoadError(f"{label} must be a nonempty directory path")
516
+ try:
517
+ path = self._resolve_path(candidate, base_dir).resolve(strict=True)
518
+ if not path.is_dir():
519
+ raise RegistryLoadError(f"{label} is not a directory: {path}")
520
+ return path
521
+ except (OSError, ValueError, RuntimeError):
522
+ raise RegistryLoadError(f"{label} must be an existing real directory") from None
523
+
524
+ @staticmethod
525
+ def _resolve_path(candidate: str, base_dir: Path) -> Path:
526
+ try:
527
+ path = Path(candidate).expanduser()
528
+ return (path if path.is_absolute() else base_dir / path).resolve()
529
+ except (OSError, ValueError, RuntimeError):
530
+ raise RegistryLoadError("Invalid configuration path") from None
531
+
532
+
533
+ _REGISTRY: ClinkRegistry | None = None
534
+
535
+
536
+ def get_registry(*, project_dir: Path | None = None) -> ClinkRegistry:
537
+ global _REGISTRY
538
+ if _REGISTRY is None:
539
+ _REGISTRY = ClinkRegistry(project_dir=project_dir)
540
+ elif project_dir is not None:
541
+ requested = _REGISTRY._resolve_directory(str(project_dir), Path.cwd(), "project_dir")
542
+ if requested != _REGISTRY.project_dir:
543
+ raise RegistryLoadError("CLI registry already has a different project binding; restart to change --project")
544
+ return _REGISTRY