ai-config-cli 0.1.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.
@@ -0,0 +1,69 @@
1
+ """Validation context for sharing state across validators."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from ai_config.adapters.claude import InstalledMarketplace, InstalledPlugin
9
+ from ai_config.types import AIConfig
10
+
11
+
12
+ @dataclass
13
+ class ValidationContext:
14
+ """Shared context passed to all validators."""
15
+
16
+ config: "AIConfig"
17
+ config_path: Path
18
+ target_type: str = "claude"
19
+
20
+ # Cached data from Claude CLI (lazy-loaded)
21
+ _installed_plugins: list["InstalledPlugin"] | None = field(default=None, repr=False)
22
+ _installed_marketplaces: list["InstalledMarketplace"] | None = field(default=None, repr=False)
23
+ _known_marketplaces_json: dict | None = field(default=None, repr=False)
24
+ _errors: list[str] = field(default_factory=list, repr=False)
25
+
26
+ @property
27
+ def installed_plugins(self) -> list["InstalledPlugin"]:
28
+ """Lazy-load installed plugins from Claude CLI."""
29
+ if self._installed_plugins is None:
30
+ from ai_config.adapters import claude
31
+
32
+ plugins, errors = claude.list_installed_plugins()
33
+ self._installed_plugins = plugins
34
+ self._errors.extend(errors)
35
+ return self._installed_plugins
36
+
37
+ @property
38
+ def installed_marketplaces(self) -> list["InstalledMarketplace"]:
39
+ """Lazy-load installed marketplaces from Claude CLI."""
40
+ if self._installed_marketplaces is None:
41
+ from ai_config.adapters import claude
42
+
43
+ marketplaces, errors = claude.list_installed_marketplaces()
44
+ self._installed_marketplaces = marketplaces
45
+ self._errors.extend(errors)
46
+ return self._installed_marketplaces
47
+
48
+ @property
49
+ def known_marketplaces_json(self) -> dict:
50
+ """Lazy-load Claude's known_marketplaces.json file."""
51
+ if self._known_marketplaces_json is None:
52
+ import json
53
+
54
+ known_path = Path.home() / ".claude" / "plugins" / "known_marketplaces.json"
55
+ if known_path.exists():
56
+ try:
57
+ with open(known_path) as f:
58
+ self._known_marketplaces_json = json.load(f)
59
+ except (json.JSONDecodeError, OSError) as e:
60
+ self._errors.append(f"Failed to read known_marketplaces.json: {e}")
61
+ self._known_marketplaces_json = {}
62
+ else:
63
+ self._known_marketplaces_json = {}
64
+ return self._known_marketplaces_json
65
+
66
+ @property
67
+ def errors(self) -> list[str]:
68
+ """Return accumulated errors from lazy loading."""
69
+ return self._errors
@@ -0,0 +1 @@
1
+ """Marketplace validators for ai-config."""
@@ -0,0 +1,433 @@
1
+ """Marketplace validators for ai-config.
2
+
3
+ Validates marketplace.json manifests per the official Claude Code schema:
4
+ https://code.claude.com/docs/en/plugin-marketplaces
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from ai_config.types import PluginSource
13
+ from ai_config.validators.base import ValidationResult
14
+
15
+ if TYPE_CHECKING:
16
+ from ai_config.validators.context import ValidationContext
17
+
18
+
19
+ # Reserved marketplace names that cannot be used
20
+ RESERVED_MARKETPLACE_NAMES = frozenset(
21
+ [
22
+ "claude-code-marketplace",
23
+ "claude-code-plugins",
24
+ "claude-plugins-official",
25
+ "anthropic-marketplace",
26
+ "anthropic-plugins",
27
+ "agent-skills",
28
+ "life-sciences",
29
+ ]
30
+ )
31
+
32
+ # Pattern for valid kebab-case names
33
+ KEBAB_CASE_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
34
+
35
+
36
+ def is_kebab_case(name: str) -> bool:
37
+ """Check if a name is valid kebab-case."""
38
+ if not name:
39
+ return False
40
+ return bool(KEBAB_CASE_PATTERN.match(name))
41
+
42
+
43
+ class MarketplacePathValidator:
44
+ """Validates that local marketplace paths exist."""
45
+
46
+ name = "marketplace_path"
47
+ description = "Validates that local marketplace directories exist"
48
+
49
+ async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
50
+ """Validate marketplace path existence.
51
+
52
+ Args:
53
+ context: The validation context.
54
+
55
+ Returns:
56
+ List of validation results.
57
+ """
58
+ results: list[ValidationResult] = []
59
+
60
+ for target in context.config.targets:
61
+ if target.type != "claude":
62
+ continue
63
+
64
+ for mp_name, mp_config in target.config.marketplaces.items():
65
+ # Only check local marketplaces
66
+ if mp_config.source != PluginSource.LOCAL:
67
+ continue
68
+
69
+ mp_path = Path(mp_config.path)
70
+ if mp_path.exists():
71
+ results.append(
72
+ ValidationResult(
73
+ check_name="marketplace_path_exists",
74
+ status="pass",
75
+ message=f"Marketplace '{mp_name}' path exists: {mp_path}",
76
+ )
77
+ )
78
+ else:
79
+ results.append(
80
+ ValidationResult(
81
+ check_name="marketplace_path_exists",
82
+ status="fail",
83
+ message=f"Marketplace '{mp_name}' path does not exist: {mp_path}",
84
+ details=f"Expected directory at: {mp_path}",
85
+ fix_hint=f"Create the directory: mkdir -p {mp_path}",
86
+ )
87
+ )
88
+
89
+ return results
90
+
91
+
92
+ class MarketplaceManifestValidator:
93
+ """Validates that local marketplaces have valid marketplace.json manifests.
94
+
95
+ Per the official Claude Code marketplace schema:
96
+ - name: Required, kebab-case, not a reserved name
97
+ - owner: Required, must have 'name' field
98
+ - plugins: Required, must be an array of plugin entries
99
+ - Each plugin entry must have 'name' and 'source'
100
+ """
101
+
102
+ name = "marketplace_manifest"
103
+ description = "Validates marketplace.json manifest files"
104
+
105
+ async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
106
+ """Validate marketplace manifest files.
107
+
108
+ Args:
109
+ context: The validation context.
110
+
111
+ Returns:
112
+ List of validation results.
113
+ """
114
+ results: list[ValidationResult] = []
115
+
116
+ for target in context.config.targets:
117
+ if target.type != "claude":
118
+ continue
119
+
120
+ for mp_name, mp_config in target.config.marketplaces.items():
121
+ # Only check local marketplaces
122
+ if mp_config.source != PluginSource.LOCAL:
123
+ continue
124
+
125
+ mp_path = Path(mp_config.path)
126
+ if not mp_path.exists():
127
+ # Path validation will catch this
128
+ continue
129
+
130
+ manifest_path = mp_path / ".claude-plugin" / "marketplace.json"
131
+ if not manifest_path.exists():
132
+ results.append(
133
+ ValidationResult(
134
+ check_name="marketplace_manifest_exists",
135
+ status="fail",
136
+ message=f"Marketplace '{mp_name}' is missing marketplace.json",
137
+ details=f"Expected file at: {manifest_path}",
138
+ fix_hint=(
139
+ f'Create manifest with: {{"name": "{mp_name}", '
140
+ '"owner": {"name": "..."}, "plugins": []}'
141
+ ),
142
+ )
143
+ )
144
+ continue
145
+
146
+ # Validate manifest JSON
147
+ try:
148
+ with open(manifest_path) as f:
149
+ manifest = json.load(f)
150
+
151
+ manifest_results = self._validate_manifest(mp_name, manifest)
152
+ results.extend(manifest_results)
153
+
154
+ if not any(r.status == "fail" for r in manifest_results):
155
+ results.append(
156
+ ValidationResult(
157
+ check_name="marketplace_manifest_valid",
158
+ status="pass",
159
+ message=f"Marketplace '{mp_name}' manifest is valid",
160
+ )
161
+ )
162
+
163
+ except json.JSONDecodeError as e:
164
+ results.append(
165
+ ValidationResult(
166
+ check_name="marketplace_manifest_valid",
167
+ status="fail",
168
+ message=f"Marketplace '{mp_name}' has invalid JSON in marketplace.json",
169
+ details=str(e),
170
+ fix_hint="Fix the JSON syntax in marketplace.json",
171
+ )
172
+ )
173
+ except OSError as e:
174
+ results.append(
175
+ ValidationResult(
176
+ check_name="marketplace_manifest_readable",
177
+ status="fail",
178
+ message=f"Failed to read marketplace.json for '{mp_name}'",
179
+ details=str(e),
180
+ )
181
+ )
182
+
183
+ return results
184
+
185
+ def _validate_manifest(self, mp_name: str, manifest: dict) -> list[ValidationResult]:
186
+ """Validate marketplace manifest content.
187
+
188
+ Args:
189
+ mp_name: The marketplace name from config.
190
+ manifest: The parsed marketplace.json content.
191
+
192
+ Returns:
193
+ List of validation results.
194
+ """
195
+ results: list[ValidationResult] = []
196
+
197
+ if not isinstance(manifest, dict):
198
+ results.append(
199
+ ValidationResult(
200
+ check_name="marketplace_manifest_valid",
201
+ status="fail",
202
+ message=f"Marketplace '{mp_name}' manifest is not a JSON object",
203
+ )
204
+ )
205
+ return results
206
+
207
+ # Check required 'name' field
208
+ name = manifest.get("name")
209
+ if not name:
210
+ results.append(
211
+ ValidationResult(
212
+ check_name="marketplace_manifest_name_required",
213
+ status="fail",
214
+ message=f"Marketplace '{mp_name}' manifest is missing required 'name' field",
215
+ fix_hint="Add 'name' field to marketplace.json",
216
+ )
217
+ )
218
+ elif not isinstance(name, str):
219
+ results.append(
220
+ ValidationResult(
221
+ check_name="marketplace_manifest_name_type",
222
+ status="fail",
223
+ message=f"Marketplace '{mp_name}' manifest 'name' must be a string",
224
+ )
225
+ )
226
+ else:
227
+ # Check kebab-case
228
+ if not is_kebab_case(name):
229
+ results.append(
230
+ ValidationResult(
231
+ check_name="marketplace_manifest_name_format",
232
+ status="fail",
233
+ message=f"Marketplace '{mp_name}' manifest 'name' must be kebab-case",
234
+ details=f"Got: '{name}'. Use lowercase letters, numbers, and hyphens only.",
235
+ )
236
+ )
237
+
238
+ # Check reserved names
239
+ if name.lower() in RESERVED_MARKETPLACE_NAMES:
240
+ results.append(
241
+ ValidationResult(
242
+ check_name="marketplace_manifest_name_reserved",
243
+ status="fail",
244
+ message=f"Marketplace '{mp_name}' manifest uses reserved name: '{name}'",
245
+ details=f"Reserved names: {', '.join(sorted(RESERVED_MARKETPLACE_NAMES))}",
246
+ fix_hint="Choose a different marketplace name",
247
+ )
248
+ )
249
+
250
+ # Check required 'owner' field
251
+ owner = manifest.get("owner")
252
+ if owner is None:
253
+ results.append(
254
+ ValidationResult(
255
+ check_name="marketplace_manifest_owner_required",
256
+ status="fail",
257
+ message=f"Marketplace '{mp_name}' manifest is missing required 'owner' field",
258
+ fix_hint='Add \'owner\': {"name": "Your Name"} to marketplace.json',
259
+ )
260
+ )
261
+ elif not isinstance(owner, dict):
262
+ results.append(
263
+ ValidationResult(
264
+ check_name="marketplace_manifest_owner_type",
265
+ status="fail",
266
+ message=f"Marketplace '{mp_name}' manifest 'owner' must be an object",
267
+ )
268
+ )
269
+ elif not owner.get("name"):
270
+ results.append(
271
+ ValidationResult(
272
+ check_name="marketplace_manifest_owner_name_required",
273
+ status="fail",
274
+ message=(
275
+ f"Marketplace '{mp_name}' manifest 'owner' is missing required 'name' field"
276
+ ),
277
+ fix_hint='Add \'name\' to owner: {"owner": {"name": "Your Name"}}',
278
+ )
279
+ )
280
+
281
+ # Check required 'plugins' field
282
+ plugins = manifest.get("plugins")
283
+ if plugins is None:
284
+ results.append(
285
+ ValidationResult(
286
+ check_name="marketplace_manifest_plugins_required",
287
+ status="fail",
288
+ message=f"Marketplace '{mp_name}' manifest is missing required 'plugins' field",
289
+ fix_hint="Add 'plugins': [] to marketplace.json",
290
+ )
291
+ )
292
+ elif not isinstance(plugins, list):
293
+ results.append(
294
+ ValidationResult(
295
+ check_name="marketplace_manifest_plugins_type",
296
+ status="fail",
297
+ message=f"Marketplace '{mp_name}' manifest 'plugins' must be an array (list)",
298
+ )
299
+ )
300
+ else:
301
+ # Validate each plugin entry
302
+ for i, plugin_entry in enumerate(plugins):
303
+ plugin_results = self._validate_plugin_entry(mp_name, i, plugin_entry)
304
+ results.extend(plugin_results)
305
+
306
+ return results
307
+
308
+ def _validate_plugin_entry(
309
+ self, mp_name: str, index: int, entry: dict
310
+ ) -> list[ValidationResult]:
311
+ """Validate a plugin entry in the plugins array.
312
+
313
+ Args:
314
+ mp_name: The marketplace name.
315
+ index: Index of the plugin entry.
316
+ entry: The plugin entry dict.
317
+
318
+ Returns:
319
+ List of validation results.
320
+ """
321
+ results: list[ValidationResult] = []
322
+
323
+ if not isinstance(entry, dict):
324
+ results.append(
325
+ ValidationResult(
326
+ check_name="marketplace_plugin_entry_type",
327
+ status="fail",
328
+ message=f"Marketplace '{mp_name}' plugins[{index}] must be an object",
329
+ )
330
+ )
331
+ return results
332
+
333
+ # Check required 'name' field
334
+ if not entry.get("name"):
335
+ results.append(
336
+ ValidationResult(
337
+ check_name="marketplace_plugin_name_required",
338
+ status="fail",
339
+ message=(
340
+ f"Marketplace '{mp_name}' plugins[{index}] is missing required 'name' field"
341
+ ),
342
+ )
343
+ )
344
+
345
+ # Check required 'source' field
346
+ if not entry.get("source"):
347
+ results.append(
348
+ ValidationResult(
349
+ check_name="marketplace_plugin_source_required",
350
+ status="fail",
351
+ message=(
352
+ f"Marketplace '{mp_name}' plugins[{index}] "
353
+ "is missing required 'source' field"
354
+ ),
355
+ )
356
+ )
357
+
358
+ return results
359
+
360
+
361
+ class PathDriftValidator:
362
+ """Validates that config marketplace paths match Claude's known_marketplaces.json."""
363
+
364
+ name = "path_drift"
365
+ description = "Detects when marketplace paths in config differ from Claude's registered paths"
366
+
367
+ async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
368
+ """Check for path drift between config and Claude's state.
369
+
370
+ Args:
371
+ context: The validation context.
372
+
373
+ Returns:
374
+ List of validation results.
375
+ """
376
+ results: list[ValidationResult] = []
377
+
378
+ # Get Claude's known marketplaces
379
+ known_marketplaces = context.known_marketplaces_json
380
+
381
+ for target in context.config.targets:
382
+ if target.type != "claude":
383
+ continue
384
+
385
+ for mp_name, mp_config in target.config.marketplaces.items():
386
+ # Only check local marketplaces for path drift
387
+ if mp_config.source != PluginSource.LOCAL:
388
+ continue
389
+
390
+ config_path = Path(mp_config.path).resolve()
391
+
392
+ # Check if marketplace is registered in Claude
393
+ if mp_name not in known_marketplaces:
394
+ results.append(
395
+ ValidationResult(
396
+ check_name="marketplace_registered",
397
+ status="warn",
398
+ message=f"Marketplace '{mp_name}' is not registered in Claude",
399
+ details="Marketplace is in config but not in known_marketplaces.json",
400
+ fix_hint="Run: ai-config sync",
401
+ )
402
+ )
403
+ continue
404
+
405
+ # Check for path drift
406
+ claude_mp = known_marketplaces[mp_name]
407
+ claude_path_str = claude_mp.get("path", "")
408
+ if claude_path_str:
409
+ claude_path = Path(claude_path_str).resolve()
410
+
411
+ if config_path != claude_path:
412
+ results.append(
413
+ ValidationResult(
414
+ check_name="marketplace_path_drift",
415
+ status="fail",
416
+ message=f"Path drift detected for marketplace '{mp_name}'",
417
+ details=(f"Config path: {config_path}\nClaude path: {claude_path}"),
418
+ fix_hint=(
419
+ f"Run: claude plugin marketplace remove {mp_name} && "
420
+ f"claude plugin marketplace add {config_path}"
421
+ ),
422
+ )
423
+ )
424
+ else:
425
+ results.append(
426
+ ValidationResult(
427
+ check_name="marketplace_path_drift",
428
+ status="pass",
429
+ message=f"Marketplace '{mp_name}' path matches registration",
430
+ )
431
+ )
432
+
433
+ return results
@@ -0,0 +1 @@
1
+ """Plugin validators for ai-config."""