| 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 |