validate.ps1 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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. # Skip shared helper dirs (e.g. _lib) - not skills, no SKILL.md expected.
  199. if ($subdir.Name -like "_*") { continue }
  200. $skillFile = Join-Path $subdir.FullName "SKILL.md"
  201. if (-not (Test-Path $skillFile)) {
  202. Write-Fail "$($subdir.FullName) - Missing SKILL.md"
  203. continue
  204. }
  205. if (-not $NamesOnly) {
  206. if (Test-YamlFrontmatter -FilePath $skillFile) {
  207. $name = Get-YamlField -FilePath $skillFile -Field "name"
  208. $desc = Get-YamlField -FilePath $skillFile -Field "description"
  209. if ($name -and $desc) {
  210. Write-Pass "$skillFile - Valid skill"
  211. } else {
  212. if (-not $name) { Write-Fail "$skillFile - Missing name" }
  213. if (-not $desc) { Write-Fail "$skillFile - Missing description" }
  214. }
  215. }
  216. }
  217. }
  218. }
  219. function Test-Rules {
  220. Write-Host ""
  221. Write-Host "=== Validating Rules ===" -ForegroundColor Cyan
  222. $rulesDir = Join-Path $ProjectDir "templates\rules"
  223. if (-not (Test-Path $rulesDir)) {
  224. Write-Host " (no templates/rules/ directory - skipping)"
  225. return
  226. }
  227. $files = Get-ChildItem -Path $rulesDir -Filter "*.md" -File -Recurse
  228. foreach ($file in $files) {
  229. # Rules should be .md files
  230. if ($file.Extension -ne ".md") {
  231. Write-Warn "$($file.FullName) - Rule file should be .md"
  232. continue
  233. }
  234. # Check if file has content
  235. if ($file.Length -eq 0) {
  236. Write-Fail "$($file.FullName) - Empty rule file"
  237. continue
  238. }
  239. $content = Get-Content -Path $file.FullName -Raw
  240. # Check for valid YAML frontmatter if present
  241. if ($content.StartsWith("---")) {
  242. $lines = $content -split "`n"
  243. $foundClosing = $false
  244. for ($i = 1; $i -lt $lines.Count; $i++) {
  245. if ($lines[$i].Trim() -eq "---") {
  246. $foundClosing = $true
  247. break
  248. }
  249. }
  250. if (-not $foundClosing) {
  251. Write-Fail "$($file.FullName) - Invalid YAML frontmatter (no closing ---)"
  252. continue
  253. }
  254. # If paths field exists, check it's not empty
  255. $paths = Get-YamlField -FilePath $file.FullName -Field "paths"
  256. if ($content -match "^paths:" -and -not $paths) {
  257. Write-Warn "$($file.FullName) - paths field is empty"
  258. }
  259. }
  260. # Check naming convention (kebab-case)
  261. $basename = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
  262. if ($basename -notmatch "^[a-z][a-z0-9]*(-[a-z0-9]+)*$") {
  263. Write-Warn "$($file.FullName) - Filename not kebab-case: $basename"
  264. }
  265. Write-Pass "$($file.FullName) - Valid rule"
  266. }
  267. }
  268. function Test-Settings {
  269. Write-Host ""
  270. Write-Host "=== Validating Settings ===" -ForegroundColor Cyan
  271. $settingsFile = Join-Path $ProjectDir "templates\settings.local.json"
  272. if (-not (Test-Path $settingsFile)) {
  273. Write-Host " (no templates/settings.local.json - skipping)"
  274. return
  275. }
  276. # Check if valid JSON
  277. try {
  278. $settings = Get-Content -Path $settingsFile -Raw | ConvertFrom-Json
  279. } catch {
  280. Write-Fail "$settingsFile - Invalid JSON"
  281. return
  282. }
  283. # Check for permissions structure
  284. if (-not $settings.permissions) {
  285. Write-Fail "$settingsFile - Missing 'permissions' key"
  286. } else {
  287. # Check permissions has allow array
  288. if (-not ($settings.permissions.allow -is [array])) {
  289. Write-Fail "$settingsFile - permissions.allow should be an array"
  290. } else {
  291. Write-Pass "$settingsFile - Valid permissions structure"
  292. }
  293. }
  294. # Check for hooks structure (optional but if present should be object)
  295. if ($settings.hooks) {
  296. $validEvents = @("PreToolUse", "PostToolUse", "PermissionRequest", "Notification",
  297. "UserPromptSubmit", "Stop", "SubagentStop", "PreCompact",
  298. "SessionStart", "SessionEnd")
  299. $hookEvents = $settings.hooks.PSObject.Properties.Name
  300. foreach ($event in $hookEvents) {
  301. if ($event -notin $validEvents) {
  302. Write-Warn "$settingsFile - Unknown hook event: $event"
  303. }
  304. }
  305. if ($hookEvents.Count -gt 0) {
  306. Write-Pass "$settingsFile - Valid hooks structure"
  307. } else {
  308. Write-Pass "$settingsFile - Hooks defined (empty)"
  309. }
  310. }
  311. }
  312. function Test-Plugin {
  313. Write-Host ""
  314. Write-Host "=== Validating Plugin Manifests ===" -ForegroundColor Cyan
  315. # The authoritative validator is `claude plugin validate` (it tracks the
  316. # live schema - it caught a bad plugin `source` shape and an `author` type
  317. # error that a hand-rolled check sailed past). Prefer it when present; fall
  318. # back to lightweight structural checks otherwise. The stray-root-file guard
  319. # runs regardless - the official tool can't see a misplaced copy.
  320. $pluginDir = Join-Path $ProjectDir ".claude-plugin"
  321. $pluginFile = Join-Path $pluginDir "plugin.json"
  322. $mktFile = Join-Path $pluginDir "marketplace.json"
  323. # --- location guard ---
  324. if (Test-Path (Join-Path $ProjectDir "marketplace.json")) {
  325. Write-Fail "marketplace.json found at repo root - must live at .claude-plugin/marketplace.json"
  326. }
  327. if (-not (Test-Path $pluginFile)) { Write-Fail ".claude-plugin/plugin.json - Missing" }
  328. if (-not (Test-Path $mktFile)) { Write-Fail ".claude-plugin/marketplace.json - Missing (required for /plugin marketplace add)" }
  329. $claude = Get-Command claude -ErrorAction SilentlyContinue
  330. # --- authoritative path ---
  331. if ($claude) {
  332. & claude plugin validate $ProjectDir 2>&1 | Out-Null
  333. if ($LASTEXITCODE -eq 0) {
  334. Write-Pass "marketplace.json - claude plugin validate passed"
  335. } else {
  336. Write-Fail "marketplace.json - claude plugin validate failed (run: claude plugin validate .)"
  337. }
  338. if (Test-Path $pluginFile) {
  339. $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
  340. New-Item -ItemType Directory -Path (Join-Path $tmp ".claude-plugin") -Force | Out-Null
  341. Copy-Item $pluginFile (Join-Path $tmp ".claude-plugin\plugin.json")
  342. & claude plugin validate $tmp 2>&1 | Out-Null
  343. if ($LASTEXITCODE -eq 0) {
  344. Write-Pass "plugin.json - claude plugin validate passed"
  345. } else {
  346. Write-Fail "plugin.json - claude plugin validate failed (unrecognized keys or wrong field types)"
  347. }
  348. Remove-Item -Recurse -Force $tmp
  349. }
  350. return
  351. }
  352. # --- fallback path: lightweight structural checks ---
  353. Write-Warn "claude CLI not found - using lightweight manifest checks only (install Claude Code for authoritative validation)"
  354. if (Test-Path $pluginFile) {
  355. try {
  356. $plugin = Get-Content -Path $pluginFile -Raw | ConvertFrom-Json
  357. if ($plugin.name -is [string] -and $plugin.name) {
  358. Write-Pass "$pluginFile - structurally OK (name present)"
  359. } else {
  360. Write-Fail "$pluginFile - Missing required field: name"
  361. }
  362. } catch {
  363. Write-Fail "$pluginFile - Invalid JSON"
  364. }
  365. }
  366. if (Test-Path $mktFile) {
  367. try {
  368. $mkt = Get-Content -Path $mktFile -Raw | ConvertFrom-Json
  369. $ok = $true
  370. if (-not ($mkt.name -is [string] -and $mkt.name)) { Write-Fail "$mktFile - Missing required field: name (string)"; $ok = $false }
  371. if ($null -eq $mkt.owner -or -not ($mkt.owner.name -is [string] -and $mkt.owner.name)) { Write-Fail "$mktFile - owner.name missing (owner must be an object with a name)"; $ok = $false }
  372. if ($mkt.plugins -isnot [array]) { Write-Fail "$mktFile - Missing required field: plugins (array)"; $ok = $false }
  373. if ($ok) { Write-Pass "$mktFile - structurally OK (run claude plugin validate for full schema)" }
  374. } catch {
  375. Write-Fail "$mktFile - Invalid JSON"
  376. }
  377. }
  378. }
  379. # Main
  380. Write-Host "claude-mods Validation"
  381. Write-Host "======================"
  382. Write-Host "Project: $ProjectDir"
  383. Test-Agents
  384. Test-Commands
  385. Test-Skills
  386. Test-Rules
  387. Test-Settings
  388. Test-Plugin
  389. Write-Host ""
  390. Write-Host "======================"
  391. Write-Host "Results: " -NoNewline
  392. Write-Host "$script:Pass passed" -ForegroundColor Green -NoNewline
  393. Write-Host ", " -NoNewline
  394. Write-Host "$script:Fail failed" -ForegroundColor Red -NoNewline
  395. Write-Host ", " -NoNewline
  396. Write-Host "$script:Warn warnings" -ForegroundColor Yellow
  397. if ($script:Fail -gt 0) {
  398. exit 1
  399. }
  400. exit 0