|
6 | 6 | from __future__ import annotations |
7 | 7 |
|
8 | 8 | import json |
| 9 | +import re |
9 | 10 | import subprocess |
10 | 11 | import sys |
11 | 12 | from pathlib import Path |
|
54 | 55 | "evals/evals.json", |
55 | 56 | ] |
56 | 57 |
|
| 58 | +MAX_SKILL_NAME_LENGTH = 64 |
| 59 | + |
| 60 | + |
| 61 | +def parse_frontmatter_minimal(frontmatter_text: str) -> tuple[dict | None, str | None]: |
| 62 | + parsed: dict[str, object] = {} |
| 63 | + |
| 64 | + for raw_line in frontmatter_text.splitlines(): |
| 65 | + if not raw_line.strip(): |
| 66 | + continue |
| 67 | + if raw_line.startswith(" ") or raw_line.startswith("\t"): |
| 68 | + continue |
| 69 | + if ":" not in raw_line: |
| 70 | + return None, f"Unsupported frontmatter line: {raw_line}" |
| 71 | + |
| 72 | + key, value = raw_line.split(":", 1) |
| 73 | + key = key.strip() |
| 74 | + value = value.strip() |
| 75 | + |
| 76 | + if not key: |
| 77 | + return None, f"Invalid frontmatter key in line: {raw_line}" |
| 78 | + |
| 79 | + if value.startswith('"') and value.endswith('"') and len(value) >= 2: |
| 80 | + parsed[key] = value[1:-1] |
| 81 | + elif value.startswith("'") and value.endswith("'") and len(value) >= 2: |
| 82 | + parsed[key] = value[1:-1] |
| 83 | + elif value == "": |
| 84 | + parsed[key] = None |
| 85 | + else: |
| 86 | + parsed[key] = value |
| 87 | + |
| 88 | + return parsed, None |
| 89 | + |
| 90 | + |
| 91 | +def local_quick_validate(skill_dir: Path) -> tuple[bool, str]: |
| 92 | + skill_md = skill_dir / "SKILL.md" |
| 93 | + if not skill_md.exists(): |
| 94 | + return False, "SKILL.md not found" |
| 95 | + |
| 96 | + content = skill_md.read_text() |
| 97 | + if not content.startswith("---"): |
| 98 | + return False, "No YAML frontmatter found" |
| 99 | + |
| 100 | + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) |
| 101 | + if not match: |
| 102 | + return False, "Invalid frontmatter format" |
| 103 | + |
| 104 | + frontmatter_text = match.group(1) |
| 105 | + |
| 106 | + frontmatter, parse_error = parse_frontmatter_minimal(frontmatter_text) |
| 107 | + if parse_error: |
| 108 | + return False, parse_error |
| 109 | + if not isinstance(frontmatter, dict): |
| 110 | + return False, "Frontmatter must be a YAML dictionary" |
| 111 | + |
| 112 | + allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"} |
| 113 | + unexpected_keys = set(frontmatter.keys()) - allowed_properties |
| 114 | + if unexpected_keys: |
| 115 | + unexpected = ", ".join(sorted(unexpected_keys)) |
| 116 | + allowed = ", ".join(sorted(allowed_properties)) |
| 117 | + return ( |
| 118 | + False, |
| 119 | + f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", |
| 120 | + ) |
| 121 | + |
| 122 | + if "name" not in frontmatter: |
| 123 | + return False, "Missing 'name' in frontmatter" |
| 124 | + if "description" not in frontmatter: |
| 125 | + return False, "Missing 'description' in frontmatter" |
| 126 | + |
| 127 | + name = frontmatter.get("name", "") |
| 128 | + if not isinstance(name, str): |
| 129 | + return False, f"Name must be a string, got {type(name).__name__}" |
| 130 | + name = name.strip() |
| 131 | + if not re.match(r"^[a-z0-9-]+$", name): |
| 132 | + return False, f"Name '{name}' should be hyphen-case" |
| 133 | + if name.startswith("-") or name.endswith("-") or "--" in name: |
| 134 | + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" |
| 135 | + if len(name) > MAX_SKILL_NAME_LENGTH: |
| 136 | + return False, f"Name is too long ({len(name)} characters). Maximum is {MAX_SKILL_NAME_LENGTH}." |
| 137 | + |
| 138 | + description = frontmatter.get("description", "") |
| 139 | + if not isinstance(description, str): |
| 140 | + return False, f"Description must be a string, got {type(description).__name__}" |
| 141 | + description = description.strip() |
| 142 | + if "<" in description or ">" in description: |
| 143 | + return False, "Description cannot contain angle brackets (< or >)" |
| 144 | + if len(description) > 1024: |
| 145 | + return False, f"Description is too long ({len(description)} characters). Maximum is 1024." |
| 146 | + |
| 147 | + return True, "Local fallback validation passed." |
| 148 | + |
57 | 149 |
|
58 | 150 | def run_quick_validate(skill_dir: Path) -> tuple[bool, str]: |
59 | 151 | validator = Path.home() / ".codex/skills/.system/skill-creator/scripts/quick_validate.py" |
60 | | - proc = subprocess.run( |
61 | | - [sys.executable, str(validator), str(skill_dir)], |
62 | | - capture_output=True, |
63 | | - text=True, |
64 | | - check=False, |
65 | | - ) |
66 | | - output = (proc.stdout + proc.stderr).strip() |
67 | | - return proc.returncode == 0, output |
| 152 | + if validator.exists(): |
| 153 | + proc = subprocess.run( |
| 154 | + [sys.executable, str(validator), str(skill_dir)], |
| 155 | + capture_output=True, |
| 156 | + text=True, |
| 157 | + check=False, |
| 158 | + ) |
| 159 | + output = (proc.stdout + proc.stderr).strip() |
| 160 | + return proc.returncode == 0, output |
| 161 | + |
| 162 | + return local_quick_validate(skill_dir) |
68 | 163 |
|
69 | 164 |
|
70 | 165 | def check_file_exists(skill_dir: Path) -> tuple[bool, list[str]]: |
|
0 commit comments