Setting the file. One moment.
Quick Validate · Skill Authoring · contentful/skills · Skills Docs
ContentsBack to the top of the page scripts/quick_validate.py
scripts/ quick_validate.py
Python · 174 lines · 6 KB
16 def parse_yaml_frontmatter (text):
17 """
18 Minimal YAML frontmatter parser for simple key: value and key: >- multiline strings.
19 Sufficient for skill frontmatter; does not handle the full YAML spec.
20 """
21 result = {}
22 lines = text.split( ' \n ' )
23 i = 0
24 while i < len (lines):
25 line = lines[i]
26 # Skip blank lines and comments
27 if not line.strip() or line.strip().startswith( '#' ):
28 i += 1
29 continue
30 # Match key: value
31 m = re.match( r ' ^([ a-z ][ a-z0-9_- ] * )\s * : \s * (. * ) ' , line)
32 if not m:
33 i += 1
34 continue
35 key = m.group( 1 )
36 value = m.group( 2 ).strip()
37 # Multiline block scalar (>- or |-)
38 if value in ( '>-' , '|-' , '>' , '|' ):
39 parts = []
40 i += 1
41 while i < len (lines):
42 if lines[i].strip() == '' or not lines[i][ 0 ].isspace():
43 break
44 parts.append(lines[i].strip())
45 i += 1
46 result[key] = ' ' .join(parts) if value.startswith( '>' ) else ' \n ' .join(parts)
47 continue
48 # Quoted or plain scalar
49 if value.startswith( '"' ) and value.endswith( '"' ):
50 value = value[ 1 : - 1 ]
51 elif value.startswith( "'" ) and value.endswith( "'" ):
52 value = value[ 1 : - 1 ]
53 result[key] = value
54 i += 1
55 return result
56
57
58 def validate_skill (skill_path):
59 """Basic validation of a skill."""
60 skill_path = Path(skill_path)
61
62 # Check SKILL.md exists
63 skill_md = skill_path / 'SKILL.md'
64 if not skill_md.exists():
65 return False , "SKILL.md not found"
66
67 # Read and validate frontmatter
68 content = skill_md.read_text()
69 if not content.startswith( '---' ):
70 return False , "No YAML frontmatter found"
71
72 # Extract frontmatter
73 match = re.match( r ' ^ --- \n (. *? ) \n ---' , content, re. DOTALL )
74 if not match:
75 return False , "Invalid frontmatter format"
76
77 frontmatter_text = match.group( 1 )
78
79 # Parse frontmatter
80 try :
81 frontmatter = parse_yaml_frontmatter(frontmatter_text)
82 if not isinstance (frontmatter, dict ):
83 return False , "Frontmatter must be a YAML dictionary"
84 except Exception as e:
85 return False , f "Invalid frontmatter: { e } "
86
87 # Define allowed properties
88 ALLOWED_PROPERTIES = { 'name' , 'description' , 'license' , 'allowed-tools' , 'metadata' , 'compatibility' , 'argument-hint' , 'arguments' , 'paths' }
89
90 # Check for unexpected properties
91 unexpected_keys = set (frontmatter.keys()) - ALLOWED_PROPERTIES
92 if unexpected_keys:
93 return False , (
94 f "Unexpected key(s) in SKILL.md frontmatter: { ', ' .join( sorted (unexpected_keys)) } . "
95 f "Allowed properties are: { ', ' .join( sorted ( ALLOWED_PROPERTIES )) } "
96 )
97
98 # Check required fields
99 if 'name' not in frontmatter:
100 return False , "Missing 'name' in frontmatter"
101 if 'description' not in frontmatter:
102 return False , "Missing 'description' in frontmatter"
103
104 # Validate name
105 name = frontmatter.get( 'name' , '' )
106 if not isinstance (name, str ):
107 return False , f "Name must be a string, got { type (name). __name__ } "
108 name = name.strip()
109 if name:
110 if not re.match( r ' ^[ a-z0-9- ] + $ ' , name):
111 return False , f "Name ' { name } ' should be kebab-case (lowercase letters, digits, and hyphens only)"
112 if name.startswith( '-' ) or name.endswith( '-' ) or '--' in name:
113 return False , f "Name ' { name } ' cannot start/end with hyphen or contain consecutive hyphens"
114 if len (name) > 64 :
115 return False , f "Name is too long ( { len (name) } characters). Maximum is 64 characters."
116
117 # Validate description
118 description = frontmatter.get( 'description' , '' )
119 if not isinstance (description, str ):
120 return False , f "Description must be a string, got { type (description). __name__ } "
121 description = description.strip()
122 if description:
123 if '<' in description or '>' in description:
124 return False , "Description cannot contain angle brackets (< or >)"
125 if len (description) > 1024 :
126 return False , f "Description is too long ( { len (description) } characters). Maximum is 1024 characters."
127
128 # Validate compatibility field if present
129 compatibility = frontmatter.get( 'compatibility' , '' )
130 if compatibility:
131 if not isinstance (compatibility, str ):
132 return False , f "Compatibility must be a string, got { type (compatibility). __name__ } "
133 if len (compatibility) > 500 :
134 return False , f "Compatibility is too long ( { len (compatibility) } characters). Maximum is 500 characters."
135
136 return True , "Skill is valid!"
137
138
139 def find_skills (root_path):
140 """Find all skill directories under the given path (those containing SKILL.md)."""
141 root = Path(root_path)
142 return sorted ( set (p.parent for p in root.rglob( 'SKILL.md' )))
143
144
145 if __name__ == "__main__" :
146 if len (sys.argv) < 2 :
147 print ( "Usage: python quick_validate.py <skill_directory_or_root> [--all]" )
148 sys.exit( 1 )
149
150 target = sys.argv[ 1 ]
151 run_all = '--all' in sys.argv
152
153 if run_all:
154 # Validate every skill under the target directory
155 skill_dirs = find_skills(target)
156 if not skill_dirs:
157 print ( f "No skills found under { target } " )
158 sys.exit( 1 )
159
160 failed = []
161 for skill_dir in skill_dirs:
162 valid, message = validate_skill(skill_dir)
163 status = "✓" if valid else "✗"
164 print ( f " { status } { skill_dir.relative_to(target) } : { message } " )
165 if not valid:
166 failed.append(skill_dir)
167
168 print ()
169 print ( f "Validated { len (skill_dirs) } skill(s): { len (skill_dirs) - len (failed) } passed, { len (failed) } failed" )
170 sys.exit( 1 if failed else 0 )
171 else :
172 valid, message = validate_skill(target)
173 print (message)
174 sys.exit( 0 if valid else 1 )