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