diff --git a/scripts/check-manifests.sh b/scripts/check-manifests.sh new file mode 100755 index 0000000..131f75f --- /dev/null +++ b/scripts/check-manifests.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates that all paths referenced in marketplace.json and plugin.json manifests +# resolve to existing files or directories. Run from repo root or pass REPO_ROOT as arg. + +REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +FAIL=0 + +err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); } + +if ! command -v jq &>/dev/null; then + echo "Error: jq is required but not installed" >&2 + exit 1 +fi + +MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json" +if [[ ! -f "$MARKETPLACE" ]]; then + exit 0 +fi + +plugin_count=$(jq '.plugins | length' "$MARKETPLACE") + +for ((i = 0; i < plugin_count; i++)); do + name=$(jq -r ".plugins[$i].name" "$MARKETPLACE") + source=$(jq -r ".plugins[$i].source" "$MARKETPLACE") + source="${source#./}" + plugin_dir="$REPO_ROOT/$source" + + if [[ ! -d "$plugin_dir" ]]; then + err "plugin '$name': source directory not found: $source" + continue + fi + + manifest="$plugin_dir/plugin.json" + if [[ ! -f "$manifest" ]]; then + err "plugin '$name': plugin.json not found in $source" + continue + fi + + # Check skills directories + skill_count=$(jq '.skills | if . then length else 0 end' "$manifest") + for ((s = 0; s < skill_count; s++)); do + skill_path=$(jq -r ".skills[$s]" "$manifest") + full_path="$plugin_dir/$skill_path" + full_path="${full_path%/}" + if [[ ! -d "$full_path" ]]; then + err "plugin '$name': skills path not found: $skill_path" + fi + done + + # Check file references (hooks, mcpServers, agents) + for field in hooks mcpServers agents; do + ref=$(jq -r ".${field} // empty" "$manifest") + [[ -z "$ref" ]] && continue + full_path="$plugin_dir/$ref" + full_path="${full_path%/}" + if [[ ! -e "$full_path" ]]; then + err "plugin '$name': $field path not found: $ref" + fi + done +done + +if [[ $FAIL -gt 0 ]]; then + echo "Manifest check failed: $FAIL error(s)" >&2 + exit 1 +fi diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh new file mode 100755 index 0000000..3bca889 --- /dev/null +++ b/scripts/setup-hooks.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs git hook blocks for validation into a target repository. +# Usage: setup-hooks.sh [TARGET_REPO] +# TARGET_REPO — path to the git repo to configure (default: current directory) +# Idempotent: safe to re-run; always replaces each managed block with the current version. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="${1:-$(pwd)}" +MARKER="# managed by setup-hooks.sh" +END_MARKER="# end setup-hooks" + +if [[ ! -d "$TARGET/.git" ]]; then + echo "Error: $TARGET is not a git repository" >&2 + exit 1 +fi + +# --- Tool checks (optional — warn, don't fail) --- + +warn_if_missing() { + local tool="$1" + if ! command -v "$tool" &>/dev/null; then + echo "Warning: $tool not installed — ${tool} checks in pre-commit will be skipped (install $tool to enable)" >&2 + fi +} + +warn_if_missing shellcheck +warn_if_missing jq +warn_if_missing yq + +# --- Marker-block helpers --- + +write_block() { + local hook_file="$1" + local block_content="$2" + + if grep -qF "$MARKER" "$hook_file"; then + awk -v start="$MARKER" -v end="$END_MARKER" ' + $0 == start { skip=1; next } + skip && $0 == end { skip=0; next } + !skip { print } + ' "$hook_file" > "${hook_file}.tmp" && mv "${hook_file}.tmp" "$hook_file" + fi + + printf '\n%s\n%s\n%s\n' "$MARKER" "$block_content" "$END_MARKER" >> "$hook_file" +} + +ensure_hook() { + local hook_file="$1" + if [[ ! -f "$hook_file" ]]; then + printf '#!/usr/bin/env bash\nset -euo pipefail\n' > "$hook_file" + chmod +x "$hook_file" + fi +} + +# --- commit-msg: conventional commits --- + +COMMIT_MSG_HOOK="$TARGET/.git/hooks/commit-msg" +ensure_hook "$COMMIT_MSG_HOOK" + +commit_msg_block() { + cat <<'BLOCK' +msg=$(cat "$1") +pattern='^(feat|fix|docs|chore|refactor|test|perf|ci|build|revert)(\(.+\))?!?: .+' +if ! echo "$msg" | grep -qE "$pattern"; then + echo "ERROR: Commit message must follow Conventional Commits format." >&2 + echo " Examples: feat: add login, fix(auth): correct token expiry, chore!: drop python dep" >&2 + exit 1 +fi +BLOCK +} + +write_block "$COMMIT_MSG_HOOK" "$(commit_msg_block)" +echo "Updated: $COMMIT_MSG_HOOK (conventional commits check)" + +# --- pre-commit: shellcheck + jq/yq + SKILL.md frontmatter --- + +PRE_COMMIT_HOOK="$TARGET/.git/hooks/pre-commit" +ensure_hook "$PRE_COMMIT_HOOK" + +pre_commit_block() { + cat <<'BLOCK' +staged=$(git diff --cached --name-only --diff-filter=ACM) + +# shellcheck on staged .sh files +if command -v shellcheck &>/dev/null; then + echo "$staged" | grep '\.sh$' | while IFS= read -r f; do + [[ -f "$f" ]] && shellcheck "$f" + done +else + echo "Warning: shellcheck not installed — shell script linting skipped" >&2 +fi + +# jq validation on staged .json files +if command -v jq &>/dev/null; then + echo "$staged" | grep '\.json$' | while IFS= read -r f; do + [[ -f "$f" ]] && jq . "$f" > /dev/null + done +else + echo "Warning: jq not installed — JSON validation skipped" >&2 +fi + +# yq validation on staged .yaml/.yml files +if command -v yq &>/dev/null; then + echo "$staged" | grep -E '\.(yaml|yml)$' | while IFS= read -r f; do + [[ -f "$f" ]] && yq eval '.' "$f" > /dev/null + done +else + echo "Warning: yq not installed — YAML validation skipped" >&2 +fi + +# SKILL.md frontmatter: must have name: and description: +echo "$staged" | grep 'SKILL\.md$' | while IFS= read -r f; do + if [[ -f "$f" ]]; then + if ! grep -q '^name:' "$f" || ! grep -q '^description' "$f"; then + echo "ERROR: $f is missing required frontmatter fields (name: and description:)" >&2 + exit 1 + fi + fi +done +BLOCK +} + +write_block "$PRE_COMMIT_HOOK" "$(pre_commit_block)" +echo "Updated: $PRE_COMMIT_HOOK (shellcheck + jq/yq + SKILL.md validation)" + +# --- pre-push: test suite + manifest cross-reference --- + +PRE_PUSH_HOOK="$TARGET/.git/hooks/pre-push" +ensure_hook "$PRE_PUSH_HOOK" + +pre_push_block() { + local script_dir="$SCRIPT_DIR" + cat < "$dir/.claude-plugin/marketplace.json" <<'JSON' +{ + "name": "test-marketplace", + "plugins": [ + { "name": "myplugin", "source": "./plugins/myplugin" } + ] +} +JSON + cat > "$dir/plugins/myplugin/plugin.json" <<'JSON' +{ + "name": "myplugin", + "skills": ["skills/"], + "agents": "agents/", + "hooks": "hooks.json" +} +JSON + touch "$dir/plugins/myplugin/hooks.json" + echo "$dir" +} + +# --- 1. Exits 0 against valid repo structure --- +echo "" +echo "--- exits 0 when all references are valid ---" +FIXTURE="$(make_valid_fixture)" +trap 'rm -rf "$FIXTURE"' EXIT +if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1; then + pass "exits 0 when all manifest references resolve" +else + fail "exited non-zero against a valid fixture" +fi + +# --- 2. Exits 1 when plugin source dir is missing --- +echo "" +echo "--- exits 1 when plugin source directory missing ---" +FIXTURE2="$(mktemp -d)" +trap 'rm -rf "$FIXTURE2"' EXIT +mkdir -p "$FIXTURE2/.claude-plugin" +cat > "$FIXTURE2/.claude-plugin/marketplace.json" <<'JSON' +{ + "name": "test-marketplace", + "plugins": [ + { "name": "ghost", "source": "./plugins/ghost" } + ] +} +JSON +if bash "$SCRIPT" "$FIXTURE2" > /dev/null 2>&1; then + fail "exited 0 when plugin source dir is missing — expected exit 1" +else + pass "exits non-zero when plugin source directory does not exist" +fi + +# --- 3. Exits 1 when plugin.json is missing from plugin dir --- +echo "" +echo "--- exits 1 when plugin.json missing from plugin directory ---" +FIXTURE3="$(mktemp -d)" +trap 'rm -rf "$FIXTURE3"' EXIT +mkdir -p "$FIXTURE3/.claude-plugin" +mkdir -p "$FIXTURE3/plugins/nomanifest" +cat > "$FIXTURE3/.claude-plugin/marketplace.json" <<'JSON' +{ + "name": "test-marketplace", + "plugins": [ + { "name": "nomanifest", "source": "./plugins/nomanifest" } + ] +} +JSON +if bash "$SCRIPT" "$FIXTURE3" > /dev/null 2>&1; then + fail "exited 0 when plugin.json is missing — expected exit 1" +else + pass "exits non-zero when plugin.json is missing from plugin directory" +fi + +# --- 4. Exits 1 when a skills directory listed in plugin.json does not exist --- +echo "" +echo "--- exits 1 when skills directory missing ---" +FIXTURE4="$(mktemp -d)" +trap 'rm -rf "$FIXTURE4"' EXIT +mkdir -p "$FIXTURE4/.claude-plugin" +mkdir -p "$FIXTURE4/plugins/myplugin" +cat > "$FIXTURE4/.claude-plugin/marketplace.json" <<'JSON' +{ + "name": "test-marketplace", + "plugins": [ + { "name": "myplugin", "source": "./plugins/myplugin" } + ] +} +JSON +cat > "$FIXTURE4/plugins/myplugin/plugin.json" <<'JSON' +{ + "name": "myplugin", + "skills": ["skills/"] +} +JSON +if bash "$SCRIPT" "$FIXTURE4" > /dev/null 2>&1; then + fail "exited 0 when skills dir missing — expected exit 1" +else + pass "exits non-zero when skills directory referenced in plugin.json does not exist" +fi + +# --- 5. Exits 1 when a file referenced in plugin.json (hooks) does not exist --- +echo "" +echo "--- exits 1 when referenced hooks file missing ---" +FIXTURE5="$(mktemp -d)" +trap 'rm -rf "$FIXTURE5"' EXIT +mkdir -p "$FIXTURE5/.claude-plugin" +mkdir -p "$FIXTURE5/plugins/myplugin/skills" +cat > "$FIXTURE5/.claude-plugin/marketplace.json" <<'JSON' +{ + "name": "test-marketplace", + "plugins": [ + { "name": "myplugin", "source": "./plugins/myplugin" } + ] +} +JSON +cat > "$FIXTURE5/plugins/myplugin/plugin.json" <<'JSON' +{ + "name": "myplugin", + "hooks": "hooks.json" +} +JSON +if bash "$SCRIPT" "$FIXTURE5" > /dev/null 2>&1; then + fail "exited 0 when hooks file missing — expected exit 1" +else + pass "exits non-zero when file referenced in plugin.json does not exist" +fi + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] diff --git a/tests/test-setup-hooks.sh b/tests/test-setup-hooks.sh new file mode 100644 index 0000000..e740428 --- /dev/null +++ b/tests/test-setup-hooks.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/setup-hooks.sh" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# Helper: make a bare git repo with no hooks yet +make_repo() { + local dir + dir="$(mktemp -d)" + git -C "$dir" init -q + echo "$dir" +} + +# Fake binaries for tools we don't want to install-check during tests +FAKE_BIN="$(mktemp -d)" +trap 'rm -rf "$FAKE_BIN"' EXIT +for tool in shellcheck jq yq; do + printf '#!/bin/sh\necho "fake %s"\n' "$tool" > "$FAKE_BIN/$tool" + chmod +x "$FAKE_BIN/$tool" +done +export PATH="$FAKE_BIN:$PATH" + +# --- 1. Rejects non-git directory --- +echo "" +echo "--- rejects non-git directory ---" +NON_GIT="$(mktemp -d)" +trap 'rm -rf "$NON_GIT"' EXIT +if bash "$SCRIPT" "$NON_GIT" > /dev/null 2>&1; then + fail "exited 0 for non-git directory — expected exit 1" +else + pass "exits non-zero for non-git directory" +fi + +# --- 2. Creates commit-msg hook --- +echo "" +echo "--- creates commit-msg hook ---" +REPO="$(make_repo)" +trap 'rm -rf "$REPO"' EXIT +bash "$SCRIPT" "$REPO" > /dev/null 2>&1 +HOOK="$REPO/.git/hooks/commit-msg" +if [[ -f "$HOOK" ]]; then + pass "commit-msg hook file created" +else + fail "commit-msg hook not created" +fi +if [[ -x "$HOOK" ]]; then + pass "commit-msg hook is executable" +else + fail "commit-msg hook is not executable" +fi +if grep -q "# managed by setup-hooks.sh" "$HOOK"; then + pass "commit-msg hook contains idempotency marker" +else + fail "commit-msg hook missing idempotency marker" +fi + +# --- 3. commit-msg hook validates conventional commits --- +echo "" +echo "--- commit-msg hook: valid message passes ---" +REPO2="$(make_repo)" +trap 'rm -rf "$REPO2"' EXIT +bash "$SCRIPT" "$REPO2" > /dev/null 2>&1 +HOOK2="$REPO2/.git/hooks/commit-msg" +TMPFILE="$(mktemp)" +trap 'rm -f "$TMPFILE"' EXIT + +for valid_msg in "feat: add validation" "fix(core): correct path resolution" "chore!: drop python dep" "docs: update readme" "refactor(hooks): extract marker logic"; do + echo "$valid_msg" > "$TMPFILE" + if bash "$HOOK2" "$TMPFILE" > /dev/null 2>&1; then + pass "commit-msg hook accepts: $valid_msg" + else + fail "commit-msg hook wrongly rejected: $valid_msg" + fi +done + +echo "" +echo "--- commit-msg hook: invalid message is rejected ---" +for invalid_msg in "added some stuff" "WIP" "Fix the thing" "FEAT: bad case" "feat bad colon"; do + echo "$invalid_msg" > "$TMPFILE" + if bash "$HOOK2" "$TMPFILE" > /dev/null 2>&1; then + fail "commit-msg hook wrongly accepted: $invalid_msg" + else + pass "commit-msg hook rejects: $invalid_msg" + fi +done + +# --- 4. Appends pre-commit validation block --- +echo "" +echo "--- appends validation block to pre-commit hook ---" +REPO3="$(make_repo)" +trap 'rm -rf "$REPO3"' EXIT +bash "$SCRIPT" "$REPO3" > /dev/null 2>&1 +PRE_COMMIT="$REPO3/.git/hooks/pre-commit" +if [[ -f "$PRE_COMMIT" ]]; then + pass "pre-commit hook created" +else + fail "pre-commit hook not created" +fi +if grep -q "shellcheck" "$PRE_COMMIT"; then + pass "pre-commit hook contains shellcheck" +else + fail "pre-commit hook missing shellcheck" +fi +if grep -q "jq" "$PRE_COMMIT"; then + pass "pre-commit hook contains jq" +else + fail "pre-commit hook missing jq" +fi +if grep -q "SKILL.md" "$PRE_COMMIT"; then + pass "pre-commit hook contains SKILL.md frontmatter check" +else + fail "pre-commit hook missing SKILL.md frontmatter check" +fi + +# --- 5. Creates pre-push hook --- +echo "" +echo "--- creates pre-push hook ---" +REPO4="$(make_repo)" +trap 'rm -rf "$REPO4"' EXIT +bash "$SCRIPT" "$REPO4" > /dev/null 2>&1 +PUSH_HOOK="$REPO4/.git/hooks/pre-push" +if [[ -f "$PUSH_HOOK" ]]; then + pass "pre-push hook created" +else + fail "pre-push hook not created" +fi +if [[ -x "$PUSH_HOOK" ]]; then + pass "pre-push hook is executable" +else + fail "pre-push hook is not executable" +fi +if grep -q "check-manifests" "$PUSH_HOOK"; then + pass "pre-push hook calls check-manifests.sh" +else + fail "pre-push hook missing check-manifests.sh call" +fi + +# --- 6. Idempotent: second run replaces each block exactly once --- +echo "" +echo "--- idempotent: second run does not duplicate blocks ---" +REPO5="$(make_repo)" +trap 'rm -rf "$REPO5"' EXIT +bash "$SCRIPT" "$REPO5" > /dev/null 2>&1 +bash "$SCRIPT" "$REPO5" > /dev/null 2>&1 +bash "$SCRIPT" "$REPO5" > /dev/null 2>&1 + +for hook_file in "$REPO5/.git/hooks/commit-msg" "$REPO5/.git/hooks/pre-commit" "$REPO5/.git/hooks/pre-push"; do + count=$(grep -c "# managed by setup-hooks.sh" "$hook_file" || true) + hook_name="$(basename "$hook_file")" + if [[ "$count" -eq 1 ]]; then + pass "idempotent: $hook_name marker appears exactly once after 3 runs" + else + fail "idempotent: $hook_name marker appears $count times — block duplicated" + fi +done + +# --- 7. Hooks contain graceful degradation paths for missing tools --- +echo "" +echo "--- hooks contain graceful degradation for missing tools ---" +REPO6="$(make_repo)" +trap 'rm -rf "$REPO6"' EXIT +bash "$SCRIPT" "$REPO6" > /dev/null 2>&1 +PRE_COMMIT6="$REPO6/.git/hooks/pre-commit" +for tool in shellcheck jq yq; do + if grep -q "Warning:.*$tool\|$tool.*not installed\|$tool.*skipped" "$PRE_COMMIT6"; then + pass "pre-commit hook has graceful degradation path for missing: $tool" + else + fail "pre-commit hook missing graceful degradation for: $tool" + fi +done + +echo "" +echo "--- setup always exits 0 (no hard dependency on optional tools) ---" +REPO7="$(make_repo)" +trap 'rm -rf "$REPO7"' EXIT +if bash "$SCRIPT" "$REPO7" > /dev/null 2>&1; then + pass "setup exits 0 when all optional tools are present" +else + fail "setup exited non-zero unexpectedly" +fi + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]]