返回 JoyAI-Echo
quick_validate.py
1 #!/usr/bin/env python3
2 """
3 Minimal validator for nanobot skill folders.
4 """
5
6 import re
7 import sys
8 from pathlib import Path
9 from typing import Optional
10
11 try:
12 import yaml
13 except ModuleNotFoundError:
14 yaml = None
15
16 MAX_SKILL_NAME_LENGTH = 64
17 ALLOWED_FRONTMATTER_KEYS = {
18 "name",
19 "description",
20 "metadata",
21 "always",
22 "license",
23 "allowed-tools",
24 }
25 ALLOWED_RESOURCE_DIRS = {"scripts", "references", "assets"}
26 PLACEHOLDER_MARKERS = ("[todo", "todo:")
27
28
29 def _extract_frontmatter(content: str) -> Optional[str]:
30 lines = content.splitlines()
31 if not lines or lines[0].strip() != "---":
32 return None
33 for i in range(1, len(lines)):
34 if lines[i].strip() == "---":
35 return "\n".join(lines[1:i])
36 return None
37
38
39 def _parse_simple_frontmatter(frontmatter_text: str) -> Optional[dict[str, str]]:
40 """Fallback parser for simple frontmatter when PyYAML is unavailable."""
41 parsed: dict[str, str] = {}
42 current_key: Optional[str] = None
43 multiline_key: Optional[str] = None
44
45 for raw_line in frontmatter_text.splitlines():
46 stripped = raw_line.strip()
47 if not stripped or stripped.startswith("#"):
48 continue
49
50 is_indented = raw_line[:1].isspace()
51 if is_indented:
52 if current_key is None:
53 return None
54 current_value = parsed[current_key]
55 parsed[current_key] = f"{current_value}\n{stripped}" if current_value else stripped
56 continue
57
58 if ":" not in stripped:
59 return None
60
61 key, value = stripped.split(":", 1)
62 key = key.strip()
63 value = value.strip()
64 if not key:
65 return None
66
67 if value in {"|", ">"}:
68 parsed[key] = ""
69 current_key = key
70 multiline_key = key
71 continue
72
73 if (value.startswith('"') and value.endswith('"')) or (
74 value.startswith("'") and value.endswith("'")
75 ):
76 value = value[1:-1]
77 parsed[key] = value
78 current_key = key
79 multiline_key = None
80
81 if multiline_key is not None and multiline_key not in parsed:
82 return None
83 return parsed
84
85
86 def _load_frontmatter(frontmatter_text: str) -> tuple[Optional[dict], Optional[str]]:
87 if yaml is not None:
88 try:
89 frontmatter = yaml.safe_load(frontmatter_text)
90 except yaml.YAMLError as exc:
91 return None, f"Invalid YAML in frontmatter: {exc}"
92 if not isinstance(frontmatter, dict):
93 return None, "Frontmatter must be a YAML dictionary"
94 return frontmatter, None
95
96 frontmatter = _parse_simple_frontmatter(frontmatter_text)
97 if frontmatter is None:
98 return None, "Invalid YAML in frontmatter: unsupported syntax without PyYAML installed"
99 return frontmatter, None
100
101
102 def _validate_skill_name(name: str, folder_name: str) -> Optional[str]:
103 if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
104 return (
105 f"Name '{name}' should be hyphen-case "
106 "(lowercase letters, digits, and single hyphens only)"
107 )
108 if len(name) > MAX_SKILL_NAME_LENGTH:
109 return (
110 f"Name is too long ({len(name)} characters). "
111 f"Maximum is {MAX_SKILL_NAME_LENGTH} characters."
112 )
113 if name != folder_name:
114 return f"Skill name '{name}' must match directory name '{folder_name}'"
115 return None
116
117
118 def _validate_description(description: str) -> Optional[str]:
119 trimmed = description.strip()
120 if not trimmed:
121 return "Description cannot be empty"
122 lowered = trimmed.lower()
123 if any(marker in lowered for marker in PLACEHOLDER_MARKERS):
124 return "Description still contains TODO placeholder text"
125 if "<" in trimmed or ">" in trimmed:
126 return "Description cannot contain angle brackets (< or >)"
127 if len(trimmed) > 1024:
128 return f"Description is too long ({len(trimmed)} characters). Maximum is 1024 characters."
129 return None
130
131
132 def validate_skill(skill_path):
133 """Validate a skill folder structure and required frontmatter."""
134 skill_path = Path(skill_path).resolve()
135
136 if not skill_path.exists():
137 return False, f"Skill folder not found: {skill_path}"
138 if not skill_path.is_dir():
139 return False, f"Path is not a directory: {skill_path}"
140
141 skill_md = skill_path / "SKILL.md"
142 if not skill_md.exists():
143 return False, "SKILL.md not found"
144
145 try:
146 content = skill_md.read_text(encoding="utf-8")
147 except OSError as exc:
148 return False, f"Could not read SKILL.md: {exc}"
149
150 frontmatter_text = _extract_frontmatter(content)
151 if frontmatter_text is None:
152 return False, "Invalid frontmatter format"
153
154 frontmatter, error = _load_frontmatter(frontmatter_text)
155 if error:
156 return False, error
157
158 unexpected_keys = sorted(set(frontmatter.keys()) - ALLOWED_FRONTMATTER_KEYS)
159 if unexpected_keys:
160 allowed = ", ".join(sorted(ALLOWED_FRONTMATTER_KEYS))
161 unexpected = ", ".join(unexpected_keys)
162 return (
163 False,
164 f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}",
165 )
166
167 if "name" not in frontmatter:
168 return False, "Missing 'name' in frontmatter"
169 if "description" not in frontmatter:
170 return False, "Missing 'description' in frontmatter"
171
172 name = frontmatter["name"]
173 if not isinstance(name, str):
174 return False, f"Name must be a string, got {type(name).__name__}"
175 name_error = _validate_skill_name(name.strip(), skill_path.name)
176 if name_error:
177 return False, name_error
178
179 description = frontmatter["description"]
180 if not isinstance(description, str):
181 return False, f"Description must be a string, got {type(description).__name__}"
182 description_error = _validate_description(description)
183 if description_error:
184 return False, description_error
185
186 always = frontmatter.get("always")
187 if always is not None and not isinstance(always, bool):
188 return False, f"'always' must be a boolean, got {type(always).__name__}"
189
190 for child in skill_path.iterdir():
191 if child.name == "SKILL.md":
192 continue
193 if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS:
194 continue
195 if child.is_symlink():
196 continue
197 return (
198 False,
199 f"Unexpected file or directory in skill root: {child.name}. "
200 "Only SKILL.md, scripts/, references/, and assets/ are allowed.",
201 )
202
203 return True, "Skill is valid!"
204
205
206 if __name__ == "__main__":
207 if len(sys.argv) != 2:
208 print("Usage: python quick_validate.py <skill_directory>")
209 sys.exit(1)
210
211 valid, message = validate_skill(sys.argv[1])
212 print(message)
213 sys.exit(0 if valid else 1)
214
214 lines PYTHON