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,230 @@
1
+ """MCP (Model Context Protocol) validators for ai-config.
2
+
3
+ Validates .mcp.json configuration per the official Claude Code schema:
4
+ https://code.claude.com/docs/en/plugins-reference#mcp-servers
5
+ """
6
+
7
+ import json
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from ai_config.validators.base import ValidationResult
13
+
14
+ if TYPE_CHECKING:
15
+ from ai_config.validators.context import ValidationContext
16
+
17
+
18
+ class MCPValidator:
19
+ """Validates .mcp.json configuration for plugins."""
20
+
21
+ name = "mcp_validator"
22
+ description = "Validates MCP server configuration files"
23
+
24
+ async def validate(self, context: "ValidationContext") -> list[ValidationResult]:
25
+ """Validate MCP config for all configured plugins.
26
+
27
+ Args:
28
+ context: The validation context.
29
+
30
+ Returns:
31
+ List of validation results.
32
+ """
33
+ results: list[ValidationResult] = []
34
+
35
+ # Create lookup map for installed plugins
36
+ installed_map = {p.id: p for p in context.installed_plugins}
37
+
38
+ for target in context.config.targets:
39
+ if target.type != "claude":
40
+ continue
41
+
42
+ for plugin in target.config.plugins:
43
+ installed = installed_map.get(plugin.id)
44
+ if not installed:
45
+ continue
46
+
47
+ install_path = Path(installed.install_path)
48
+ if not install_path.exists():
49
+ continue
50
+
51
+ # Check for .mcp.json
52
+ mcp_json = install_path / ".mcp.json"
53
+ if not mcp_json.exists():
54
+ # No MCP config is fine - not all plugins need MCP
55
+ continue
56
+
57
+ # Validate .mcp.json
58
+ try:
59
+ with open(mcp_json) as f:
60
+ mcp_config = json.load(f)
61
+ except json.JSONDecodeError as e:
62
+ results.append(
63
+ ValidationResult(
64
+ check_name="mcp_json_valid",
65
+ status="fail",
66
+ message=f"Plugin '{plugin.id}' has invalid JSON in .mcp.json",
67
+ details=str(e),
68
+ )
69
+ )
70
+ continue
71
+ except OSError as e:
72
+ results.append(
73
+ ValidationResult(
74
+ check_name="mcp_json_readable",
75
+ status="fail",
76
+ message=f"Failed to read .mcp.json for '{plugin.id}'",
77
+ details=str(e),
78
+ )
79
+ )
80
+ continue
81
+
82
+ if not isinstance(mcp_config, dict):
83
+ results.append(
84
+ ValidationResult(
85
+ check_name="mcp_json_valid",
86
+ status="fail",
87
+ message=f"Plugin '{plugin.id}' .mcp.json is not a JSON object",
88
+ )
89
+ )
90
+ continue
91
+
92
+ # Validate MCP server entries
93
+ mcp_results = self._validate_mcp_servers(plugin.id, mcp_config)
94
+ results.extend(mcp_results)
95
+
96
+ if not any(r.status == "fail" for r in mcp_results):
97
+ results.append(
98
+ ValidationResult(
99
+ check_name="mcp_valid",
100
+ status="pass",
101
+ message=f"Plugin '{plugin.id}' MCP config is valid",
102
+ )
103
+ )
104
+
105
+ return results
106
+
107
+ def _validate_mcp_servers(self, plugin_id: str, mcp_config: dict) -> list[ValidationResult]:
108
+ """Validate MCP server entries.
109
+
110
+ Official schema:
111
+ {
112
+ "mcpServers": {
113
+ "server-name": {
114
+ "command": "path/to/server", // required
115
+ "args": ["--flag", "value"], // optional
116
+ "env": { "VAR": "value" }, // optional
117
+ "cwd": "working/dir" // optional
118
+ }
119
+ }
120
+ }
121
+
122
+ Args:
123
+ plugin_id: The plugin identifier.
124
+ mcp_config: The parsed .mcp.json content.
125
+
126
+ Returns:
127
+ List of validation results.
128
+ """
129
+ results: list[ValidationResult] = []
130
+
131
+ # Check mcpServers if present
132
+ mcp_servers = mcp_config.get("mcpServers", {})
133
+ if not isinstance(mcp_servers, dict):
134
+ results.append(
135
+ ValidationResult(
136
+ check_name="mcp_servers_format",
137
+ status="fail",
138
+ message=f"Plugin '{plugin_id}' .mcp.json 'mcpServers' must be an object",
139
+ )
140
+ )
141
+ return results
142
+
143
+ for server_name, server_config in mcp_servers.items():
144
+ if not isinstance(server_config, dict):
145
+ results.append(
146
+ ValidationResult(
147
+ check_name="mcp_server_format",
148
+ status="fail",
149
+ message=f"MCP server '{server_name}' config must be an object",
150
+ details=f"Plugin: {plugin_id}",
151
+ )
152
+ )
153
+ continue
154
+
155
+ # Check command exists (required field)
156
+ command = server_config.get("command")
157
+ if command is None:
158
+ results.append(
159
+ ValidationResult(
160
+ check_name="mcp_command_required",
161
+ status="fail",
162
+ message=f"MCP server '{server_name}' is missing required 'command' field",
163
+ details=f"Plugin: {plugin_id}",
164
+ fix_hint="Add 'command' field with the path to the MCP server executable",
165
+ )
166
+ )
167
+ continue
168
+
169
+ # Validate command is a string
170
+ if not isinstance(command, str):
171
+ results.append(
172
+ ValidationResult(
173
+ check_name="mcp_command_type",
174
+ status="fail",
175
+ message=f"MCP server '{server_name}' command must be a string",
176
+ details=f"Plugin: {plugin_id}, got type: {type(command).__name__}",
177
+ )
178
+ )
179
+ continue
180
+
181
+ # Check if command is on PATH (only warn, don't fail - it might use variables)
182
+ if not command.startswith("${") and not shutil.which(command):
183
+ # Only warn if it's not a variable reference
184
+ results.append(
185
+ ValidationResult(
186
+ check_name="mcp_command_exists",
187
+ status="warn",
188
+ message=f"MCP server '{server_name}' command not found: {command}",
189
+ details=f"Plugin: {plugin_id}",
190
+ fix_hint=f"Install {command} or add it to PATH",
191
+ )
192
+ )
193
+
194
+ # Check args is a list if present
195
+ args = server_config.get("args")
196
+ if args is not None and not isinstance(args, list):
197
+ results.append(
198
+ ValidationResult(
199
+ check_name="mcp_args_format",
200
+ status="fail",
201
+ message=f"MCP server '{server_name}' args must be an array (list)",
202
+ details=f"Plugin: {plugin_id}",
203
+ )
204
+ )
205
+
206
+ # Check env is a dict if present
207
+ env = server_config.get("env")
208
+ if env is not None and not isinstance(env, dict):
209
+ results.append(
210
+ ValidationResult(
211
+ check_name="mcp_env_format",
212
+ status="fail",
213
+ message=f"MCP server '{server_name}' env must be an object",
214
+ details=f"Plugin: {plugin_id}",
215
+ )
216
+ )
217
+
218
+ # Check cwd is a string if present
219
+ cwd = server_config.get("cwd")
220
+ if cwd is not None and not isinstance(cwd, str):
221
+ results.append(
222
+ ValidationResult(
223
+ check_name="mcp_cwd_format",
224
+ status="fail",
225
+ message=f"MCP server '{server_name}' cwd must be a string",
226
+ details=f"Plugin: {plugin_id}",
227
+ )
228
+ )
229
+
230
+ return results
@@ -0,0 +1,411 @@
1
+ """Skill validator for ai-config.
2
+
3
+ Validates skills per the agentskills.io specification:
4
+ https://agentskills.io/specification
5
+
6
+ Adapted from the reference implementation:
7
+ https://github.com/agentskills/agentskills/blob/main/skills-ref/src/skills_ref/validator.py
8
+ """
9
+
10
+ import re
11
+ import unicodedata
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from ai_config.validators.base import ValidationResult
17
+ from ai_config.validators.context import ValidationContext
18
+
19
+ # Maximum lengths per spec
20
+ MAX_NAME_LENGTH = 64
21
+ MAX_DESCRIPTION_LENGTH = 1024
22
+ MAX_COMPATIBILITY_LENGTH = 500
23
+
24
+ # Allowed frontmatter fields per spec
25
+ ALLOWED_FIELDS = frozenset(
26
+ ["name", "description", "license", "allowed-tools", "metadata", "compatibility"]
27
+ )
28
+
29
+ # Regex for valid skill names: lowercase alphanumeric + hyphens
30
+ NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
31
+
32
+
33
+ def _normalize_unicode(text: str) -> str:
34
+ """Normalize Unicode text to NFC form."""
35
+ return unicodedata.normalize("NFC", text)
36
+
37
+
38
+ def validate_name(name: str, directory_name: str | None = None) -> list[str]:
39
+ """Validate skill name per agentskills.io specification.
40
+
41
+ Args:
42
+ name: The skill name to validate.
43
+ directory_name: Optional directory name to check for match.
44
+
45
+ Returns:
46
+ List of error messages (empty if valid).
47
+ """
48
+ errors: list[str] = []
49
+
50
+ if not name:
51
+ errors.append("Skill name is required and cannot be empty")
52
+ return errors
53
+
54
+ # Normalize Unicode
55
+ name = _normalize_unicode(name)
56
+
57
+ # Check length
58
+ if len(name) > MAX_NAME_LENGTH:
59
+ errors.append(f"Skill name exceeds {MAX_NAME_LENGTH} characters (got {len(name)})")
60
+
61
+ # Check for lowercase only
62
+ if name != name.lower():
63
+ errors.append("Skill name must be lowercase")
64
+
65
+ # Check for valid characters (alphanumeric and hyphens only)
66
+ if not NAME_PATTERN.match(name):
67
+ if name.startswith("-"):
68
+ errors.append("Skill name cannot start with a hyphen")
69
+ elif name.endswith("-"):
70
+ errors.append("Skill name cannot end with a hyphen")
71
+ elif "--" in name:
72
+ errors.append("Skill name cannot contain consecutive hyphens")
73
+ else:
74
+ errors.append("Skill name can only contain lowercase letters, numbers, and hyphens")
75
+
76
+ # Check directory name match if provided
77
+ if directory_name is not None and name != directory_name:
78
+ errors.append(f"Skill name '{name}' does not match directory name '{directory_name}'")
79
+
80
+ return errors
81
+
82
+
83
+ def validate_description(description: str) -> list[str]:
84
+ """Validate skill description per agentskills.io specification.
85
+
86
+ Args:
87
+ description: The description to validate.
88
+
89
+ Returns:
90
+ List of error messages (empty if valid).
91
+ """
92
+ errors: list[str] = []
93
+
94
+ if not description or not description.strip():
95
+ errors.append("Skill description is required and cannot be empty")
96
+ return errors
97
+
98
+ # Normalize Unicode
99
+ description = _normalize_unicode(description)
100
+
101
+ # Check length
102
+ if len(description) > MAX_DESCRIPTION_LENGTH:
103
+ errors.append(
104
+ f"Skill description exceeds {MAX_DESCRIPTION_LENGTH} characters "
105
+ f"(got {len(description)})"
106
+ )
107
+
108
+ return errors
109
+
110
+
111
+ def validate_compatibility(compatibility: str) -> list[str]:
112
+ """Validate skill compatibility field per spec.
113
+
114
+ Args:
115
+ compatibility: The compatibility string to validate.
116
+
117
+ Returns:
118
+ List of error messages (empty if valid).
119
+ """
120
+ errors: list[str] = []
121
+
122
+ if not isinstance(compatibility, str):
123
+ errors.append("Skill compatibility must be a string")
124
+ return errors
125
+
126
+ if len(compatibility) > MAX_COMPATIBILITY_LENGTH:
127
+ errors.append(
128
+ f"Skill compatibility exceeds {MAX_COMPATIBILITY_LENGTH} characters "
129
+ f"(got {len(compatibility)})"
130
+ )
131
+
132
+ return errors
133
+
134
+
135
+ def validate_metadata_fields(metadata: dict) -> list[str]:
136
+ """Validate that metadata only contains allowed fields.
137
+
138
+ Args:
139
+ metadata: The frontmatter metadata dict.
140
+
141
+ Returns:
142
+ List of error messages (empty if valid).
143
+ """
144
+ errors: list[str] = []
145
+ extra_fields = set(metadata.keys()) - ALLOWED_FIELDS
146
+ if extra_fields:
147
+ errors.append(f"Unknown frontmatter fields: {', '.join(sorted(extra_fields))}")
148
+ return errors
149
+
150
+
151
+ def _parse_frontmatter(content: str) -> tuple[dict | None, str | None]:
152
+ """Parse YAML frontmatter from markdown content.
153
+
154
+ Args:
155
+ content: The markdown file content.
156
+
157
+ Returns:
158
+ Tuple of (frontmatter_dict, error_message).
159
+ """
160
+ content = content.strip()
161
+ if not content.startswith("---"):
162
+ return None, "SKILL.md must start with YAML frontmatter (---)"
163
+
164
+ # Find the closing ---
165
+ end_idx = content.find("---", 3)
166
+ if end_idx == -1:
167
+ return None, "SKILL.md frontmatter is not properly closed (missing ---)"
168
+
169
+ yaml_content = content[3:end_idx].strip()
170
+ if not yaml_content:
171
+ return None, "SKILL.md frontmatter is empty"
172
+
173
+ try:
174
+ metadata = yaml.safe_load(yaml_content)
175
+ if not isinstance(metadata, dict):
176
+ return None, "SKILL.md frontmatter must be a YAML mapping"
177
+ return metadata, None
178
+ except yaml.YAMLError as e:
179
+ return None, f"Failed to parse SKILL.md frontmatter: {e}"
180
+
181
+
182
+ def _find_resource_references(content: str) -> list[str]:
183
+ """Find all resource references in markdown content.
184
+
185
+ Looks for markdown links like [text](resources/file.md)
186
+
187
+ Args:
188
+ content: The markdown content.
189
+
190
+ Returns:
191
+ List of resource paths referenced.
192
+ """
193
+ # Match markdown links to resources/ directory
194
+ pattern = r"\[.*?\]\((resources/[^)]+)\)"
195
+ matches = re.findall(pattern, content)
196
+ return matches
197
+
198
+
199
+ def validate_skill_directory(skill_dir: Path) -> list[ValidationResult]:
200
+ """Validate a skill directory.
201
+
202
+ Args:
203
+ skill_dir: Path to the skill directory.
204
+
205
+ Returns:
206
+ List of ValidationResult objects.
207
+ """
208
+ results: list[ValidationResult] = []
209
+ dir_name = skill_dir.name
210
+
211
+ # Check directory exists
212
+ if not skill_dir.is_dir():
213
+ results.append(
214
+ ValidationResult(
215
+ check_name="skill_directory_exists",
216
+ status="fail",
217
+ message=f"Skill directory does not exist: {skill_dir}",
218
+ )
219
+ )
220
+ return results
221
+
222
+ # Check SKILL.md exists
223
+ skill_md = skill_dir / "SKILL.md"
224
+ if not skill_md.exists():
225
+ results.append(
226
+ ValidationResult(
227
+ check_name="skill_md_exists",
228
+ status="fail",
229
+ message=f"SKILL.md not found in {skill_dir}",
230
+ fix_hint=f"Create {skill_md}",
231
+ )
232
+ )
233
+ return results
234
+
235
+ # Read and parse frontmatter
236
+ try:
237
+ content = skill_md.read_text()
238
+ except OSError as e:
239
+ results.append(
240
+ ValidationResult(
241
+ check_name="skill_md_readable",
242
+ status="fail",
243
+ message=f"Failed to read SKILL.md: {e}",
244
+ )
245
+ )
246
+ return results
247
+
248
+ metadata, parse_error = _parse_frontmatter(content)
249
+ if parse_error:
250
+ results.append(
251
+ ValidationResult(
252
+ check_name="frontmatter_valid",
253
+ status="fail",
254
+ message=parse_error,
255
+ )
256
+ )
257
+ return results
258
+
259
+ assert metadata is not None # mypy
260
+
261
+ # Validate required fields
262
+ name = metadata.get("name")
263
+ if not name:
264
+ results.append(
265
+ ValidationResult(
266
+ check_name="name_required",
267
+ status="fail",
268
+ message="Skill name is required in frontmatter",
269
+ fix_hint="Add 'name: your-skill-name' to the YAML frontmatter",
270
+ )
271
+ )
272
+ else:
273
+ name_errors = validate_name(name, dir_name)
274
+ if name_errors:
275
+ for error in name_errors:
276
+ results.append(
277
+ ValidationResult(
278
+ check_name="name_valid",
279
+ status="fail",
280
+ message=f"Invalid skill name: {error}",
281
+ )
282
+ )
283
+ else:
284
+ results.append(
285
+ ValidationResult(
286
+ check_name="name_valid",
287
+ status="pass",
288
+ message=f"Skill name '{name}' is valid",
289
+ )
290
+ )
291
+
292
+ description = metadata.get("description")
293
+ if not description:
294
+ results.append(
295
+ ValidationResult(
296
+ check_name="description_required",
297
+ status="fail",
298
+ message="Skill description is required in frontmatter",
299
+ fix_hint="Add 'description: Your skill description' to the YAML frontmatter",
300
+ )
301
+ )
302
+ else:
303
+ desc_errors = validate_description(description)
304
+ if desc_errors:
305
+ for error in desc_errors:
306
+ results.append(
307
+ ValidationResult(
308
+ check_name="description_valid",
309
+ status="fail",
310
+ message=f"Invalid skill description: {error}",
311
+ )
312
+ )
313
+ else:
314
+ results.append(
315
+ ValidationResult(
316
+ check_name="description_valid",
317
+ status="pass",
318
+ message="Skill description is valid",
319
+ )
320
+ )
321
+
322
+ # Validate optional compatibility field if present
323
+ compatibility = metadata.get("compatibility")
324
+ if compatibility is not None:
325
+ compat_errors = validate_compatibility(compatibility)
326
+ for error in compat_errors:
327
+ results.append(
328
+ ValidationResult(
329
+ check_name="compatibility_valid",
330
+ status="fail",
331
+ message=f"Invalid compatibility field: {error}",
332
+ )
333
+ )
334
+
335
+ # Check for unknown fields
336
+ field_errors = validate_metadata_fields(metadata)
337
+ for error in field_errors:
338
+ results.append(
339
+ ValidationResult(
340
+ check_name="fields_valid",
341
+ status="warn",
342
+ message=error,
343
+ )
344
+ )
345
+
346
+ # Validate resource references
347
+ resource_refs = _find_resource_references(content)
348
+ for ref in resource_refs:
349
+ ref_path = skill_dir / ref
350
+ if not ref_path.exists():
351
+ results.append(
352
+ ValidationResult(
353
+ check_name="resource_exists",
354
+ status="warn",
355
+ message=f"Referenced resource does not exist: {ref}",
356
+ details=f"Expected file at: {ref_path}",
357
+ fix_hint=f"Create {ref_path} or remove the reference",
358
+ )
359
+ )
360
+
361
+ return results
362
+
363
+
364
+ class SkillValidator:
365
+ """Validator for Claude Code plugin skills."""
366
+
367
+ name = "skill_validator"
368
+ description = "Validates skill directories per agentskills.io specification"
369
+
370
+ async def validate(
371
+ self,
372
+ context: ValidationContext,
373
+ ) -> list[ValidationResult]:
374
+ """Validate all skills in the configured plugins.
375
+
376
+ Args:
377
+ context: The validation context.
378
+
379
+ Returns:
380
+ List of validation results.
381
+ """
382
+
383
+ results: list[ValidationResult] = []
384
+
385
+ # Find skill directories from the config
386
+ for target in context.config.targets:
387
+ if target.type != "claude":
388
+ continue
389
+
390
+ for _mp_name, mp_config in target.config.marketplaces.items():
391
+ if mp_config.source.value == "local":
392
+ mp_path = Path(mp_config.path)
393
+ if not mp_path.exists():
394
+ continue
395
+
396
+ # Look for plugins in the marketplace
397
+ for plugin_dir in mp_path.iterdir():
398
+ if not plugin_dir.is_dir():
399
+ continue
400
+ if plugin_dir.name.startswith("."):
401
+ continue
402
+
403
+ # Look for skills directory
404
+ skills_dir = plugin_dir / "skills"
405
+ if skills_dir.is_dir():
406
+ for skill_dir in skills_dir.iterdir():
407
+ if skill_dir.is_dir():
408
+ skill_results = validate_skill_directory(skill_dir)
409
+ results.extend(skill_results)
410
+
411
+ return results