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/setup.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Create a project-local Clink configuration from bundled templates only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from clink.constants import BUILTIN_PROMPTS_DIR, CONFIG_DIR, INTERNAL_DEFAULTS, PROJECT_ROOT
|
|
20
|
+
from clink.models import CLIClientConfig
|
|
21
|
+
|
|
22
|
+
MAX_SETUP_FILE_BYTES = 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SetupError(RuntimeError):
|
|
26
|
+
"""Setup could not proceed without changing operator-owned content."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SetupResult:
|
|
31
|
+
project_dir: Path
|
|
32
|
+
created: tuple[Path, ...]
|
|
33
|
+
unchanged: tuple[Path, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class _PlannedFile:
|
|
38
|
+
parts: tuple[str, ...]
|
|
39
|
+
content: bytes
|
|
40
|
+
previous: bytes | None
|
|
41
|
+
identity: tuple[int, ...] | None
|
|
42
|
+
mode: int = 0o600
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_bytes(data: dict[str, Any]) -> bytes:
|
|
46
|
+
return (json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
50
|
+
result: dict[str, Any] = {}
|
|
51
|
+
for key, value in pairs:
|
|
52
|
+
if key in result:
|
|
53
|
+
raise ValueError("duplicate JSON field")
|
|
54
|
+
result[key] = value
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _reject_constant(_value: str) -> None:
|
|
59
|
+
raise ValueError("non-JSON numeric constant")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _json_object(content: bytes, path: Path) -> dict[str, Any]:
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(content, object_pairs_hook=_unique_object, parse_constant=_reject_constant)
|
|
65
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
66
|
+
raise SetupError(f"Expected valid JSON without duplicate fields: {path}") from None
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
raise SetupError(f"Expected a JSON object: {path}")
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _bounded_read(stream: Any, path: Path) -> bytes:
|
|
73
|
+
content = stream.read(MAX_SETUP_FILE_BYTES + 1)
|
|
74
|
+
if len(content) > MAX_SETUP_FILE_BYTES:
|
|
75
|
+
raise SetupError(f"File exceeds {MAX_SETUP_FILE_BYTES} bytes: {path}")
|
|
76
|
+
return content
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _bundled_content(path: Path) -> bytes:
|
|
80
|
+
if path.is_symlink() or not path.is_file():
|
|
81
|
+
raise SetupError(f"Expected a bundled regular file: {path}")
|
|
82
|
+
with path.open("rb") as stream:
|
|
83
|
+
return _bounded_read(stream, path)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _template_files(cli_names: Sequence[str] | None) -> dict[tuple[str, ...], bytes]:
|
|
87
|
+
templates = {path.stem: path for path in sorted(CONFIG_DIR.glob("*.json"))}
|
|
88
|
+
selected = sorted(set(cli_names)) if cli_names else sorted(templates)
|
|
89
|
+
if not selected:
|
|
90
|
+
raise SetupError(f"No bundled CLI templates found: {CONFIG_DIR}")
|
|
91
|
+
for name in selected:
|
|
92
|
+
if name not in templates:
|
|
93
|
+
raise SetupError(f"Unknown bundled CLI {name!r}; choose from {', '.join(sorted(templates))}")
|
|
94
|
+
contents: dict[tuple[str, ...], bytes] = {}
|
|
95
|
+
prompt_root = BUILTIN_PROMPTS_DIR.resolve(strict=True)
|
|
96
|
+
for name in selected:
|
|
97
|
+
source = templates[name]
|
|
98
|
+
data = _json_object(_bundled_content(source), source)
|
|
99
|
+
try:
|
|
100
|
+
raw = CLIClientConfig.model_validate(data)
|
|
101
|
+
except ValidationError:
|
|
102
|
+
raise SetupError(f"Invalid bundled CLI template: {source}") from None
|
|
103
|
+
if raw.name != name or raw.adapter not in INTERNAL_DEFAULTS or "default" not in raw.roles:
|
|
104
|
+
raise SetupError(f"Incomplete bundled CLI template: {source}")
|
|
105
|
+
data["working_dir"] = "../.."
|
|
106
|
+
data["allowed_roots"] = ["../.."]
|
|
107
|
+
role_maps = [data["roles"]]
|
|
108
|
+
if data.get("native_task") is not None:
|
|
109
|
+
role_maps.append(data["native_task"]["roles"])
|
|
110
|
+
for roles in role_maps:
|
|
111
|
+
for role in roles.values():
|
|
112
|
+
candidate = role.get("prompt_path")
|
|
113
|
+
if not isinstance(candidate, str):
|
|
114
|
+
raise SetupError(f"Bundled role requires a prompt_path: {source}")
|
|
115
|
+
prompt = (source.parent / candidate).resolve(strict=True)
|
|
116
|
+
if not prompt.is_relative_to(prompt_root):
|
|
117
|
+
raise SetupError(f"Bundled role prompt is outside the bundled prompt directory: {source}")
|
|
118
|
+
relative = prompt.relative_to(prompt_root)
|
|
119
|
+
prompt_content = _bundled_content(prompt)
|
|
120
|
+
try:
|
|
121
|
+
prompt_content.decode("utf-8")
|
|
122
|
+
except UnicodeError:
|
|
123
|
+
raise SetupError(f"Expected a UTF-8 bundled role prompt: {prompt}") from None
|
|
124
|
+
contents[(".clink", "prompts", *relative.parts)] = prompt_content
|
|
125
|
+
role["prompt_path"] = f"../prompts/{relative.as_posix()}"
|
|
126
|
+
contents[(".clink", "cli_clients", f"{name}.json")] = _json_bytes(data)
|
|
127
|
+
_validate_templates(contents)
|
|
128
|
+
return contents
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validate_templates(contents: dict[tuple[str, ...], bytes]) -> None:
|
|
132
|
+
"""Validate generated schema and relative paths without loading user settings or .env."""
|
|
133
|
+
with tempfile.TemporaryDirectory(prefix="clink-setup-validate-") as directory:
|
|
134
|
+
root = Path(directory).resolve(strict=True)
|
|
135
|
+
for parts, content in contents.items():
|
|
136
|
+
path = root.joinpath(*parts)
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
path.write_bytes(content)
|
|
139
|
+
for path in sorted((root / ".clink" / "cli_clients").glob("*.json")):
|
|
140
|
+
try:
|
|
141
|
+
client = CLIClientConfig.model_validate(_json_object(path.read_bytes(), path))
|
|
142
|
+
except ValidationError:
|
|
143
|
+
raise SetupError(f"Generated CLI template failed validation: {path.name}") from None
|
|
144
|
+
if (path.parent / client.working_dir).resolve(strict=True) != root:
|
|
145
|
+
raise SetupError(f"Generated working_dir is outside the project: {path.name}")
|
|
146
|
+
if client.allowed_roots != ["../.."]:
|
|
147
|
+
raise SetupError(f"Generated allowed_roots are invalid: {path.name}")
|
|
148
|
+
role_maps = [client.roles]
|
|
149
|
+
if client.native_task is not None:
|
|
150
|
+
role_maps.append(client.native_task.roles)
|
|
151
|
+
for roles in role_maps:
|
|
152
|
+
for role in roles.values():
|
|
153
|
+
if role.prompt_path is None:
|
|
154
|
+
raise SetupError(f"Generated role has no prompt_path: {path.name}")
|
|
155
|
+
prompt = (path.parent / role.prompt_path).resolve(strict=True)
|
|
156
|
+
if not prompt.is_relative_to(root / ".clink" / "prompts") or not prompt.is_file():
|
|
157
|
+
raise SetupError(f"Generated role prompt is outside the project: {path.name}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _identity(info: os.stat_result) -> tuple[int, ...]:
|
|
161
|
+
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@contextmanager
|
|
165
|
+
def _directory(root_fd: int, parts: tuple[str, ...], root: Path, *, create: bool = False) -> Iterator[int | None]:
|
|
166
|
+
descriptor = os.dup(root_fd)
|
|
167
|
+
try:
|
|
168
|
+
for index, part in enumerate(parts):
|
|
169
|
+
try:
|
|
170
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
|
|
171
|
+
except FileNotFoundError:
|
|
172
|
+
if not create:
|
|
173
|
+
yield None
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
os.mkdir(part, mode=0o700, dir_fd=descriptor)
|
|
177
|
+
except FileExistsError:
|
|
178
|
+
pass
|
|
179
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
|
|
180
|
+
except OSError:
|
|
181
|
+
raise SetupError(
|
|
182
|
+
f"Expected a real destination directory without symlinks: {root.joinpath(*parts[:index + 1])}"
|
|
183
|
+
) from None
|
|
184
|
+
os.close(descriptor)
|
|
185
|
+
descriptor = child
|
|
186
|
+
yield descriptor
|
|
187
|
+
finally:
|
|
188
|
+
os.close(descriptor)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _read_destination(root_fd: int, parts: tuple[str, ...], root: Path) -> tuple[bytes | None, os.stat_result | None]:
|
|
192
|
+
path = root.joinpath(*parts)
|
|
193
|
+
with _directory(root_fd, parts[:-1], root) as parent_fd:
|
|
194
|
+
if parent_fd is None:
|
|
195
|
+
return None, None
|
|
196
|
+
try:
|
|
197
|
+
descriptor = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd)
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
return None, None
|
|
200
|
+
except OSError:
|
|
201
|
+
raise SetupError(f"Expected a regular destination file without symlinks: {path}") from None
|
|
202
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
203
|
+
info = os.fstat(stream.fileno())
|
|
204
|
+
if not stat.S_ISREG(info.st_mode):
|
|
205
|
+
raise SetupError(f"Expected a regular destination file: {path}")
|
|
206
|
+
content = _bounded_read(stream, path)
|
|
207
|
+
if _identity(os.fstat(stream.fileno())) != _identity(info):
|
|
208
|
+
raise SetupError(f"Destination changed while being read; rerun setup: {path}")
|
|
209
|
+
return content, info
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _registration(project: Path) -> dict[str, Any]:
|
|
213
|
+
# Resolving sys.executable would erase the virtualenv path on symlink-based environments.
|
|
214
|
+
interpreter = os.path.abspath(sys.executable)
|
|
215
|
+
server = PROJECT_ROOT / "server.py"
|
|
216
|
+
if not server.is_file():
|
|
217
|
+
raise SetupError(f"Installed MCP server entrypoint is missing: {server}")
|
|
218
|
+
return {"command": interpreter, "args": [str(server.absolute()), "--project", str(project)]}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _plan(root_fd: int, root: Path, contents: dict[tuple[str, ...], bytes]) -> list[_PlannedFile]:
|
|
222
|
+
plans: list[_PlannedFile] = []
|
|
223
|
+
# Check registration first so a conflicting operator registration creates no project files.
|
|
224
|
+
parts = (".mcp.json",)
|
|
225
|
+
previous, info = _read_destination(root_fd, parts, root)
|
|
226
|
+
data = _json_object(previous, root / parts[0]) if previous is not None else {}
|
|
227
|
+
servers = data.setdefault("mcpServers", {})
|
|
228
|
+
if not isinstance(servers, dict):
|
|
229
|
+
raise SetupError(f"Expected mcpServers to be a JSON object: {root / parts[0]}")
|
|
230
|
+
expected = _registration(root)
|
|
231
|
+
if "clink" in servers and servers["clink"] != expected:
|
|
232
|
+
raise SetupError(
|
|
233
|
+
f"Conflicting Clink registration; preserve or edit it explicitly before setup: {root / parts[0]}"
|
|
234
|
+
)
|
|
235
|
+
if "clink" in servers:
|
|
236
|
+
registration_content = previous
|
|
237
|
+
else:
|
|
238
|
+
servers["clink"] = expected
|
|
239
|
+
registration_content = _json_bytes(data)
|
|
240
|
+
if len(registration_content) > MAX_SETUP_FILE_BYTES:
|
|
241
|
+
raise SetupError(f"Merged configuration would exceed {MAX_SETUP_FILE_BYTES} bytes: {root / parts[0]}")
|
|
242
|
+
registration = _PlannedFile(
|
|
243
|
+
parts,
|
|
244
|
+
registration_content,
|
|
245
|
+
previous,
|
|
246
|
+
_identity(info) if info else None,
|
|
247
|
+
stat.S_IMODE(info.st_mode) if info else 0o600,
|
|
248
|
+
)
|
|
249
|
+
# Publish prompt dependencies before manifests, then the registration last.
|
|
250
|
+
for parts, content in sorted(contents.items(), key=lambda item: (item[0][1] != "prompts", item[0])):
|
|
251
|
+
existing, info = _read_destination(root_fd, parts, root)
|
|
252
|
+
if existing is not None and existing != content:
|
|
253
|
+
raise SetupError(
|
|
254
|
+
f"Conflicting project file; preserve or edit it explicitly before setup: {root.joinpath(*parts)}"
|
|
255
|
+
)
|
|
256
|
+
plans.append(_PlannedFile(parts, content, existing, _identity(info) if info else None))
|
|
257
|
+
plans.append(registration)
|
|
258
|
+
return plans
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _check_unchanged(root_fd: int, root: Path, planned: _PlannedFile) -> None:
|
|
262
|
+
current, info = _read_destination(root_fd, planned.parts, root)
|
|
263
|
+
if current != planned.previous or (_identity(info) if info else None) != planned.identity:
|
|
264
|
+
raise SetupError(f"Destination changed during setup; rerun after reviewing it: {root.joinpath(*planned.parts)}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _check_parent(root_fd: int, parent_fd: int, root: Path, parts: tuple[str, ...]) -> None:
|
|
268
|
+
root_info = root.lstat()
|
|
269
|
+
held_root = os.fstat(root_fd)
|
|
270
|
+
if (root_info.st_dev, root_info.st_ino) != (held_root.st_dev, held_root.st_ino):
|
|
271
|
+
raise SetupError(f"Project directory changed during setup; review it before rerunning: {root}")
|
|
272
|
+
with _directory(root_fd, parts, root) as current_fd:
|
|
273
|
+
held = os.fstat(parent_fd)
|
|
274
|
+
current = os.fstat(current_fd) if current_fd is not None else None
|
|
275
|
+
if current is None or (current.st_dev, current.st_ino) != (held.st_dev, held.st_ino):
|
|
276
|
+
raise SetupError(
|
|
277
|
+
f"Destination directory changed during setup; review it before rerunning: {root.joinpath(*parts)}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _publish(
|
|
282
|
+
root_fd: int,
|
|
283
|
+
root: Path,
|
|
284
|
+
planned: _PlannedFile,
|
|
285
|
+
*,
|
|
286
|
+
prepublish: Callable[[], None] | None = None,
|
|
287
|
+
published: Callable[[], None] | None = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
with _directory(root_fd, planned.parts[:-1], root, create=True) as parent_fd:
|
|
290
|
+
assert parent_fd is not None
|
|
291
|
+
# Create and fully flush each file before its name becomes visible.
|
|
292
|
+
name = f".clink-setup-{os.urandom(12).hex()}"
|
|
293
|
+
descriptor = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, planned.mode, dir_fd=parent_fd)
|
|
294
|
+
try:
|
|
295
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
296
|
+
os.fchmod(stream.fileno(), planned.mode)
|
|
297
|
+
stream.write(planned.content)
|
|
298
|
+
stream.flush()
|
|
299
|
+
os.fsync(stream.fileno())
|
|
300
|
+
_check_unchanged(root_fd, root, planned)
|
|
301
|
+
_check_parent(root_fd, parent_fd, root, planned.parts[:-1])
|
|
302
|
+
if prepublish is not None:
|
|
303
|
+
prepublish()
|
|
304
|
+
if planned.previous is None:
|
|
305
|
+
try:
|
|
306
|
+
os.link(name, planned.parts[-1], src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)
|
|
307
|
+
except FileExistsError:
|
|
308
|
+
raise SetupError(
|
|
309
|
+
f"Destination appeared during setup; review it before rerunning: {root.joinpath(*planned.parts)}"
|
|
310
|
+
) from None
|
|
311
|
+
else:
|
|
312
|
+
# The directory lock serializes setup processes. Recheck detects editor changes;
|
|
313
|
+
# unrelated editors do not participate in this cooperative lock.
|
|
314
|
+
os.replace(name, planned.parts[-1], src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
315
|
+
if published is not None:
|
|
316
|
+
published()
|
|
317
|
+
os.fsync(parent_fd)
|
|
318
|
+
finally:
|
|
319
|
+
try:
|
|
320
|
+
os.unlink(name, dir_fd=parent_fd)
|
|
321
|
+
except FileNotFoundError:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def setup_project(project_dir: str | os.PathLike[str] = ".", *, cli_names: Sequence[str] | None = None) -> SetupResult:
|
|
326
|
+
"""Generate selected project templates and publish the project-bound MCP registration last."""
|
|
327
|
+
try:
|
|
328
|
+
project = Path(project_dir).expanduser().resolve(strict=True)
|
|
329
|
+
except (OSError, RuntimeError, ValueError):
|
|
330
|
+
raise SetupError(f"Project must be an existing directory: {project_dir}") from None
|
|
331
|
+
if not project.is_dir():
|
|
332
|
+
raise SetupError(f"Project must be an existing directory: {project}")
|
|
333
|
+
if os.name != "posix":
|
|
334
|
+
raise SetupError("Project setup currently requires POSIX no-follow file operations")
|
|
335
|
+
import fcntl
|
|
336
|
+
|
|
337
|
+
created: list[Path] = []
|
|
338
|
+
try:
|
|
339
|
+
contents = _template_files(cli_names)
|
|
340
|
+
descriptor = os.open(project, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
341
|
+
try:
|
|
342
|
+
try:
|
|
343
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
344
|
+
except BlockingIOError:
|
|
345
|
+
raise SetupError(f"Another setup is running for this project: {project}") from None
|
|
346
|
+
plans = _plan(descriptor, project, contents)
|
|
347
|
+
for planned in plans:
|
|
348
|
+
_check_unchanged(descriptor, project, planned)
|
|
349
|
+
unchanged: list[Path] = []
|
|
350
|
+
for planned in plans:
|
|
351
|
+
if planned.previous == planned.content:
|
|
352
|
+
_check_unchanged(descriptor, project, planned)
|
|
353
|
+
unchanged.append(project.joinpath(*planned.parts))
|
|
354
|
+
else:
|
|
355
|
+
_publish(descriptor, project, planned)
|
|
356
|
+
created.append(project.joinpath(*planned.parts))
|
|
357
|
+
return SetupResult(project, tuple(created), tuple(unchanged))
|
|
358
|
+
finally:
|
|
359
|
+
os.close(descriptor)
|
|
360
|
+
except SetupError as exc:
|
|
361
|
+
if created:
|
|
362
|
+
raise SetupError(
|
|
363
|
+
f"{exc}; at least {len(created)} files published, rerun the same setup to resume"
|
|
364
|
+
) from None
|
|
365
|
+
raise
|
|
366
|
+
except OSError as exc:
|
|
367
|
+
raise SetupError(
|
|
368
|
+
f"Cannot set up project {project} ({type(exc).__name__}); check paths and permissions; "
|
|
369
|
+
f"at least {len(created)} files published, rerun the same setup to resume"
|
|
370
|
+
) from None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
374
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
375
|
+
parser.add_argument("--project", default=".", help="Existing project directory (default: current directory)")
|
|
376
|
+
parser.add_argument(
|
|
377
|
+
"--cli",
|
|
378
|
+
action="append",
|
|
379
|
+
dest="cli_names",
|
|
380
|
+
metavar="NAME",
|
|
381
|
+
help="Bundled CLI to configure; repeat to select clients (default: all)",
|
|
382
|
+
)
|
|
383
|
+
args = parser.parse_args(argv)
|
|
384
|
+
try:
|
|
385
|
+
result = setup_project(args.project, cli_names=args.cli_names)
|
|
386
|
+
except SetupError as exc:
|
|
387
|
+
print(f"clink setup: {exc}", file=sys.stderr)
|
|
388
|
+
return 1
|
|
389
|
+
print(
|
|
390
|
+
f"Configured Clink for {result.project_dir}: {len(result.created)} files written, {len(result.unchanged)} unchanged"
|
|
391
|
+
)
|
|
392
|
+
for path in (*result.created, *result.unchanged):
|
|
393
|
+
print(f" {path.relative_to(result.project_dir)}")
|
|
394
|
+
print("Restart the MCP client/server, then run listmodels to check CLI discovery.")
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
raise SystemExit(main())
|