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/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Public helpers for clink components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from .registry import ClinkRegistry, get_registry
9
+
10
+ __all__ = ["ClinkRegistry", "get_registry"]
11
+
12
+
13
+ def __getattr__(name: str) -> Any:
14
+ """Keep setup imports free of runtime dotenv and registry initialization."""
15
+ if name in __all__:
16
+ from .registry import ClinkRegistry, get_registry
17
+
18
+ return {"ClinkRegistry": ClinkRegistry, "get_registry": get_registry}[name]
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,30 @@
1
+ """Agent factory for clink CLI integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from clink.models import ResolvedCLIClient
6
+
7
+ from .base import AgentOutput, BaseCLIAgent, CLIAgentError
8
+ from .claude import ClaudeAgent
9
+ from .codex import CodexAgent
10
+ from .gemini import GeminiAgent
11
+
12
+ _AGENTS: dict[str, type[BaseCLIAgent]] = {
13
+ "gemini": GeminiAgent,
14
+ "codex": CodexAgent,
15
+ "claude": ClaudeAgent,
16
+ }
17
+
18
+
19
+ def create_agent(client: ResolvedCLIClient) -> BaseCLIAgent:
20
+ agent_key = (client.runner or client.name).lower()
21
+ agent_cls = _AGENTS.get(agent_key, BaseCLIAgent)
22
+ return agent_cls(client)
23
+
24
+
25
+ __all__ = [
26
+ "AgentOutput",
27
+ "BaseCLIAgent",
28
+ "CLIAgentError",
29
+ "create_agent",
30
+ ]
clink/agents/base.py ADDED
@@ -0,0 +1,70 @@
1
+ """Legacy agent facade; every execution uses the shared advisory backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+
8
+ from clink.contracts import BackendError, CLIRequest
9
+ from clink.models import ResolvedCLIClient, ResolvedCLIRole
10
+ from clink.parsers import ParsedCLIResponse
11
+
12
+
13
+ @dataclass
14
+ class AgentOutput:
15
+ """Compatibility response without raw streams, commands or native sessions."""
16
+
17
+ parsed: ParsedCLIResponse
18
+ sanitized_command: list[str]
19
+ returncode: int
20
+ stdout: str
21
+ stderr: str
22
+ duration_seconds: float
23
+ parser_name: str
24
+ output_file_content: str | None = None
25
+
26
+
27
+ class CLIAgentError(BackendError):
28
+ """Compatibility name for explicit backend errors."""
29
+
30
+
31
+ class BaseCLIAgent:
32
+ """Preserve old callers while prohibiting independent launch-policy paths."""
33
+
34
+ def __init__(self, client: ResolvedCLIClient):
35
+ self.client = client
36
+
37
+ async def run(
38
+ self,
39
+ *,
40
+ role: ResolvedCLIRole,
41
+ prompt: str,
42
+ system_prompt: str | None = None,
43
+ files: Sequence[str],
44
+ images: Sequence[str],
45
+ ) -> AgentOutput:
46
+ from clink.backend import get_backend
47
+
48
+ backend = get_backend()
49
+ try:
50
+ target = backend.resolve(cli_name=self.client.name, role=role.name, images=tuple(images))
51
+ result = await backend.run(
52
+ CLIRequest(
53
+ prompt=prompt,
54
+ target=target,
55
+ system_prompt=system_prompt or "",
56
+ files=tuple(files),
57
+ images=tuple(images),
58
+ )
59
+ )
60
+ except BackendError as exc:
61
+ raise CLIAgentError(exc.code, str(exc), exc.remedy, exc.metadata) from exc
62
+ return AgentOutput(
63
+ parsed=ParsedCLIResponse(result.content, result.to_metadata()),
64
+ sanitized_command=[],
65
+ returncode=0,
66
+ stdout="",
67
+ stderr="",
68
+ duration_seconds=result.duration_seconds,
69
+ parser_name=target.client.parser,
70
+ )
clink/agents/claude.py ADDED
@@ -0,0 +1,7 @@
1
+ """Compatibility facade for Claude; policy lives in the shared backend."""
2
+
3
+ from .base import BaseCLIAgent
4
+
5
+
6
+ class ClaudeAgent(BaseCLIAgent):
7
+ """Delegate through the shared backend without independent argv injection."""
clink/agents/codex.py ADDED
@@ -0,0 +1,7 @@
1
+ """Compatibility facade for Codex; policy lives in the shared backend."""
2
+
3
+ from .base import BaseCLIAgent
4
+
5
+
6
+ class CodexAgent(BaseCLIAgent):
7
+ """Delegate through the shared backend without nonzero-exit recovery."""
clink/agents/gemini.py ADDED
@@ -0,0 +1,7 @@
1
+ """Compatibility facade for Gemini; execution remains admission-gated."""
2
+
3
+ from .base import BaseCLIAgent
4
+
5
+
6
+ class GeminiAgent(BaseCLIAgent):
7
+ """Never turn native CLI errors into successful content."""
clink/allowed_roots.py ADDED
@@ -0,0 +1,356 @@
1
+ """CLI-owned, explicit enrollment of canonical directories in operator manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ import stat
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import clink.registry as registry_module
17
+ from clink.registry import ClinkRegistry, ManifestSource, RegistryLoadError
18
+ from clink.setup import (
19
+ SetupError,
20
+ _check_parent,
21
+ _directory,
22
+ _identity,
23
+ _json_object,
24
+ _PlannedFile,
25
+ _publish,
26
+ _read_destination,
27
+ )
28
+
29
+
30
+ class EnrollmentError(RuntimeError):
31
+ """An enrollment failed; publication state is structural, never parsed from prose."""
32
+
33
+ def __init__(
34
+ self, message: str, *, grant_saved: bool | None = False, error_code: str = "enrollment_failed"
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.grant_saved = grant_saved
38
+ self.error_code = error_code
39
+
40
+
41
+ def canonical_directory(value: str) -> Path:
42
+ try:
43
+ path = Path(value).expanduser().resolve(strict=True)
44
+ if not path.is_dir() or not os.access(path, os.R_OK | os.X_OK):
45
+ raise OSError
46
+ return path
47
+ except (OSError, RuntimeError, ValueError):
48
+ raise EnrollmentError(f"Choose an existing accessible directory: {json.dumps(value)}") from None
49
+
50
+
51
+ def allow_command(
52
+ target: str, cli_name: str, project: Path | None = None, consumer_config_dir: str | Path | None = None
53
+ ) -> str:
54
+ arguments = ["clink"]
55
+ if consumer_config_dir is not None:
56
+ arguments.extend(("--consumer-config-dir", str(consumer_config_dir)))
57
+ arguments.extend(("allow", target, "--cli", cli_name))
58
+ if project is not None:
59
+ arguments.extend(("--project", str(project)))
60
+ return shlex.join([*arguments, "--yes"])
61
+
62
+
63
+ def approved(registry: ClinkRegistry, cli_name: str, target: Path, *, native: bool = False) -> bool:
64
+ client = registry.get_client(cli_name)
65
+ eligible = (
66
+ {"user", "legacy_user", "explicit", "consumer"} if native else {"user", "legacy_user", "explicit", "project"}
67
+ )
68
+ if not client.allowed_roots_explicit or client.configuration_scope not in eligible:
69
+ return False
70
+ for root in client.allowed_roots:
71
+ if canonical_directory(str(root)) != root:
72
+ raise EnrollmentError("An approved directory changed; review the operator manifest before proceeding.")
73
+ return any(target == root or root in target.parents for root in client.allowed_roots)
74
+
75
+
76
+ def _replace_roots(content: bytes, roots: list[str], path: Path) -> bytes:
77
+ """Replace only the top-level value span, or insert without rewriting existing bytes."""
78
+ _json_object(content, path)
79
+ text = content.decode("utf-8")
80
+ decoder = json.JSONDecoder()
81
+ index = text.index("{") + 1
82
+ last_end = index
83
+ indent = " "
84
+ while True:
85
+ index += len(text[index:]) - len(text[index:].lstrip())
86
+ if text[index] == "}":
87
+ addition = json.dumps("allowed_roots") + ": " + json.dumps(roots, ensure_ascii=False)
88
+ prefix = "," if last_end != text.index("{") + 1 else ""
89
+ separator = ("\r\n" if "\r\n" in text else "\n") + indent if "\n" in text else " "
90
+ return (text[:last_end] + prefix + separator + addition + text[last_end:]).encode("utf-8")
91
+ line_start = text.rfind("\n", 0, index) + 1
92
+ if line_start and not text[line_start:index].strip():
93
+ indent = text[line_start:index]
94
+ key, end = decoder.raw_decode(text, index)
95
+ index = end
96
+ index += len(text[index:]) - len(text[index:].lstrip())
97
+ index += 1 # Strict object validation above established the colon.
98
+ index += len(text[index:]) - len(text[index:].lstrip())
99
+ previous_roots, end = decoder.raw_decode(text, index)
100
+ if key == "allowed_roots":
101
+ value = json.dumps(roots, ensure_ascii=False)
102
+ if isinstance(previous_roots, list) and roots[: len(previous_roots)] == previous_roots:
103
+ value = text[index:end]
104
+ for root in roots[len(previous_roots) :]:
105
+ entry = json.dumps(root, ensure_ascii=False)
106
+ insert = len(value[:-1].rstrip())
107
+ if "\n" in value:
108
+ last_line = value[:insert].rsplit("\n", 1)[-1]
109
+ array_indent = re.match(r"[\t ]*", last_line).group()
110
+ separator = ("\r\n" if "\r\n" in value else "\n") + array_indent
111
+ else:
112
+ separator = " "
113
+ comma = "," if previous_roots else ""
114
+ value = value[:insert] + comma + separator + entry + value[insert:]
115
+ previous_roots.append(root)
116
+ return (text[:index] + value + text[end:]).encode("utf-8")
117
+ last_end = end
118
+ index = end + len(text[end:]) - len(text[end:].lstrip())
119
+ if text[index] == ",":
120
+ index += 1
121
+
122
+
123
+ def _portable(registry: ClinkRegistry, cli_name: str, source: ManifestSource) -> dict[str, Any]:
124
+ data = _json_object(source.content, source.path)
125
+ client = registry.get_client(cli_name)
126
+ data["working_dir"] = str(client.working_dir)
127
+ data["allowed_roots"] = [str(root) for root in client.allowed_roots]
128
+ data["command"] = shlex.join(client.executable)
129
+ role_maps = [(data.setdefault("roles", {}), client.roles)]
130
+ if client.native_task is not None:
131
+ role_maps.append((data["native_task"].setdefault("roles", {}), client.native_task.roles))
132
+ for raw_roles, resolved_roles in role_maps:
133
+ for name, role in resolved_roles.items():
134
+ raw_roles.setdefault(name, {})["prompt_path"] = str(role.prompt_path)
135
+ return data
136
+
137
+
138
+ def _parent_identities(path: Path) -> tuple[tuple[Path, int, int], ...]:
139
+ identities = []
140
+ for parent in reversed((path, *path.parents)):
141
+ try:
142
+ info = parent.lstat()
143
+ except FileNotFoundError:
144
+ break
145
+ if not stat.S_ISDIR(info.st_mode):
146
+ raise EnrollmentError(f"Configuration parent must be a real directory: {parent}")
147
+ identities.append((parent, info.st_dev, info.st_ino))
148
+ return tuple(identities)
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class Enrollment:
153
+ registry: ClinkRegistry
154
+ cli_name: str
155
+ target: Path
156
+ target_spelling: str
157
+ target_identity: tuple[int, int]
158
+ source: ManifestSource
159
+ destination: Path
160
+ content: bytes
161
+ previous: bytes | None
162
+ identity: tuple[int, ...] | None
163
+ mode: int
164
+ already_allowed: bool
165
+ native: bool
166
+ lock_root: Path
167
+ parents: tuple[tuple[Path, int, int], ...]
168
+
169
+ @property
170
+ def entry(self) -> str:
171
+ return json.dumps(str(self.target), ensure_ascii=False)
172
+
173
+ @property
174
+ def broad(self) -> bool:
175
+ return self.target in {Path(self.target.anchor), Path.home().resolve()}
176
+
177
+ def _registry_snapshot(self) -> ClinkRegistry:
178
+ return ClinkRegistry(
179
+ project_dir=self.registry.project_dir,
180
+ consumer_config_dir=self.registry.consumer_config_dir,
181
+ _consumer_identity=self.registry.consumer_config_identity,
182
+ )
183
+
184
+ def _validate(self) -> None:
185
+ target = canonical_directory(self.target_spelling)
186
+ info = target.stat()
187
+ if target != self.target or (info.st_dev, info.st_ino) != self.target_identity:
188
+ raise EnrollmentError("Selected directory changed during approval.")
189
+ for parent, device, inode in self.parents:
190
+ current_parent = parent.lstat()
191
+ if not stat.S_ISDIR(current_parent.st_mode) or (current_parent.st_dev, current_parent.st_ino) != (
192
+ device,
193
+ inode,
194
+ ):
195
+ raise EnrollmentError("Configuration parent changed during approval.")
196
+ current = self._registry_snapshot()
197
+ if current.manifest_source(self.cli_name) != self.source or current.get_client(
198
+ self.cli_name
199
+ ) != self.registry.get_client(self.cli_name):
200
+ raise EnrollmentError("Effective configuration changed during approval.")
201
+
202
+ def persist(
203
+ self, *, guard: Callable[[], None] | None = None, published: Callable[[], None] | None = None
204
+ ) -> ClinkRegistry:
205
+ if self.already_allowed:
206
+ return self.registry
207
+ saved = False
208
+
209
+ def mark_saved() -> None:
210
+ nonlocal saved
211
+ saved = True
212
+ if published is not None:
213
+ published()
214
+
215
+ try:
216
+ if os.name != "posix":
217
+ raise EnrollmentError("Directory enrollment requires POSIX no-follow file operations.")
218
+ import fcntl
219
+
220
+ root = Path(self.destination.anchor)
221
+ descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
222
+ try:
223
+ self._validate()
224
+ parts = self.destination.relative_to(root).parts
225
+ with _directory(descriptor, parts[:-1], root, create=True) as lock_fd:
226
+ assert lock_fd is not None
227
+ try:
228
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
229
+ except BlockingIOError:
230
+ raise EnrollmentError(
231
+ "Another directory enrollment is running; review before retrying."
232
+ ) from None
233
+ self._validate()
234
+
235
+ def prepublish() -> None:
236
+ self._validate()
237
+ _check_parent(descriptor, lock_fd, root, parts[:-1])
238
+ if guard is not None:
239
+ guard()
240
+
241
+ planned = _PlannedFile(
242
+ parts,
243
+ self.content,
244
+ self.previous,
245
+ self.identity,
246
+ self.mode,
247
+ )
248
+ _publish(descriptor, root, planned, prepublish=prepublish, published=mark_saved)
249
+ finally:
250
+ os.close(descriptor)
251
+ refreshed = self._registry_snapshot()
252
+ if not approved(refreshed, self.cli_name, self.target, native=self.native):
253
+ raise EnrollmentError("Saved configuration did not establish the requested authority.")
254
+ return refreshed
255
+ except (EnrollmentError, RegistryLoadError, SetupError, OSError, ValueError) as exc:
256
+ state = (
257
+ "Grant saved, but enrollment did not finish; inspect it before retrying" if saved else "No grant saved"
258
+ )
259
+ raise EnrollmentError(
260
+ f"{state}: configuration {self.destination}; allowed_roots entry {self.entry} "
261
+ f"({type(exc).__name__}: {exc}).",
262
+ grant_saved=saved,
263
+ error_code=exc.error_code if isinstance(exc, EnrollmentError) else "enrollment_failed",
264
+ ) from None
265
+ except (asyncio.CancelledError, KeyboardInterrupt) as exc:
266
+ # Keep cancellation semantics while preserving the publication fact for callers.
267
+ exc.grant_saved = saved
268
+ raise
269
+
270
+
271
+ def prepare_enrollment(
272
+ registry: ClinkRegistry,
273
+ cli_name: str,
274
+ value: str,
275
+ *,
276
+ native: bool = False,
277
+ ) -> Enrollment:
278
+ """Prepare a bounded immutable change without creating directories or writing files."""
279
+ target = canonical_directory(value)
280
+ destination: Path | str = "selected operator manifest"
281
+ try:
282
+ if os.name != "posix":
283
+ raise EnrollmentError("Directory enrollment requires POSIX no-follow file operations.")
284
+ client = registry.get_client(cli_name)
285
+ source = registry.manifest_source(cli_name)
286
+ if (
287
+ registry.consumer_config_dir is None
288
+ and client.configuration_scope != "bundled"
289
+ and source.path.is_relative_to(registry_module.CONFIG_DIR.resolve())
290
+ ):
291
+ raise EnrollmentError(
292
+ f"Refusing to edit a bundled manifest selected as operator configuration: {source.path}. "
293
+ "Select an independent operator manifest with CLI_CLIENTS_CONFIG_PATH."
294
+ )
295
+ native = native or registry.consumer_config_dir is not None
296
+ already = approved(registry, cli_name, target, native=native)
297
+ if native and registry.project_dir is not None:
298
+ raise EnrollmentError("Native generation requires independent user or explicit configuration.")
299
+ destination = source.path
300
+ portable = client.configuration_scope == "bundled"
301
+ if portable:
302
+ destination = registry_module.USER_CONFIG_DIR / f"{client.name.lower()}.json"
303
+ destination = destination.absolute()
304
+ lock_root = destination.parent
305
+ root = Path(destination.anchor)
306
+ descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
307
+ try:
308
+ previous, info = _read_destination(descriptor, destination.relative_to(root).parts, root)
309
+ finally:
310
+ os.close(descriptor)
311
+ if portable and previous is not None:
312
+ raise EnrollmentError("A user manifest already occupies the override destination.")
313
+ if not portable and (previous != source.content or info is None or _identity(info) != source.identity):
314
+ raise EnrollmentError("Selected configuration changed before approval.")
315
+ data = _json_object(source.content, source.path)
316
+ roots = list(data.get("allowed_roots") or [str(root) for root in client.allowed_roots])
317
+ if not any(target == root or root in target.parents for root in client.allowed_roots):
318
+ roots.append(str(target))
319
+ if portable:
320
+ data = _portable(registry, cli_name, source)
321
+ data["allowed_roots"] = [str(root) for root in client.allowed_roots]
322
+ if str(target) not in data["allowed_roots"]:
323
+ data["allowed_roots"].append(str(target))
324
+ content = (json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False) + "\n").encode()
325
+ else:
326
+ content = _replace_roots(source.content, roots, source.path)
327
+ if len(content) > registry_module.MAX_CONFIG_BYTES:
328
+ raise EnrollmentError("Updated configuration exceeds the bounded file limit.")
329
+ registry.validate_manifest(
330
+ _json_object(content, destination),
331
+ source_path=destination,
332
+ configuration_scope=("user" if portable else client.configuration_scope),
333
+ )
334
+ info_target = target.stat()
335
+ return Enrollment(
336
+ registry,
337
+ cli_name,
338
+ target,
339
+ value,
340
+ (info_target.st_dev, info_target.st_ino),
341
+ source,
342
+ destination,
343
+ content,
344
+ previous,
345
+ _identity(info) if info else None,
346
+ stat.S_IMODE(info.st_mode) if info else 0o600,
347
+ already,
348
+ native,
349
+ lock_root,
350
+ _parent_identities(destination.parent),
351
+ )
352
+ except (EnrollmentError, RegistryLoadError, SetupError, OSError, KeyError, ValueError) as exc:
353
+ raise EnrollmentError(
354
+ f"No grant saved: configuration {destination}; allowed_roots entry {json.dumps(str(target))}: {exc}",
355
+ grant_saved=False,
356
+ ) from None