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