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.
- ai_config/__init__.py +3 -0
- ai_config/__main__.py +6 -0
- ai_config/adapters/__init__.py +1 -0
- ai_config/adapters/claude.py +353 -0
- ai_config/cli.py +729 -0
- ai_config/cli_render.py +525 -0
- ai_config/cli_theme.py +44 -0
- ai_config/config.py +260 -0
- ai_config/init.py +763 -0
- ai_config/operations.py +357 -0
- ai_config/scaffold.py +87 -0
- ai_config/settings.py +63 -0
- ai_config/types.py +143 -0
- ai_config/validators/__init__.py +149 -0
- ai_config/validators/base.py +48 -0
- ai_config/validators/component/__init__.py +1 -0
- ai_config/validators/component/hook.py +366 -0
- ai_config/validators/component/mcp.py +230 -0
- ai_config/validators/component/skill.py +411 -0
- ai_config/validators/context.py +69 -0
- ai_config/validators/marketplace/__init__.py +1 -0
- ai_config/validators/marketplace/validators.py +433 -0
- ai_config/validators/plugin/__init__.py +1 -0
- ai_config/validators/plugin/validators.py +336 -0
- ai_config/validators/target/__init__.py +1 -0
- ai_config/validators/target/claude.py +154 -0
- ai_config/watch.py +279 -0
- ai_config_cli-0.1.0.dist-info/METADATA +235 -0
- ai_config_cli-0.1.0.dist-info/RECORD +32 -0
- ai_config_cli-0.1.0.dist-info/WHEEL +4 -0
- ai_config_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ai_config_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|