validate.ps1 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. # claude-mods validation script (PowerShell)
  2. # Validates YAML frontmatter, required fields, and naming conventions
  3. param(
  4. [switch]$YamlOnly,
  5. [switch]$NamesOnly
  6. )
  7. $ErrorActionPreference = "Stop"
  8. # Counters
  9. $script:Pass = 0
  10. $script:Fail = 0
  11. $script:Warn = 0
  12. # Get project directory
  13. $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
  14. $ProjectDir = Split-Path -Parent $ScriptDir
  15. function Write-Pass {
  16. param([string]$Message)
  17. Write-Host "PASS" -ForegroundColor Green -NoNewline
  18. Write-Host ": $Message"
  19. $script:Pass++
  20. }
  21. function Write-Fail {
  22. param([string]$Message)
  23. Write-Host "FAIL" -ForegroundColor Red -NoNewline
  24. Write-Host ": $Message"
  25. $script:Fail++
  26. }
  27. function Write-Warn {
  28. param([string]$Message)
  29. Write-Host "WARN" -ForegroundColor Yellow -NoNewline
  30. Write-Host ": $Message"
  31. $script:Warn++
  32. }
  33. function Test-YamlFrontmatter {
  34. param([string]$FilePath)
  35. $content = Get-Content -Path $FilePath -Raw
  36. # Check for opening ---
  37. if (-not $content.StartsWith("---")) {
  38. Write-Fail "$FilePath - Missing YAML frontmatter (no opening ---)"
  39. return $false
  40. }
  41. # Check for closing ---
  42. $lines = $content -split "`n"
  43. $foundClosing = $false
  44. for ($i = 1; $i -lt $lines.Count; $i++) {
  45. if ($lines[$i].Trim() -eq "---") {
  46. $foundClosing = $true
  47. break
  48. }
  49. }
  50. if (-not $foundClosing) {
  51. Write-Fail "$FilePath - Invalid YAML frontmatter (no closing ---)"
  52. return $false
  53. }
  54. return $true
  55. }
  56. function Get-YamlField {
  57. param(
  58. [string]$FilePath,
  59. [string]$Field
  60. )
  61. $content = Get-Content -Path $FilePath -Raw
  62. $lines = $content -split "`n"
  63. $inFrontmatter = $false
  64. foreach ($line in $lines) {
  65. if ($line.Trim() -eq "---") {
  66. if ($inFrontmatter) { break }
  67. $inFrontmatter = $true
  68. continue
  69. }
  70. if ($inFrontmatter -and $line -match "^${Field}:\s*(.+)$") {
  71. $value = $matches[1].Trim()
  72. # Remove quotes
  73. $value = $value -replace '^["'']|["'']$', ''
  74. return $value
  75. }
  76. }
  77. return $null
  78. }
  79. function Test-RequiredFields {
  80. param(
  81. [string]$FilePath,
  82. [string]$Type
  83. )
  84. $name = Get-YamlField -FilePath $FilePath -Field "name"
  85. $description = Get-YamlField -FilePath $FilePath -Field "description"
  86. # Agents require both name and description
  87. if ($Type -eq "agent") {
  88. if (-not $name) {
  89. Write-Fail "$FilePath - Missing required field: name"
  90. return $false
  91. }
  92. if (-not $description) {
  93. Write-Fail "$FilePath - Missing required field: description"
  94. return $false
  95. }
  96. }
  97. # Commands only require description
  98. if ($Type -eq "command") {
  99. if (-not $description) {
  100. Write-Fail "$FilePath - Missing required field: description"
  101. return $false
  102. }
  103. }
  104. return $true
  105. }
  106. function Test-Naming {
  107. param([string]$FilePath)
  108. $basename = [System.IO.Path]::GetFileNameWithoutExtension($FilePath)
  109. # Check if filename is kebab-case
  110. if ($basename -notmatch "^[a-z][a-z0-9]*(-[a-z0-9]+)*$") {
  111. Write-Warn "$FilePath - Filename not kebab-case: $basename"
  112. return $false
  113. }
  114. # Check if name field matches filename
  115. $name = Get-YamlField -FilePath $FilePath -Field "name"
  116. if ($name -and $name -ne $basename) {
  117. Write-Warn "$FilePath - Name field '$name' doesn't match filename '$basename'"
  118. return $false
  119. }
  120. return $true
  121. }
  122. function Test-Agents {
  123. Write-Host ""
  124. Write-Host "=== Validating Agents ===" -ForegroundColor Cyan
  125. $agentDir = Join-Path $ProjectDir "agents"
  126. if (-not (Test-Path $agentDir)) {
  127. Write-Warn "agents/ directory not found"
  128. return
  129. }
  130. $files = Get-ChildItem -Path $agentDir -Filter "*.md" -File
  131. foreach ($file in $files) {
  132. if (-not $NamesOnly) {
  133. if (Test-YamlFrontmatter -FilePath $file.FullName) {
  134. if (Test-RequiredFields -FilePath $file.FullName -Type "agent") {
  135. Write-Pass "$($file.FullName) - Valid agent"
  136. }
  137. }
  138. }
  139. if (-not $YamlOnly) {
  140. Test-Naming -FilePath $file.FullName | Out-Null
  141. }
  142. }
  143. }
  144. function Test-Commands {
  145. Write-Host ""
  146. Write-Host "=== Validating Commands ===" -ForegroundColor Cyan
  147. $cmdDir = Join-Path $ProjectDir "commands"
  148. if (-not (Test-Path $cmdDir)) {
  149. Write-Warn "commands/ directory not found"
  150. return
  151. }
  152. # Check .md files directly in commands/
  153. $files = Get-ChildItem -Path $cmdDir -Filter "*.md" -File
  154. foreach ($file in $files) {
  155. if (-not $NamesOnly) {
  156. if (Test-YamlFrontmatter -FilePath $file.FullName) {
  157. if (Test-RequiredFields -FilePath $file.FullName -Type "command") {
  158. Write-Pass "$($file.FullName) - Valid command"
  159. }
  160. }
  161. }
  162. if (-not $YamlOnly) {
  163. Test-Naming -FilePath $file.FullName | Out-Null
  164. }
  165. }
  166. # Check subdirectories
  167. $subdirs = Get-ChildItem -Path $cmdDir -Directory
  168. foreach ($subdir in $subdirs) {
  169. $subFiles = Get-ChildItem -Path $subdir.FullName -Filter "*.md" -File
  170. foreach ($file in $subFiles) {
  171. # Skip README and LICENSE files
  172. if ($file.Name -eq "README.md" -or $file.Name -eq "LICENSE.md") {
  173. continue
  174. }
  175. if (-not $NamesOnly) {
  176. if (Test-YamlFrontmatter -FilePath $file.FullName) {
  177. $desc = Get-YamlField -FilePath $file.FullName -Field "description"
  178. if ($desc) {
  179. Write-Pass "$($file.FullName) - Valid subcommand"
  180. } else {
  181. Write-Warn "$($file.FullName) - Missing description"
  182. }
  183. }
  184. }
  185. }
  186. }
  187. }
  188. function Test-Skills {
  189. Write-Host ""
  190. Write-Host "=== Validating Skills ===" -ForegroundColor Cyan
  191. $skillsDir = Join-Path $ProjectDir "skills"
  192. if (-not (Test-Path $skillsDir)) {
  193. Write-Warn "skills/ directory not found"
  194. return
  195. }
  196. $subdirs = Get-ChildItem -Path $skillsDir -Directory
  197. foreach ($subdir in $subdirs) {
  198. $skillFile = Join-Path $subdir.FullName "SKILL.md"
  199. if (-not (Test-Path $skillFile)) {
  200. Write-Fail "$($subdir.FullName) - Missing SKILL.md"
  201. continue
  202. }
  203. if (-not $NamesOnly) {
  204. if (Test-YamlFrontmatter -FilePath $skillFile) {
  205. $name = Get-YamlField -FilePath $skillFile -Field "name"
  206. $desc = Get-YamlField -FilePath $skillFile -Field "description"
  207. if ($name -and $desc) {
  208. Write-Pass "$skillFile - Valid skill"
  209. } else {
  210. if (-not $name) { Write-Fail "$skillFile - Missing name" }
  211. if (-not $desc) { Write-Fail "$skillFile - Missing description" }
  212. }
  213. }
  214. }
  215. }
  216. }
  217. function Test-Rules {
  218. Write-Host ""
  219. Write-Host "=== Validating Rules ===" -ForegroundColor Cyan
  220. $rulesDir = Join-Path $ProjectDir "templates\rules"
  221. if (-not (Test-Path $rulesDir)) {
  222. Write-Host " (no templates/rules/ directory - skipping)"
  223. return
  224. }
  225. $files = Get-ChildItem -Path $rulesDir -Filter "*.md" -File -Recurse
  226. foreach ($file in $files) {
  227. # Rules should be .md files
  228. if ($file.Extension -ne ".md") {
  229. Write-Warn "$($file.FullName) - Rule file should be .md"
  230. continue
  231. }
  232. # Check if file has content
  233. if ($file.Length -eq 0) {
  234. Write-Fail "$($file.FullName) - Empty rule file"
  235. continue
  236. }
  237. $content = Get-Content -Path $file.FullName -Raw
  238. # Check for valid YAML frontmatter if present
  239. if ($content.StartsWith("---")) {
  240. $lines = $content -split "`n"
  241. $foundClosing = $false
  242. for ($i = 1; $i -lt $lines.Count; $i++) {
  243. if ($lines[$i].Trim() -eq "---") {
  244. $foundClosing = $true
  245. break
  246. }
  247. }
  248. if (-not $foundClosing) {
  249. Write-Fail "$($file.FullName) - Invalid YAML frontmatter (no closing ---)"
  250. continue
  251. }
  252. # If paths field exists, check it's not empty
  253. $paths = Get-YamlField -FilePath $file.FullName -Field "paths"
  254. if ($content -match "^paths:" -and -not $paths) {
  255. Write-Warn "$($file.FullName) - paths field is empty"
  256. }
  257. }
  258. # Check naming convention (kebab-case)
  259. $basename = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
  260. if ($basename -notmatch "^[a-z][a-z0-9]*(-[a-z0-9]+)*$") {
  261. Write-Warn "$($file.FullName) - Filename not kebab-case: $basename"
  262. }
  263. Write-Pass "$($file.FullName) - Valid rule"
  264. }
  265. }
  266. function Test-Settings {
  267. Write-Host ""
  268. Write-Host "=== Validating Settings ===" -ForegroundColor Cyan
  269. $settingsFile = Join-Path $ProjectDir "templates\settings.local.json"
  270. if (-not (Test-Path $settingsFile)) {
  271. Write-Host " (no templates/settings.local.json - skipping)"
  272. return
  273. }
  274. # Check if valid JSON
  275. try {
  276. $settings = Get-Content -Path $settingsFile -Raw | ConvertFrom-Json
  277. } catch {
  278. Write-Fail "$settingsFile - Invalid JSON"
  279. return
  280. }
  281. # Check for permissions structure
  282. if (-not $settings.permissions) {
  283. Write-Fail "$settingsFile - Missing 'permissions' key"
  284. } else {
  285. # Check permissions has allow array
  286. if (-not ($settings.permissions.allow -is [array])) {
  287. Write-Fail "$settingsFile - permissions.allow should be an array"
  288. } else {
  289. Write-Pass "$settingsFile - Valid permissions structure"
  290. }
  291. }
  292. # Check for hooks structure (optional but if present should be object)
  293. if ($settings.hooks) {
  294. $validEvents = @("PreToolUse", "PostToolUse", "PermissionRequest", "Notification",
  295. "UserPromptSubmit", "Stop", "SubagentStop", "PreCompact",
  296. "SessionStart", "SessionEnd")
  297. $hookEvents = $settings.hooks.PSObject.Properties.Name
  298. foreach ($event in $hookEvents) {
  299. if ($event -notin $validEvents) {
  300. Write-Warn "$settingsFile - Unknown hook event: $event"
  301. }
  302. }
  303. if ($hookEvents.Count -gt 0) {
  304. Write-Pass "$settingsFile - Valid hooks structure"
  305. } else {
  306. Write-Pass "$settingsFile - Hooks defined (empty)"
  307. }
  308. }
  309. }
  310. # Main
  311. Write-Host "claude-mods Validation"
  312. Write-Host "======================"
  313. Write-Host "Project: $ProjectDir"
  314. Test-Agents
  315. Test-Commands
  316. Test-Skills
  317. Test-Rules
  318. Test-Settings
  319. Write-Host ""
  320. Write-Host "======================"
  321. Write-Host "Results: " -NoNewline
  322. Write-Host "$script:Pass passed" -ForegroundColor Green -NoNewline
  323. Write-Host ", " -NoNewline
  324. Write-Host "$script:Fail failed" -ForegroundColor Red -NoNewline
  325. Write-Host ", " -NoNewline
  326. Write-Host "$script:Warn warnings" -ForegroundColor Yellow
  327. if ($script:Fail -gt 0) {
  328. exit 1
  329. }
  330. exit 0