| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476 |
- # 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
|