
Plugin Validator
- 114 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Use plugin-validator for development tasks
About
plugin-validator: A skill for development. This provides functionality for development workflows.
- plugin-validator
Plugin Validator by the numbers
- 114 all-time installs (skills.sh)
- Ranked #2,906 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill plugin-validatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 114 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Use plugin-validator for development tasks
Files
Plugin Validator
Comprehensive validation for Claude Code marketplace plugins.
Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
When to Use This Skill
Use this skill when:
- Validating plugin structure before release
- Auditing hooks for silent failures
- Checking plugin.json syntax and required fields
- Verifying skill file formatting and frontmatter
Quick Start
# Validate a specific plugin
uv run plugins/plugin-dev/skills/plugin-validator/scripts/audit_silent_failures.py plugins/my-plugin/
# Validate with fix suggestions
uv run plugins/plugin-dev/skills/plugin-validator/scripts/audit_silent_failures.py plugins/my-plugin/ --fixValidation Phases
Phase 1: Structure Validation
Check plugin directory structure:
/usr/bin/env bash << 'VALIDATE_EOF'
PLUGIN_PATH="${1:-.}"
# Check plugin.json exists
if [[ ! -f "$PLUGIN_PATH/plugin.json" ]]; then
echo "ERROR: Missing plugin.json" >&2
exit 1
fi
# Validate JSON syntax
if ! jq empty "$PLUGIN_PATH/plugin.json" 2>/dev/null; then
echo "ERROR: Invalid JSON in plugin.json" >&2
exit 1
fi
# Check required fields
REQUIRED_FIELDS=("name" "version" "description")
for field in "${REQUIRED_FIELDS[@]}"; do
if ! jq -e ".$field" "$PLUGIN_PATH/plugin.json" >/dev/null 2>&1; then
echo "ERROR: Missing required field: $field" >&2
exit 1
fi
done
echo "Structure validation passed"
VALIDATE_EOFPhase 2: Silent Failure Audit
Critical Rule: All hook entry points MUST emit to stderr on failure.
Run the audit script:
uv run plugins/plugin-dev/skills/plugin-validator/scripts/audit_silent_failures.py plugins/my-plugin/What Gets Checked
| Check | Target Files | Pattern |
|---|---|---|
| Shellcheck | hooks/*.sh | SC2155, SC2086, etc. |
| Silent bash | hooks/*.sh | `mkdir\ |
| Silent Python | hooks/*.py | except.*: pass without stderr |
Hook Entry Points vs Utility Scripts
| Location | Type | Requirement |
|---|---|---|
plugins/*/hooks/*.sh | Entry point | MUST emit to stderr |
plugins/*/hooks/*.py | Entry point | MUST emit to stderr |
plugins/*/scripts/*.sh | Utility | Fallback behavior OK |
plugins/*/scripts/*.py | Utility | Fallback behavior OK |
Phase 3: Fix Patterns
Bash: Silent mkdir
# BAD - silent failure
mkdir -p "$DIR"
# GOOD - emits to stderr
if ! mkdir -p "$DIR" 2>&1; then
echo "[plugin] Failed to create directory: $DIR" >&2
fiPython: Silent except pass
# BAD - silent failure
except (json.JSONDecodeError, OSError):
pass
# GOOD - emits to stderr
except (json.JSONDecodeError, OSError) as e:
print(f"[plugin] Warning: {e}", file=sys.stderr)Integration with /plugin-dev:create
This skill is invoked in Phase 3 of the plugin-add workflow:
### 3.4 Plugin Validation
**MANDATORY**: Run plugin-validator before registration.
Task with subagent_type="plugin-dev:plugin-validator"
prompt: "Validate the plugin at plugins/$PLUGIN_NAME/"Exit Codes
| Code | Meaning |
|---|---|
| 0 | All validations passed |
| 1 | Violations found (see output) |
| 2 | Error (invalid path, missing files) |
References
- Silent Failure Patterns
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| plugin.json not found | Missing manifest file | Create plugin.json with required fields |
| Invalid JSON syntax | Malformed plugin.json | Run jq empty plugin.json to find syntax errors |
| Missing required field | Incomplete manifest | Add name, version, description to plugin.json |
| Shellcheck errors | Bash script issues | Run shellcheck hooks/*.sh to see details |
| Silent failure in bash | Missing error handling | Add if ! check around mkdir/cp/mv/rm commands |
| Silent except:pass in Python | Missing stderr output | Add print(..., file=sys.stderr) before pass |
| Exit code 2 | Invalid path or missing files | Verify plugin path exists and has correct structure |
| Violations after --fix | Fix suggestions not applied | Manually apply suggested fixes from output |
Post-Execution Reflection
After this skill completes, reflect before closing the task:
0. Locate yourself. — Find this SKILL.md's canonical path (Glob for this skill's name) before editing. All corrections target THIS file and its sibling references/ — never other documentation. 1. What failed? — Fix the instruction that caused it. If it could recur, add it as an anti-pattern. 2. What worked better than expected? — Promote it to recommended practice. Document why. 3. What drifted? — Any script, reference, or external dependency that no longer matches reality gets fixed now. 4. Log it. — Every change gets an evolution-log entry with trigger, fix, and evidence.
Do NOT defer. The next invocation inherits whatever you leave behind.
--- ---
Evolution Log
Convention: Reverse chronological order (newest on top, oldest at bottom). Prepend new entries.
---
2026-02-26: Initial Evolution Log
Status: Skill is in use and maintained. Track improvements here.
Purpose
This evolution log tracks updates to the skill. Each entry should note:
- What changed (content, structure, tooling)
- Why it changed (bug fix, feature request, best practice)
- Files affected
How to Use
1. When updating SKILL.md or references, add an entry here with the date 2. Keep entries reverse-chronological (newest first) 3. Link to ADRs or GitHub issues when relevant 4. Reference specific line changes when helpful
---
Silent Failure Patterns
Reference for detecting and fixing silent failures in Claude Code hooks.
Why This Matters
Hook entry points are executed by Claude Code. If they fail silently:
- Claude doesn't know something went wrong
- Users don't see error messages
- Debugging becomes difficult
Rule: All hook entry points MUST emit to stderr on failure.
Hook Entry Points vs Utility Scripts
| Location | Type | Requirement |
|---|---|---|
plugins/*/hooks/*.sh | Entry point | MUST emit to stderr |
plugins/*/hooks/*.py | Entry point | MUST emit to stderr |
plugins/*/scripts/*.sh | Utility | Fallback behavior acceptable |
plugins/*/scripts/*.py | Utility | Fallback behavior acceptable |
Bash Patterns
Silent Commands to Check
# These commands can fail silently:
mkdir -p "$DIR" # Directory creation
cp "$SRC" "$DST" # File copy
mv "$SRC" "$DST" # File move
rm -f "$FILE" # File removal
jq '.key' "$FILE" # JSON parsingFix Pattern: if ! ... then
# BAD - silent failure
mkdir -p "$STATE_DIR"
# GOOD - emits to stderr
if ! mkdir -p "$STATE_DIR" 2>&1; then
echo "[plugin] Failed to create directory: $STATE_DIR" >&2
fiFix Pattern: || operator
# BAD - silent failure
cp "$SRC" "$DST"
# GOOD - emits to stderr on failure
cp "$SRC" "$DST" 2>&1 || echo "[plugin] Failed to copy: $SRC" >&2Fix Pattern: Trap for cleanup
# For temporary files with cleanup
temp=$(mktemp)
trap 'rm -f "$temp"' EXIT
if ! some_command > "$temp" 2>&1; then
echo "[plugin] Command failed" >&2
fiPython Patterns
Silent Exception: pass
# BAD - silent failure
try:
config = json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
pass # Silent!
# GOOD - emits to stderr
try:
config = json.loads(path.read_text())
except (json.JSONDecodeError, OSError) as e:
print(f"[plugin] Warning: Failed to load config: {e}", file=sys.stderr)
config = {} # FallbackSilent Exception: No capture
# BAD - no exception capture
try:
result = subprocess.run(cmd, check=True)
except subprocess.CalledProcessError:
return None # What went wrong?
# GOOD - captures and logs
try:
result = subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"[plugin] Command failed: {e}", file=sys.stderr)
return NoneAcceptable Silent Patterns
Some silent patterns are acceptable in utility code:
# OK in utility functions (not hook entry points)
# When fallback behavior is intentional and well-documented
def find_git_root(workspace: Path) -> Path | None:
"""Find git root, returns None if not a git repo."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
return Path(result.stdout.strip())
except (subprocess.TimeoutExpired, FileNotFoundError):
pass # OK - fallback to None is documented behavior
return NoneDetection Commands
Find silent bash commands
grep -rn "mkdir\|cp\|mv\|rm" plugins/*/hooks/*.sh | grep -v "if !" | grep -v "||" | grep -v "#"Find silent Python exceptions
grep -rn "except.*:" plugins/*/hooks/*.py | grep -v "as e" | grep -v "as err"
grep -rn "pass$" plugins/*/hooks/*.py -B2 | grep "except"Run shellcheck
shellcheck plugins/*/hooks/*.shIntegration
This audit runs automatically in /plugin-dev:create Phase 3.
Manual invocation:
uv run plugins/plugin-dev/skills/plugin-validator/scripts/audit_silent_failures.py plugins/my-plugin/ --fix# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Silent Failure Audit for Claude Code Plugins.
Validates that all hook entry points emit to stderr on failure.
Checks:
1. Shellcheck on hook .sh files
2. Silent bash commands (mkdir, cp, mv, rm, jq) without `if !` pattern
3. Silent Python exceptions (`except: pass`) without stderr emission
Usage:
uv run audit_silent_failures.py <plugin_path> [--fix]
Exit codes:
0 = All validations passed
1 = Violations found
2 = Error (invalid path)
"""
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Violation:
"""A single validation violation."""
file: Path
line: int
check: str
message: str
severity: str = "error" # error, warning
fix_suggestion: str | None = None
@dataclass
class AuditResult:
"""Complete audit results for a plugin."""
plugin_path: Path
violations: list[Violation] = field(default_factory=list)
@property
def passed(self) -> bool:
return not any(v.severity == "error" for v in self.violations)
@property
def errors(self) -> list[Violation]:
return [v for v in self.violations if v.severity == "error"]
@property
def warnings(self) -> list[Violation]:
return [v for v in self.violations if v.severity == "warning"]
def find_hook_entry_points(plugin_path: Path) -> tuple[list[Path], list[Path]]:
"""Find actual hook entry point files by reading hooks.json.
Only returns files that are directly invoked by Claude Code hooks,
not utility modules that are imported by entry points.
"""
hooks_dir = plugin_path / "hooks"
hooks_json = hooks_dir / "hooks.json"
if not hooks_dir.exists():
return [], []
entry_point_files: set[str] = set()
# Try to read hooks.json to find actual entry points
if hooks_json.exists():
try:
config = json.loads(hooks_json.read_text())
hooks_config = config.get("hooks", {})
# Extract file names from hook commands
for _event_type, hook_list in hooks_config.items():
for hook_group in hook_list:
for hook in hook_group.get("hooks", []):
command = hook.get("command", "")
# Extract filename from command
# Patterns: "uv run .../file.py", ".../file.sh", "python .../file.py"
for part in command.split():
if part.endswith(".py") or part.endswith(".sh"):
# Extract just the filename
filename = Path(part.replace("${CLAUDE_PLUGIN_ROOT}/hooks/", "")).name
entry_point_files.add(filename)
except (json.JSONDecodeError, OSError) as e:
print(f"[audit] Warning: Failed to parse hooks.json: {e}", file=sys.stderr)
# Fall back to checking all files
entry_point_files = None
# If no hooks.json or parsing failed, fall back to heuristics
if entry_point_files is None or not entry_point_files:
# Fall back: check all .sh files and .py files with __main__
sh_files = list(hooks_dir.glob("*.sh"))
py_files = []
for py_file in hooks_dir.glob("*.py"):
if py_file.name.startswith("__") or py_file.name.startswith("test_"):
continue
# Check if file has __main__ guard (indicates entry point)
try:
content = py_file.read_text()
if '__name__ == "__main__"' in content or "__name__ == '__main__'" in content:
py_files.append(py_file)
except OSError:
pass
return sh_files, py_files
# Filter to only entry point files
sh_files = [f for f in hooks_dir.glob("*.sh") if f.name in entry_point_files]
py_files = [f for f in hooks_dir.glob("*.py") if f.name in entry_point_files]
return sh_files, py_files
def run_shellcheck(sh_files: list[Path]) -> list[Violation]:
"""Run shellcheck on shell files."""
violations = []
if not sh_files:
return violations
# Check if shellcheck is available
try:
subprocess.run(["shellcheck", "--version"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
print("[audit] Warning: shellcheck not installed, skipping shell checks", file=sys.stderr)
return violations
for sh_file in sh_files:
try:
result = subprocess.run(
["shellcheck", "-f", "json", str(sh_file)],
capture_output=True,
text=True,
timeout=30,
)
if result.stdout:
import json
issues = json.loads(result.stdout)
for issue in issues:
# Only report warnings and errors (not style/info)
if issue.get("level") in ("warning", "error"):
violations.append(
Violation(
file=sh_file,
line=issue.get("line", 0),
check="shellcheck",
message=f"SC{issue.get('code')}: {issue.get('message')}",
severity="warning" if issue.get("level") == "warning" else "error",
)
)
except subprocess.TimeoutExpired:
print(f"[audit] Warning: shellcheck timed out on {sh_file}", file=sys.stderr)
except Exception as e:
print(f"[audit] Warning: shellcheck failed on {sh_file}: {e}", file=sys.stderr)
return violations
def check_silent_bash_commands(sh_files: list[Path]) -> list[Violation]:
"""Check for silent bash commands that should have error handling."""
violations = []
# Commands that can fail silently and should have `if !` pattern
silent_commands = ["mkdir", "cp", "mv", "rm"]
for sh_file in sh_files:
try:
content = sh_file.read_text()
lines = content.splitlines()
for i, line in enumerate(lines, 1):
# Skip comments
stripped = line.strip()
if stripped.startswith("#"):
continue
for cmd in silent_commands:
# Pattern: command at start of line (not in if ! or ||)
# Look for: mkdir -p, cp file, mv file, rm -f
pattern = rf"^\s*{cmd}\s+"
if re.search(pattern, line):
# Check if it's properly guarded
# Good patterns:
# - if ! mkdir ...; then
# - mkdir ... || echo "error" >&2
# - mkdir ... 2>&1
# - if ! mkdir ... 2>&1; then
has_if_guard = re.search(rf"if\s+!\s+{cmd}", line)
has_or_guard = "||" in line
has_stderr_redirect = ">&2" in line or "2>&1" in line
if not (has_if_guard or has_or_guard or has_stderr_redirect):
violations.append(
Violation(
file=sh_file,
line=i,
check="silent_bash",
message=f"`{cmd}` without error handling - may fail silently",
severity="error",
fix_suggestion=f'if ! {cmd} ... 2>&1; then echo "[plugin] Failed: {cmd}" >&2; fi',
)
)
except Exception as e:
print(f"[audit] Warning: Failed to read {sh_file}: {e}", file=sys.stderr)
return violations
def check_silent_python_exceptions(py_files: list[Path]) -> list[Violation]:
"""Check for silent Python exception handlers in hook entry points."""
violations = []
for py_file in py_files:
try:
content = py_file.read_text()
lines = content.splitlines()
i = 0
while i < len(lines):
line = lines[i]
# Look for except blocks
except_match = re.search(r"^\s*except\s+.*:", line)
if except_match:
except_line = i + 1
# Check if exception is captured (has `as e` or `as err` etc.)
has_capture = re.search(r"\s+as\s+\w+", line)
# Look at the next few lines for the handler body
handler_lines = []
j = i + 1
indent_match = re.match(r"^(\s*)", line)
base_indent = len(indent_match.group(1)) if indent_match else 0
while j < len(lines):
next_line = lines[j]
if not next_line.strip():
j += 1
continue
next_indent_match = re.match(r"^(\s*)", next_line)
next_indent = len(next_indent_match.group(1)) if next_indent_match else 0
if next_indent <= base_indent and next_line.strip():
break
handler_lines.append(next_line)
j += 1
handler_body = "\n".join(handler_lines)
# Check for silent patterns
is_silent = False
# Pattern 1: Just `pass`
if re.search(r"^\s*pass\s*$", handler_body, re.MULTILINE):
# Check if stderr is used anywhere in handler
if "stderr" not in handler_body and "sys.stderr" not in handler_body:
is_silent = True
# Pattern 2: No capture and no stderr output
if not has_capture and "stderr" not in handler_body:
# Check if there's any logging or print
if "print(" not in handler_body and "logger" not in handler_body.lower():
is_silent = True
if is_silent:
violations.append(
Violation(
file=py_file,
line=except_line,
check="silent_python",
message="Silent exception handler - must emit to stderr in hook entry points",
severity="error",
fix_suggestion='except ... as e: print(f"[plugin] Warning: {e}", file=sys.stderr)',
)
)
i += 1
except Exception as e:
print(f"[audit] Warning: Failed to read {py_file}: {e}", file=sys.stderr)
return violations
def audit_plugin(plugin_path: Path) -> AuditResult:
"""Run all audits on a plugin."""
result = AuditResult(plugin_path=plugin_path)
sh_files, py_files = find_hook_entry_points(plugin_path)
if not sh_files and not py_files:
print(f"[audit] No hook entry points found in {plugin_path}/hooks/", file=sys.stderr)
print("[audit] (Checked hooks.json for registered entry points)", file=sys.stderr)
return result
print(f"[audit] Found {len(sh_files)} .sh and {len(py_files)} .py hook entry points", file=sys.stderr)
# Run all checks
result.violations.extend(run_shellcheck(sh_files))
result.violations.extend(check_silent_bash_commands(sh_files))
result.violations.extend(check_silent_python_exceptions(py_files))
return result
def print_results(result: AuditResult, show_fix: bool = False) -> None:
"""Print audit results."""
print(f"\n{'=' * 60}")
print(f"Silent Failure Audit: {result.plugin_path}")
print(f"{'=' * 60}\n")
if not result.violations:
print("All hook entry points properly emit to stderr on failure.")
return
# Group by file
by_file: dict[Path, list[Violation]] = {}
for v in result.violations:
by_file.setdefault(v.file, []).append(v)
for file_path, violations in by_file.items():
rel_path = file_path.relative_to(result.plugin_path) if file_path.is_relative_to(result.plugin_path) else file_path
print(f"\n{rel_path}:")
for v in sorted(violations, key=lambda x: x.line):
icon = "X" if v.severity == "error" else "!"
print(f" [{icon}] Line {v.line}: {v.message}")
if show_fix and v.fix_suggestion:
print(f" Fix: {v.fix_suggestion}")
# Summary
print(f"\n{'=' * 60}")
print(f"Summary: {len(result.errors)} errors, {len(result.warnings)} warnings")
if result.passed:
print("PASSED (warnings only)")
else:
print("FAILED")
def main() -> int:
"""Main entry point."""
if len(sys.argv) < 2:
print(__doc__)
return 2
plugin_path = Path(sys.argv[1]).resolve()
show_fix = "--fix" in sys.argv
if not plugin_path.exists():
print(f"Error: Path not found: {plugin_path}", file=sys.stderr)
return 2
if not plugin_path.is_dir():
print(f"Error: Not a directory: {plugin_path}", file=sys.stderr)
return 2
result = audit_plugin(plugin_path)
print_results(result, show_fix)
return 0 if result.passed else 1
if __name__ == "__main__":
sys.exit(main())