kanon-cli 1.0.0__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.
- kanon_cli/__init__.py +3 -0
- kanon_cli/__main__.py +5 -0
- kanon_cli/catalog/kanon/.kanon +35 -0
- kanon_cli/catalog/kanon/kanon-readme.md +155 -0
- kanon_cli/cli.py +67 -0
- kanon_cli/commands/__init__.py +0 -0
- kanon_cli/commands/bootstrap.py +70 -0
- kanon_cli/commands/clean.py +40 -0
- kanon_cli/commands/install.py +186 -0
- kanon_cli/commands/validate.py +133 -0
- kanon_cli/constants.py +29 -0
- kanon_cli/core/__init__.py +0 -0
- kanon_cli/core/bootstrap.py +107 -0
- kanon_cli/core/catalog.py +133 -0
- kanon_cli/core/clean.py +111 -0
- kanon_cli/core/install.py +349 -0
- kanon_cli/core/kanonenv.py +322 -0
- kanon_cli/core/marketplace.py +433 -0
- kanon_cli/core/marketplace_validator.py +237 -0
- kanon_cli/core/xml_validator.py +94 -0
- kanon_cli/version.py +189 -0
- kanon_cli-1.0.0.dist-info/METADATA +844 -0
- kanon_cli-1.0.0.dist-info/RECORD +26 -0
- kanon_cli-1.0.0.dist-info/WHEEL +4 -0
- kanon_cli-1.0.0.dist-info/entry_points.txt +2 -0
- kanon_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Shared marketplace operations for Claude Code plugin install/uninstall.
|
|
2
|
+
|
|
3
|
+
Provides functions for locating the claude binary, discovering marketplace
|
|
4
|
+
entries and plugins, and orchestrating plugin install/uninstall lifecycles.
|
|
5
|
+
Used by both ``core.install`` (install) and ``core.clean`` (uninstall).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def locate_claude_binary() -> str:
|
|
17
|
+
"""Locate the claude CLI binary on $PATH.
|
|
18
|
+
|
|
19
|
+
Uses shutil.which("claude") to find the binary.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Absolute path to the claude binary.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
SystemExit: Exits with code 1 if claude is not found on $PATH.
|
|
26
|
+
"""
|
|
27
|
+
path = shutil.which("claude")
|
|
28
|
+
if path is None:
|
|
29
|
+
print(
|
|
30
|
+
"Error: claude binary not found on $PATH. Ensure claude is installed and available.",
|
|
31
|
+
file=sys.stderr,
|
|
32
|
+
)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
return os.path.abspath(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_marketplace_dir(globals_dict: dict[str, str]) -> pathlib.Path:
|
|
38
|
+
"""Return Path to marketplace directory from globals_dict.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
globals_dict: Parsed .kanon globals containing CLAUDE_MARKETPLACES_DIR.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
pathlib.Path to the marketplace directory.
|
|
45
|
+
"""
|
|
46
|
+
env_value = globals_dict.get("CLAUDE_MARKETPLACES_DIR", "")
|
|
47
|
+
if env_value:
|
|
48
|
+
return pathlib.Path(env_value)
|
|
49
|
+
return pathlib.Path.home() / ".claude-marketplaces"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def discover_marketplace_entries(marketplace_dir: pathlib.Path) -> list[pathlib.Path]:
|
|
53
|
+
"""Discover marketplace entries in the given directory.
|
|
54
|
+
|
|
55
|
+
Returns sorted list of non-hidden entries that are directories or
|
|
56
|
+
symlinks to directories. Hidden entries (dot-prefixed) are excluded.
|
|
57
|
+
Broken symlinks are logged as warnings and excluded.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
marketplace_dir: Path to the marketplace directory.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Alphabetically sorted list of Path objects.
|
|
64
|
+
"""
|
|
65
|
+
entries = []
|
|
66
|
+
for entry in sorted(marketplace_dir.iterdir()):
|
|
67
|
+
if entry.name.startswith("."):
|
|
68
|
+
continue
|
|
69
|
+
if entry.is_symlink() and not entry.exists():
|
|
70
|
+
print(
|
|
71
|
+
f"Warning: Broken symlink detected and skipped: {entry}",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
if entry.is_dir():
|
|
76
|
+
entries.append(entry)
|
|
77
|
+
return entries
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_marketplace_name(marketplace_path: pathlib.Path) -> str:
|
|
81
|
+
"""Read marketplace name from .claude-plugin/marketplace.json.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
marketplace_path: Path to the marketplace directory.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The 'name' field from marketplace.json.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
FileNotFoundError: If marketplace.json does not exist.
|
|
91
|
+
KeyError: If 'name' field is missing.
|
|
92
|
+
json.JSONDecodeError: If file is not valid JSON.
|
|
93
|
+
"""
|
|
94
|
+
manifest_path = marketplace_path / ".claude-plugin" / "marketplace.json"
|
|
95
|
+
with manifest_path.open() as f:
|
|
96
|
+
data = json.load(f)
|
|
97
|
+
return data["name"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def discover_plugins(marketplace_path: pathlib.Path) -> list[tuple[str, pathlib.Path]]:
|
|
101
|
+
"""Discover plugins within a marketplace directory.
|
|
102
|
+
|
|
103
|
+
Scans immediate subdirectories for .claude-plugin/plugin.json files.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
marketplace_path: Path to the marketplace directory.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of (plugin_name, plugin_path) tuples for each discovered plugin.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
json.JSONDecodeError: If plugin.json exists but contains invalid JSON.
|
|
113
|
+
KeyError: If plugin.json exists but lacks the 'name' field.
|
|
114
|
+
"""
|
|
115
|
+
plugins = []
|
|
116
|
+
for entry in sorted(marketplace_path.iterdir()):
|
|
117
|
+
if not entry.is_dir():
|
|
118
|
+
continue
|
|
119
|
+
plugin_json = entry / ".claude-plugin" / "plugin.json"
|
|
120
|
+
if not plugin_json.is_file():
|
|
121
|
+
continue
|
|
122
|
+
with plugin_json.open() as f:
|
|
123
|
+
data = json.load(f)
|
|
124
|
+
plugins.append((data["name"], entry))
|
|
125
|
+
return plugins
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _get_timeout(env_var: str, default: int = 30) -> int:
|
|
129
|
+
"""Read and validate a timeout from an environment variable.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
env_var: Name of the environment variable.
|
|
133
|
+
default: Default timeout in seconds.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Timeout value in seconds.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
SystemExit: If the value is not a valid positive integer.
|
|
140
|
+
"""
|
|
141
|
+
timeout_str = os.environ.get(env_var, str(default))
|
|
142
|
+
try:
|
|
143
|
+
value = int(timeout_str)
|
|
144
|
+
except ValueError:
|
|
145
|
+
print(
|
|
146
|
+
f"Error: {env_var} must be a positive integer, got: {timeout_str}",
|
|
147
|
+
file=sys.stderr,
|
|
148
|
+
)
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
if value <= 0:
|
|
151
|
+
print(
|
|
152
|
+
f"Error: {env_var} must be a positive integer, got: {timeout_str}",
|
|
153
|
+
file=sys.stderr,
|
|
154
|
+
)
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def register_marketplace(claude_bin: str, marketplace_path: pathlib.Path) -> bool:
|
|
160
|
+
"""Register a marketplace with Claude Code.
|
|
161
|
+
|
|
162
|
+
Runs: claude plugin marketplace add <absolute-path>
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
claude_bin: Path to claude binary.
|
|
166
|
+
marketplace_path: Absolute path to marketplace directory.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
True if registration succeeded, False otherwise.
|
|
170
|
+
"""
|
|
171
|
+
timeout = _get_timeout("CLAUDE_REGISTER_TIMEOUT")
|
|
172
|
+
try:
|
|
173
|
+
result = subprocess.run(
|
|
174
|
+
[claude_bin, "plugin", "marketplace", "add", str(marketplace_path)],
|
|
175
|
+
capture_output=True,
|
|
176
|
+
text=True,
|
|
177
|
+
timeout=timeout,
|
|
178
|
+
)
|
|
179
|
+
except subprocess.TimeoutExpired:
|
|
180
|
+
print(
|
|
181
|
+
f"Error: Timed out after {timeout} seconds registering marketplace {marketplace_path}",
|
|
182
|
+
file=sys.stderr,
|
|
183
|
+
)
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
if result.returncode != 0:
|
|
187
|
+
print(
|
|
188
|
+
f"Error: Failed to register marketplace {marketplace_path}: {result.stderr}",
|
|
189
|
+
file=sys.stderr,
|
|
190
|
+
)
|
|
191
|
+
return False
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def install_plugin(claude_bin: str, plugin_name: str, marketplace_name: str) -> bool:
|
|
196
|
+
"""Install a plugin via Claude Code CLI.
|
|
197
|
+
|
|
198
|
+
Runs: claude plugin install <plugin_name>@<marketplace_name> --scope user
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
claude_bin: Path to claude binary.
|
|
202
|
+
plugin_name: Name of the plugin (from plugin.json).
|
|
203
|
+
marketplace_name: Name of the marketplace (from marketplace.json).
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True if install succeeded, False otherwise.
|
|
207
|
+
"""
|
|
208
|
+
timeout = _get_timeout("CLAUDE_INSTALL_TIMEOUT")
|
|
209
|
+
plugin_ref = f"{plugin_name}@{marketplace_name}"
|
|
210
|
+
try:
|
|
211
|
+
result = subprocess.run(
|
|
212
|
+
[claude_bin, "plugin", "install", plugin_ref, "--scope", "user"],
|
|
213
|
+
capture_output=True,
|
|
214
|
+
text=True,
|
|
215
|
+
timeout=timeout,
|
|
216
|
+
)
|
|
217
|
+
except subprocess.TimeoutExpired:
|
|
218
|
+
print(
|
|
219
|
+
f"Error: Timed out after {timeout} seconds installing plugin {plugin_ref}",
|
|
220
|
+
file=sys.stderr,
|
|
221
|
+
)
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
if result.returncode != 0:
|
|
225
|
+
print(
|
|
226
|
+
f"Error: Failed to install plugin {plugin_ref}: {result.stderr}",
|
|
227
|
+
file=sys.stderr,
|
|
228
|
+
)
|
|
229
|
+
return False
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def uninstall_plugin(claude_bin: str, plugin_name: str, marketplace_name: str) -> bool:
|
|
234
|
+
"""Uninstall a plugin via Claude Code CLI.
|
|
235
|
+
|
|
236
|
+
Runs: claude plugin uninstall <plugin_name>@<marketplace_name> --scope user
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
claude_bin: Path to claude binary.
|
|
240
|
+
plugin_name: Name of the plugin (from plugin.json).
|
|
241
|
+
marketplace_name: Name of the marketplace (from marketplace.json).
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
True if uninstall succeeded, False otherwise.
|
|
245
|
+
"""
|
|
246
|
+
timeout = _get_timeout("CLAUDE_UNINSTALL_TIMEOUT")
|
|
247
|
+
plugin_ref = f"{plugin_name}@{marketplace_name}"
|
|
248
|
+
try:
|
|
249
|
+
result = subprocess.run(
|
|
250
|
+
[claude_bin, "plugin", "uninstall", plugin_ref, "--scope", "user"],
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
timeout=timeout,
|
|
254
|
+
)
|
|
255
|
+
except subprocess.TimeoutExpired:
|
|
256
|
+
print(
|
|
257
|
+
f"Error: Timed out after {timeout} seconds uninstalling plugin {plugin_ref}",
|
|
258
|
+
file=sys.stderr,
|
|
259
|
+
)
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
if result.returncode != 0:
|
|
263
|
+
stderr = result.stderr.strip() if result.stderr else ""
|
|
264
|
+
if "not found" in stderr.lower() or "not installed" in stderr.lower():
|
|
265
|
+
print(f"Plugin already uninstalled (not found): {plugin_ref}")
|
|
266
|
+
return True
|
|
267
|
+
print(
|
|
268
|
+
f"Error: Failed to uninstall plugin {plugin_ref}: {stderr}",
|
|
269
|
+
file=sys.stderr,
|
|
270
|
+
)
|
|
271
|
+
return False
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def remove_marketplace(claude_bin: str, marketplace_name: str) -> bool:
|
|
276
|
+
"""Remove a marketplace registration from Claude Code.
|
|
277
|
+
|
|
278
|
+
Runs: claude plugin marketplace remove <marketplace_name>
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
claude_bin: Path to claude binary.
|
|
282
|
+
marketplace_name: Marketplace name (from marketplace.json).
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
True if removal succeeded, False otherwise.
|
|
286
|
+
"""
|
|
287
|
+
timeout = _get_timeout("CLAUDE_UNINSTALL_TIMEOUT")
|
|
288
|
+
try:
|
|
289
|
+
result = subprocess.run(
|
|
290
|
+
[claude_bin, "plugin", "marketplace", "remove", marketplace_name],
|
|
291
|
+
capture_output=True,
|
|
292
|
+
text=True,
|
|
293
|
+
timeout=timeout,
|
|
294
|
+
)
|
|
295
|
+
except subprocess.TimeoutExpired:
|
|
296
|
+
print(
|
|
297
|
+
f"Error: Timed out after {timeout} seconds removing marketplace {marketplace_name}",
|
|
298
|
+
file=sys.stderr,
|
|
299
|
+
)
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
if result.returncode != 0:
|
|
303
|
+
stderr = result.stderr.strip() if result.stderr else ""
|
|
304
|
+
if "not found" in stderr.lower():
|
|
305
|
+
print(f"Marketplace already removed (not found): {marketplace_name}")
|
|
306
|
+
return True
|
|
307
|
+
print(
|
|
308
|
+
f"Error: Failed to remove marketplace {marketplace_name}: {stderr}",
|
|
309
|
+
file=sys.stderr,
|
|
310
|
+
)
|
|
311
|
+
return False
|
|
312
|
+
return True
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def install_marketplace_plugins(marketplace_dir: pathlib.Path) -> None:
|
|
316
|
+
"""Orchestrate marketplace plugin installation.
|
|
317
|
+
|
|
318
|
+
Locates claude binary, discovers marketplace entries, registers each
|
|
319
|
+
marketplace, discovers and installs plugins.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
marketplace_dir: Path to CLAUDE_MARKETPLACES_DIR.
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
SystemExit: If claude binary not found or any operation fails.
|
|
326
|
+
"""
|
|
327
|
+
claude_bin = locate_claude_binary()
|
|
328
|
+
|
|
329
|
+
if not marketplace_dir.is_dir():
|
|
330
|
+
print(
|
|
331
|
+
f"Warning: Marketplace directory does not exist: {marketplace_dir}. No marketplaces to register.",
|
|
332
|
+
file=sys.stderr,
|
|
333
|
+
)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
entries = discover_marketplace_entries(marketplace_dir)
|
|
337
|
+
if not entries:
|
|
338
|
+
print("Warning: No marketplace entries found. Nothing to do.", file=sys.stderr)
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
marketplaces_processed = 0
|
|
342
|
+
marketplaces_registered = 0
|
|
343
|
+
plugins_installed = 0
|
|
344
|
+
any_failures = False
|
|
345
|
+
|
|
346
|
+
for entry in entries:
|
|
347
|
+
marketplaces_processed += 1
|
|
348
|
+
marketplace_name = read_marketplace_name(entry)
|
|
349
|
+
|
|
350
|
+
reg_success = register_marketplace(claude_bin, entry)
|
|
351
|
+
if reg_success:
|
|
352
|
+
marketplaces_registered += 1
|
|
353
|
+
else:
|
|
354
|
+
any_failures = True
|
|
355
|
+
|
|
356
|
+
plugins = discover_plugins(entry)
|
|
357
|
+
for plugin_name, _plugin_path in plugins:
|
|
358
|
+
success = install_plugin(claude_bin, plugin_name, marketplace_name)
|
|
359
|
+
if success:
|
|
360
|
+
plugins_installed += 1
|
|
361
|
+
else:
|
|
362
|
+
any_failures = True
|
|
363
|
+
|
|
364
|
+
print(
|
|
365
|
+
f"Install summary: {marketplaces_processed} marketplaces processed, "
|
|
366
|
+
f"{marketplaces_registered} registered, {plugins_installed} plugins installed"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if any_failures:
|
|
370
|
+
print(
|
|
371
|
+
"Error: Some marketplace operations failed (see errors above).",
|
|
372
|
+
file=sys.stderr,
|
|
373
|
+
)
|
|
374
|
+
sys.exit(1)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def uninstall_marketplace_plugins(marketplace_dir: pathlib.Path) -> None:
|
|
378
|
+
"""Orchestrate marketplace plugin uninstallation.
|
|
379
|
+
|
|
380
|
+
Locates claude binary, discovers marketplace entries, uninstalls each
|
|
381
|
+
plugin, then removes marketplace registrations.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
marketplace_dir: Path to CLAUDE_MARKETPLACES_DIR.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
SystemExit: If claude binary not found or any operation fails.
|
|
388
|
+
"""
|
|
389
|
+
claude_bin = locate_claude_binary()
|
|
390
|
+
|
|
391
|
+
if not marketplace_dir.is_dir():
|
|
392
|
+
print(
|
|
393
|
+
f"Warning: Marketplace directory does not exist: {marketplace_dir}. No marketplaces to uninstall.",
|
|
394
|
+
file=sys.stderr,
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
entries = discover_marketplace_entries(marketplace_dir)
|
|
399
|
+
if not entries:
|
|
400
|
+
print("Warning: No marketplace entries found. Nothing to uninstall.", file=sys.stderr)
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
marketplaces_processed = 0
|
|
404
|
+
plugins_uninstalled = 0
|
|
405
|
+
any_failures = False
|
|
406
|
+
|
|
407
|
+
for entry in entries:
|
|
408
|
+
marketplaces_processed += 1
|
|
409
|
+
marketplace_name = read_marketplace_name(entry)
|
|
410
|
+
|
|
411
|
+
plugins = discover_plugins(entry)
|
|
412
|
+
for plugin_name, _plugin_path in plugins:
|
|
413
|
+
success = uninstall_plugin(claude_bin, plugin_name, marketplace_name)
|
|
414
|
+
if success:
|
|
415
|
+
plugins_uninstalled += 1
|
|
416
|
+
else:
|
|
417
|
+
any_failures = True
|
|
418
|
+
|
|
419
|
+
remove_success = remove_marketplace(claude_bin, marketplace_name)
|
|
420
|
+
if not remove_success:
|
|
421
|
+
any_failures = True
|
|
422
|
+
|
|
423
|
+
print(
|
|
424
|
+
f"Uninstall summary: {marketplaces_processed} marketplace(s) processed, "
|
|
425
|
+
f"{plugins_uninstalled} plugin(s) uninstalled"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if any_failures:
|
|
429
|
+
print(
|
|
430
|
+
"Error: Some marketplace operations failed (see errors above).",
|
|
431
|
+
file=sys.stderr,
|
|
432
|
+
)
|
|
433
|
+
sys.exit(1)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Validate marketplace XML manifest files.
|
|
2
|
+
|
|
3
|
+
Checks:
|
|
4
|
+
- All <linkfile dest> attributes use the ${CLAUDE_MARKETPLACES_DIR}
|
|
5
|
+
variable prefix, rejecting hard-coded or relative paths.
|
|
6
|
+
- All <include> chains are unbroken (every referenced file exists).
|
|
7
|
+
- All flattened project path names are unique across manifests.
|
|
8
|
+
- All <project revision> attributes follow valid formats.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from kanon_cli.constants import (
|
|
16
|
+
ALLOWED_BRANCHES,
|
|
17
|
+
CONSTRAINT_RE,
|
|
18
|
+
MARKETPLACE_DIR_PREFIX,
|
|
19
|
+
MARKETPLACE_FILE_GLOB,
|
|
20
|
+
REFS_TAGS_RE,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_linkfile_dest(xml_path: Path) -> list[str]:
|
|
25
|
+
"""Validate all linkfile dest attributes in a manifest XML file.
|
|
26
|
+
|
|
27
|
+
Checks that every <linkfile> element's dest attribute starts with
|
|
28
|
+
${CLAUDE_MARKETPLACES_DIR}/. Returns a list of error messages for
|
|
29
|
+
any violations found. An empty list means validation passed.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
xml_path: Path to the XML manifest file to validate.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of error messages. Empty if all dest attributes are valid.
|
|
36
|
+
Each error identifies the file, project name, and invalid dest.
|
|
37
|
+
"""
|
|
38
|
+
errors: list[str] = []
|
|
39
|
+
tree = ET.parse(xml_path)
|
|
40
|
+
root = tree.getroot()
|
|
41
|
+
|
|
42
|
+
for project in root.findall("project"):
|
|
43
|
+
project_name = project.get("name", "<unknown>")
|
|
44
|
+
for linkfile in project.findall("linkfile"):
|
|
45
|
+
dest = linkfile.get("dest", "")
|
|
46
|
+
if not dest.startswith(MARKETPLACE_DIR_PREFIX):
|
|
47
|
+
errors.append(
|
|
48
|
+
f"{xml_path}: project '{project_name}' has "
|
|
49
|
+
f"invalid linkfile dest='{dest}' — "
|
|
50
|
+
f"must start with {MARKETPLACE_DIR_PREFIX}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return errors
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_include_chain(
|
|
57
|
+
xml_path: Path,
|
|
58
|
+
repo_root: Path,
|
|
59
|
+
) -> list[str]:
|
|
60
|
+
"""Validate that all includes in a manifest chain resolve to files.
|
|
61
|
+
|
|
62
|
+
Recursively follows <include> elements starting from xml_path,
|
|
63
|
+
checking that each referenced file exists. Returns errors for any
|
|
64
|
+
broken links in the chain.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
xml_path: Path to the XML manifest file to validate.
|
|
68
|
+
repo_root: Repository root for resolving include paths.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of error messages. Empty if the entire chain is valid.
|
|
72
|
+
Each error identifies the source file and missing include.
|
|
73
|
+
"""
|
|
74
|
+
errors: list[str] = []
|
|
75
|
+
visited: set[str] = set()
|
|
76
|
+
|
|
77
|
+
def _walk(current_path: Path) -> None:
|
|
78
|
+
resolved = str(current_path.resolve())
|
|
79
|
+
if resolved in visited:
|
|
80
|
+
return
|
|
81
|
+
visited.add(resolved)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
tree = ET.parse(current_path)
|
|
85
|
+
except ET.ParseError as exc:
|
|
86
|
+
errors.append(f"{current_path}: XML parse error: {exc}")
|
|
87
|
+
return
|
|
88
|
+
root = tree.getroot()
|
|
89
|
+
|
|
90
|
+
for include in root.findall("include"):
|
|
91
|
+
name = include.get("name")
|
|
92
|
+
if not name:
|
|
93
|
+
errors.append(f'{current_path}: <include> element missing required "name" attribute')
|
|
94
|
+
continue
|
|
95
|
+
include_path = repo_root / name
|
|
96
|
+
if not include_path.exists():
|
|
97
|
+
errors.append(f'{current_path}: <include name="{name}"> references non-existent file: {include_path}')
|
|
98
|
+
else:
|
|
99
|
+
_walk(include_path)
|
|
100
|
+
|
|
101
|
+
_walk(xml_path)
|
|
102
|
+
return errors
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_name_uniqueness(xml_files: list[Path]) -> list[str]:
|
|
106
|
+
"""Validate that all project path attributes are unique across manifests.
|
|
107
|
+
|
|
108
|
+
Parses each XML file, collects all <project path="..."> values, and
|
|
109
|
+
reports any duplicates along with the files containing them.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
xml_files: List of paths to marketplace XML manifest files.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of error messages. Empty if all paths are unique.
|
|
116
|
+
Each error identifies the duplicate path and conflicting files.
|
|
117
|
+
"""
|
|
118
|
+
errors: list[str] = []
|
|
119
|
+
path_to_files: dict[str, list[str]] = {}
|
|
120
|
+
|
|
121
|
+
for xml_file in xml_files:
|
|
122
|
+
tree = ET.parse(xml_file)
|
|
123
|
+
root = tree.getroot()
|
|
124
|
+
for project in root.findall("project"):
|
|
125
|
+
path_attr = project.get("path", "")
|
|
126
|
+
if path_attr:
|
|
127
|
+
if path_attr not in path_to_files:
|
|
128
|
+
path_to_files[path_attr] = []
|
|
129
|
+
path_to_files[path_attr].append(str(xml_file))
|
|
130
|
+
|
|
131
|
+
for path_attr, files in path_to_files.items():
|
|
132
|
+
if len(files) > 1:
|
|
133
|
+
file_list = ", ".join(files)
|
|
134
|
+
errors.append(f"Duplicate project path '{path_attr}' found in: {file_list}")
|
|
135
|
+
|
|
136
|
+
return errors
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _is_valid_revision(revision: str) -> bool:
|
|
140
|
+
"""Check if a revision string is a valid format.
|
|
141
|
+
|
|
142
|
+
Valid formats:
|
|
143
|
+
- refs/tags/<path>/<semver> (e.g., refs/tags/example/proj/1.0.0)
|
|
144
|
+
- Single version constraints (~=1.2.0, >=1.0.0, <2.0.0)
|
|
145
|
+
- Compound version constraints (>=1.0.0,<2.0.0)
|
|
146
|
+
- Wildcard (*)
|
|
147
|
+
- Branch names (main)
|
|
148
|
+
"""
|
|
149
|
+
if revision in ALLOWED_BRANCHES:
|
|
150
|
+
return True
|
|
151
|
+
if revision == "*":
|
|
152
|
+
return True
|
|
153
|
+
if REFS_TAGS_RE.match(revision):
|
|
154
|
+
return True
|
|
155
|
+
# Support compound constraints separated by commas (e.g., >=1.0.0,<2.0.0)
|
|
156
|
+
parts = revision.split(",")
|
|
157
|
+
if all(CONSTRAINT_RE.match(part) for part in parts):
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def validate_tag_format(xml_files: list[Path]) -> list[str]:
|
|
163
|
+
"""Validate that all project revision attributes follow valid formats.
|
|
164
|
+
|
|
165
|
+
Checks that each <project> element's revision attribute is either a
|
|
166
|
+
refs/tags/<path>/<semver> tag, a version constraint, a wildcard, or
|
|
167
|
+
an allowed branch name. Returns errors for any invalid revisions.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
xml_files: List of paths to marketplace XML manifest files.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List of error messages. Empty if all revisions are valid.
|
|
174
|
+
Each error identifies the file, project name, and invalid revision.
|
|
175
|
+
"""
|
|
176
|
+
errors: list[str] = []
|
|
177
|
+
|
|
178
|
+
for xml_file in xml_files:
|
|
179
|
+
tree = ET.parse(xml_file)
|
|
180
|
+
root = tree.getroot()
|
|
181
|
+
for project in root.findall("project"):
|
|
182
|
+
revision = project.get("revision", "")
|
|
183
|
+
if revision and not _is_valid_revision(revision):
|
|
184
|
+
project_name = project.get("name", "<unknown>")
|
|
185
|
+
errors.append(
|
|
186
|
+
f"{xml_file}: project '{project_name}' has "
|
|
187
|
+
f"invalid revision='{revision}' — must be "
|
|
188
|
+
f"refs/tags/<path>/<semver>, a version constraint, "
|
|
189
|
+
f"or an allowed branch"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return errors
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_marketplace(repo_root: Path) -> int:
|
|
196
|
+
"""Validate all marketplace XML files found under repo-specs/.
|
|
197
|
+
|
|
198
|
+
Scans for *-marketplace.xml files and validates each one
|
|
199
|
+
for linkfile dest attributes and include chain integrity.
|
|
200
|
+
Exits with non-zero code if any validation errors are found.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
repo_root: Repository root directory.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
0 if all files pass validation, 1 otherwise.
|
|
207
|
+
"""
|
|
208
|
+
marketplace_files = sorted(repo_root.joinpath("repo-specs").rglob(MARKETPLACE_FILE_GLOB))
|
|
209
|
+
|
|
210
|
+
if not marketplace_files:
|
|
211
|
+
print(
|
|
212
|
+
"Error: No *-marketplace.xml files found under repo-specs/",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
return 1
|
|
216
|
+
|
|
217
|
+
all_errors: list[str] = []
|
|
218
|
+
for xml_file in marketplace_files:
|
|
219
|
+
rel_path = xml_file.relative_to(repo_root)
|
|
220
|
+
print(f"Validating {rel_path}...")
|
|
221
|
+
all_errors.extend(validate_linkfile_dest(xml_file))
|
|
222
|
+
all_errors.extend(validate_include_chain(xml_file, repo_root))
|
|
223
|
+
|
|
224
|
+
all_errors.extend(validate_name_uniqueness(marketplace_files))
|
|
225
|
+
all_errors.extend(validate_tag_format(marketplace_files))
|
|
226
|
+
|
|
227
|
+
if all_errors:
|
|
228
|
+
print(
|
|
229
|
+
f"\nFound {len(all_errors)} validation error(s):",
|
|
230
|
+
file=sys.stderr,
|
|
231
|
+
)
|
|
232
|
+
for error in all_errors:
|
|
233
|
+
print(f" {error}", file=sys.stderr)
|
|
234
|
+
return 1
|
|
235
|
+
|
|
236
|
+
print(f"\nAll {len(marketplace_files)} marketplace files passed.")
|
|
237
|
+
return 0
|