# claude-mods validation script (PowerShell) # Validates YAML frontmatter, required fields, and naming conventions param( [switch]$YamlOnly, [switch]$NamesOnly ) $ErrorActionPreference = "Stop" # Counters $script:Pass = 0 $script:Fail = 0 $script:Warn = 0 # Get project directory $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ProjectDir = Split-Path -Parent $ScriptDir function Write-Pass { param([string]$Message) Write-Host "PASS" -ForegroundColor Green -NoNewline Write-Host ": $Message" $script:Pass++ } function Write-Fail { param([string]$Message) Write-Host "FAIL" -ForegroundColor Red -NoNewline Write-Host ": $Message" $script:Fail++ } function Write-Warn { param([string]$Message) Write-Host "WARN" -ForegroundColor Yellow -NoNewline Write-Host ": $Message" $script:Warn++ } function Test-YamlFrontmatter { param([string]$FilePath) $content = Get-Content -Path $FilePath -Raw # Check for opening --- if (-not $content.StartsWith("---")) { Write-Fail "$FilePath - Missing YAML frontmatter (no opening ---)" return $false } # Check for closing --- $lines = $content -split "`n" $foundClosing = $false for ($i = 1; $i -lt $lines.Count; $i++) { if ($lines[$i].Trim() -eq "---") { $foundClosing = $true break } } if (-not $foundClosing) { Write-Fail "$FilePath - Invalid YAML frontmatter (no closing ---)" return $false } return $true } function Get-YamlField { param( [string]$FilePath, [string]$Field ) $content = Get-Content -Path $FilePath -Raw $lines = $content -split "`n" $inFrontmatter = $false foreach ($line in $lines) { if ($line.Trim() -eq "---") { if ($inFrontmatter) { break } $inFrontmatter = $true continue } if ($inFrontmatter -and $line -match "^${Field}:\s*(.+)$") { $value = $matches[1].Trim() # Remove quotes $value = $value -replace '^["'']|["'']$', '' return $value } } return $null } function Test-RequiredFields { param( [string]$FilePath, [string]$Type ) $name = Get-YamlField -FilePath $FilePath -Field "name" $description = Get-YamlField -FilePath $FilePath -Field "description" # Agents require both name and description if ($Type -eq "agent") { if (-not $name) { Write-Fail "$FilePath - Missing required field: name" return $false } if (-not $description) { Write-Fail "$FilePath - Missing required field: description" return $false } } # Commands only require description if ($Type -eq "command") { if (-not $description) { Write-Fail "$FilePath - Missing required field: description" return $false } } return $true } function Test-Naming { param([string]$FilePath) $basename = [System.IO.Path]::GetFileNameWithoutExtension($FilePath) # Check if filename is kebab-case if ($basename -notmatch "^[a-z][a-z0-9]*(-[a-z0-9]+)*$") { Write-Warn "$FilePath - Filename not kebab-case: $basename" return $false } # Check if name field matches filename $name = Get-YamlField -FilePath $FilePath -Field "name" if ($name -and $name -ne $basename) { Write-Warn "$FilePath - Name field '$name' doesn't match filename '$basename'" return $false } return $true } function Test-Agents { Write-Host "" Write-Host "=== Validating Agents ===" -ForegroundColor Cyan $agentDir = Join-Path $ProjectDir "agents" if (-not (Test-Path $agentDir)) { Write-Warn "agents/ directory not found" return } $files = Get-ChildItem -Path $agentDir -Filter "*.md" -File foreach ($file in $files) { if (-not $NamesOnly) { if (Test-YamlFrontmatter -FilePath $file.FullName) { if (Test-RequiredFields -FilePath $file.FullName -Type "agent") { Write-Pass "$($file.FullName) - Valid agent" } } } if (-not $YamlOnly) { Test-Naming -FilePath $file.FullName | Out-Null } } } function Test-Commands { Write-Host "" Write-Host "=== Validating Commands ===" -ForegroundColor Cyan $cmdDir = Join-Path $ProjectDir "commands" if (-not (Test-Path $cmdDir)) { Write-Warn "commands/ directory not found" return } # Check .md files directly in commands/ $files = Get-ChildItem -Path $cmdDir -Filter "*.md" -File foreach ($file in $files) { if (-not $NamesOnly) { if (Test-YamlFrontmatter -FilePath $file.FullName) { if (Test-RequiredFields -FilePath $file.FullName -Type "command") { Write-Pass "$($file.FullName) - Valid command" } } } if (-not $YamlOnly) { Test-Naming -FilePath $file.FullName | Out-Null } } # Check subdirectories $subdirs = Get-ChildItem -Path $cmdDir -Directory foreach ($subdir in $subdirs) { $subFiles = Get-ChildItem -Path $subdir.FullName -Filter "*.md" -File foreach ($file in $subFiles) { # Skip README and LICENSE files if ($file.Name -eq "README.md" -or $file.Name -eq "LICENSE.md") { continue } if (-not $NamesOnly) { if (Test-YamlFrontmatter -FilePath $file.FullName) { $desc = Get-YamlField -FilePath $file.FullName -Field "description" if ($desc) { Write-Pass "$($file.FullName) - Valid subcommand" } else { Write-Warn "$($file.FullName) - Missing description" } } } } } } function Test-Skills { Write-Host "" Write-Host "=== Validating Skills ===" -ForegroundColor Cyan $skillsDir = Join-Path $ProjectDir "skills" if (-not (Test-Path $skillsDir)) { Write-Warn "skills/ directory not found" return } $subdirs = Get-ChildItem -Path $skillsDir -Directory foreach ($subdir in $subdirs) { # Skip shared helper dirs (e.g. _lib) - not skills, no SKILL.md expected. if ($subdir.Name -like "_*") { continue } $skillFile = Join-Path $subdir.FullName "SKILL.md" if (-not (Test-Path $skillFile)) { Write-Fail "$($subdir.FullName) - Missing SKILL.md" continue } if (-not $NamesOnly) { if (Test-YamlFrontmatter -FilePath $skillFile) { $name = Get-YamlField -FilePath $skillFile -Field "name" $desc = Get-YamlField -FilePath $skillFile -Field "description" if ($name -and $desc) { Write-Pass "$skillFile - Valid skill" } else { if (-not $name) { Write-Fail "$skillFile - Missing name" } if (-not $desc) { Write-Fail "$skillFile - Missing description" } } } } } } function Test-Rules { Write-Host "" Write-Host "=== Validating Rules ===" -ForegroundColor Cyan $rulesDir = Join-Path $ProjectDir "templates\rules" if (-not (Test-Path $rulesDir)) { Write-Host " (no templates/rules/ directory - skipping)" return } $files = Get-ChildItem -Path $rulesDir -Filter "*.md" -File -Recurse foreach ($file in $files) { # Rules should be .md files if ($file.Extension -ne ".md") { Write-Warn "$($file.FullName) - Rule file should be .md" continue } # Check if file has content if ($file.Length -eq 0) { Write-Fail "$($file.FullName) - Empty rule file" continue } $content = Get-Content -Path $file.FullName -Raw # Check for valid YAML frontmatter if present if ($content.StartsWith("---")) { $lines = $content -split "`n" $foundClosing = $false for ($i = 1; $i -lt $lines.Count; $i++) { if ($lines[$i].Trim() -eq "---") { $foundClosing = $true break } } if (-not $foundClosing) { Write-Fail "$($file.FullName) - Invalid YAML frontmatter (no closing ---)" continue } # If paths field exists, check it's not empty $paths = Get-YamlField -FilePath $file.FullName -Field "paths" if ($content -match "^paths:" -and -not $paths) { Write-Warn "$($file.FullName) - paths field is empty" } } # Check naming convention (kebab-case) $basename = [System.IO.Path]::GetFileNameWithoutExtension($file.Name) if ($basename -notmatch "^[a-z][a-z0-9]*(-[a-z0-9]+)*$") { Write-Warn "$($file.FullName) - Filename not kebab-case: $basename" } Write-Pass "$($file.FullName) - Valid rule" } } function Test-Settings { Write-Host "" Write-Host "=== Validating Settings ===" -ForegroundColor Cyan $settingsFile = Join-Path $ProjectDir "templates\settings.local.json" if (-not (Test-Path $settingsFile)) { Write-Host " (no templates/settings.local.json - skipping)" return } # Check if valid JSON try { $settings = Get-Content -Path $settingsFile -Raw | ConvertFrom-Json } catch { Write-Fail "$settingsFile - Invalid JSON" return } # Check for permissions structure if (-not $settings.permissions) { Write-Fail "$settingsFile - Missing 'permissions' key" } else { # Check permissions has allow array if (-not ($settings.permissions.allow -is [array])) { Write-Fail "$settingsFile - permissions.allow should be an array" } else { Write-Pass "$settingsFile - Valid permissions structure" } } # Check for hooks structure (optional but if present should be object) if ($settings.hooks) { $validEvents = @("PreToolUse", "PostToolUse", "PermissionRequest", "Notification", "UserPromptSubmit", "Stop", "SubagentStop", "PreCompact", "SessionStart", "SessionEnd") $hookEvents = $settings.hooks.PSObject.Properties.Name foreach ($event in $hookEvents) { if ($event -notin $validEvents) { Write-Warn "$settingsFile - Unknown hook event: $event" } } if ($hookEvents.Count -gt 0) { Write-Pass "$settingsFile - Valid hooks structure" } else { Write-Pass "$settingsFile - Hooks defined (empty)" } } } function Test-Plugin { Write-Host "" Write-Host "=== Validating Plugin Manifests ===" -ForegroundColor Cyan # The authoritative validator is `claude plugin validate` (it tracks the # live schema - it caught a bad plugin `source` shape and an `author` type # error that a hand-rolled check sailed past). Prefer it when present; fall # back to lightweight structural checks otherwise. The stray-root-file guard # runs regardless - the official tool can't see a misplaced copy. $pluginDir = Join-Path $ProjectDir ".claude-plugin" $pluginFile = Join-Path $pluginDir "plugin.json" $mktFile = Join-Path $pluginDir "marketplace.json" # --- location guard --- if (Test-Path (Join-Path $ProjectDir "marketplace.json")) { Write-Fail "marketplace.json found at repo root - must live at .claude-plugin/marketplace.json" } if (-not (Test-Path $pluginFile)) { Write-Fail ".claude-plugin/plugin.json - Missing" } if (-not (Test-Path $mktFile)) { Write-Fail ".claude-plugin/marketplace.json - Missing (required for /plugin marketplace add)" } $claude = Get-Command claude -ErrorAction SilentlyContinue # --- authoritative path --- if ($claude) { & claude plugin validate $ProjectDir 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { Write-Pass "marketplace.json - claude plugin validate passed" } else { Write-Fail "marketplace.json - claude plugin validate failed (run: claude plugin validate .)" } if (Test-Path $pluginFile) { $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) New-Item -ItemType Directory -Path (Join-Path $tmp ".claude-plugin") -Force | Out-Null Copy-Item $pluginFile (Join-Path $tmp ".claude-plugin\plugin.json") & claude plugin validate $tmp 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { Write-Pass "plugin.json - claude plugin validate passed" } else { Write-Fail "plugin.json - claude plugin validate failed (unrecognized keys or wrong field types)" } Remove-Item -Recurse -Force $tmp } return } # --- fallback path: lightweight structural checks --- Write-Warn "claude CLI not found - using lightweight manifest checks only (install Claude Code for authoritative validation)" if (Test-Path $pluginFile) { try { $plugin = Get-Content -Path $pluginFile -Raw | ConvertFrom-Json if ($plugin.name -is [string] -and $plugin.name) { Write-Pass "$pluginFile - structurally OK (name present)" } else { Write-Fail "$pluginFile - Missing required field: name" } } catch { Write-Fail "$pluginFile - Invalid JSON" } } if (Test-Path $mktFile) { try { $mkt = Get-Content -Path $mktFile -Raw | ConvertFrom-Json $ok = $true if (-not ($mkt.name -is [string] -and $mkt.name)) { Write-Fail "$mktFile - Missing required field: name (string)"; $ok = $false } 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 } if ($mkt.plugins -isnot [array]) { Write-Fail "$mktFile - Missing required field: plugins (array)"; $ok = $false } if ($ok) { Write-Pass "$mktFile - structurally OK (run claude plugin validate for full schema)" } } catch { Write-Fail "$mktFile - Invalid JSON" } } } # Main Write-Host "claude-mods Validation" Write-Host "======================" Write-Host "Project: $ProjectDir" Test-Agents Test-Commands Test-Skills Test-Rules Test-Settings Test-Plugin Write-Host "" Write-Host "======================" Write-Host "Results: " -NoNewline Write-Host "$script:Pass passed" -ForegroundColor Green -NoNewline Write-Host ", " -NoNewline Write-Host "$script:Fail failed" -ForegroundColor Red -NoNewline Write-Host ", " -NoNewline Write-Host "$script:Warn warnings" -ForegroundColor Yellow if ($script:Fail -gt 0) { exit 1 } exit 0