feat(projman): implement 8 improvement issues (#231-#238) #239

Merged
lmiranda merged 28 commits from feat/projman-improvements-231-238 into development 2026-01-28 15:53:51 +00:00
3 changed files with 390 additions and 0 deletions
Showing only changes of commit 551c60fb45 - Show all commits

View File

@@ -0,0 +1,195 @@
#!/bin/bash
# contract-validator SessionStart auto-validate hook
# Validates plugin contracts only when plugin files have changed since last check
# All output MUST have [contract-validator] prefix
PREFIX="[contract-validator]"
# ============================================================================
# Configuration
# ============================================================================
# Enable/disable auto-check (default: true)
AUTO_CHECK="${CONTRACT_VALIDATOR_AUTO_CHECK:-true}"
# Cache location for storing last check hash
CACHE_DIR="$HOME/.cache/claude-plugins/contract-validator"
HASH_FILE="$CACHE_DIR/last-check.hash"
# Marketplace location (installed plugins)
MARKETPLACE_PATH="$HOME/.claude/plugins/marketplaces/leo-claude-mktplace"
# ============================================================================
# Early exit if disabled
# ============================================================================
if [[ "$AUTO_CHECK" != "true" ]]; then
exit 0
fi
# ============================================================================
# Smart mode: Check if plugin files have changed
# ============================================================================
# Function to compute hash of all plugin manifest files
compute_plugin_hash() {
local hash_input=""
if [[ -d "$MARKETPLACE_PATH/plugins" ]]; then
# Hash all plugin.json, hooks.json, and agent files
while IFS= read -r -d '' file; do
if [[ -f "$file" ]]; then
hash_input+="$(md5sum "$file" 2>/dev/null | cut -d' ' -f1)"
fi
done < <(find "$MARKETPLACE_PATH/plugins" \
\( -name "plugin.json" -o -name "hooks.json" -o -name "*.md" -path "*/agents/*" \) \
-print0 2>/dev/null | sort -z)
fi
# Also include marketplace.json
if [[ -f "$MARKETPLACE_PATH/.claude-plugin/marketplace.json" ]]; then
hash_input+="$(md5sum "$MARKETPLACE_PATH/.claude-plugin/marketplace.json" 2>/dev/null | cut -d' ' -f1)"
fi
# Compute final hash
echo "$hash_input" | md5sum | cut -d' ' -f1
}
# Ensure cache directory exists
mkdir -p "$CACHE_DIR" 2>/dev/null
# Compute current hash
CURRENT_HASH=$(compute_plugin_hash)
# Check if we have a previous hash
if [[ -f "$HASH_FILE" ]]; then
PREVIOUS_HASH=$(cat "$HASH_FILE" 2>/dev/null)
# If hashes match, no changes - skip validation
if [[ "$CURRENT_HASH" == "$PREVIOUS_HASH" ]]; then
exit 0
fi
fi
# ============================================================================
# Run validation (hashes differ or no cache)
# ============================================================================
ISSUES_FOUND=0
WARNINGS=""
# Function to add warning
add_warning() {
WARNINGS+=" - $1"$'\n'
((ISSUES_FOUND++))
}
# 1. Check all installed plugins have valid plugin.json
if [[ -d "$MARKETPLACE_PATH/plugins" ]]; then
for plugin_dir in "$MARKETPLACE_PATH/plugins"/*/; do
if [[ -d "$plugin_dir" ]]; then
plugin_name=$(basename "$plugin_dir")
plugin_json="$plugin_dir/.claude-plugin/plugin.json"
if [[ ! -f "$plugin_json" ]]; then
add_warning "$plugin_name: missing .claude-plugin/plugin.json"
continue
fi
# Basic JSON validation
if ! python3 -c "import json; json.load(open('$plugin_json'))" 2>/dev/null; then
add_warning "$plugin_name: invalid JSON in plugin.json"
continue
fi
# Check required fields
if ! python3 -c "
import json
with open('$plugin_json') as f:
data = json.load(f)
required = ['name', 'version', 'description']
missing = [r for r in required if r not in data]
if missing:
exit(1)
" 2>/dev/null; then
add_warning "$plugin_name: plugin.json missing required fields"
fi
fi
done
fi
# 2. Check hooks.json files are properly formatted
if [[ -d "$MARKETPLACE_PATH/plugins" ]]; then
while IFS= read -r -d '' hooks_file; do
plugin_name=$(basename "$(dirname "$(dirname "$hooks_file")")")
# Validate JSON
if ! python3 -c "import json; json.load(open('$hooks_file'))" 2>/dev/null; then
add_warning "$plugin_name: invalid JSON in hooks/hooks.json"
continue
fi
# Validate hook structure
if ! python3 -c "
import json
with open('$hooks_file') as f:
data = json.load(f)
if 'hooks' not in data:
exit(1)
valid_events = ['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'SessionStart', 'SessionEnd', 'Notification', 'Stop', 'SubagentStop', 'PreCompact']
for event in data['hooks']:
if event not in valid_events:
exit(1)
for hook in data['hooks'][event]:
# Support both flat structure (type at top) and nested structure (matcher + hooks array)
if 'type' in hook:
# Flat structure: {type: 'command', command: '...'}
pass
elif 'matcher' in hook and 'hooks' in hook:
# Nested structure: {matcher: '...', hooks: [{type: 'command', ...}]}
for nested_hook in hook['hooks']:
if 'type' not in nested_hook:
exit(1)
else:
exit(1)
" 2>/dev/null; then
add_warning "$plugin_name: hooks.json has invalid structure or events"
fi
done < <(find "$MARKETPLACE_PATH/plugins" -path "*/hooks/hooks.json" -print0 2>/dev/null)
fi
# 3. Check agent references are valid (agent files exist and are markdown)
if [[ -d "$MARKETPLACE_PATH/plugins" ]]; then
while IFS= read -r -d '' agent_file; do
plugin_name=$(basename "$(dirname "$(dirname "$agent_file")")")
agent_name=$(basename "$agent_file")
# Check file is not empty
if [[ ! -s "$agent_file" ]]; then
add_warning "$plugin_name: empty agent file $agent_name"
continue
fi
# Check file has markdown content (at least a header)
if ! grep -q '^#' "$agent_file" 2>/dev/null; then
add_warning "$plugin_name: agent $agent_name missing markdown header"
fi
done < <(find "$MARKETPLACE_PATH/plugins" -path "*/agents/*.md" -print0 2>/dev/null)
fi
# ============================================================================
# Store new hash and report results
# ============================================================================
# Always store the new hash (even if issues found - we don't want to recheck)
echo "$CURRENT_HASH" > "$HASH_FILE"
# Report any issues found (non-blocking warning)
if [[ $ISSUES_FOUND -gt 0 ]]; then
echo "$PREFIX Plugin contract validation found $ISSUES_FOUND issue(s):"
echo "$WARNINGS"
echo "$PREFIX Run /validate-contracts for full details"
fi
# Always exit 0 (non-blocking)
exit 0

View File

@@ -0,0 +1,174 @@
#!/bin/bash
# contract-validator breaking change detection hook
# Warns when plugin interface changes might break consumers
# This is a PostToolUse hook - non-blocking, warnings only
PREFIX="[contract-validator]"
# Check if warnings are enabled (default: true)
if [[ "${CONTRACT_VALIDATOR_BREAKING_WARN:-true}" != "true" ]]; then
exit 0
fi
# Read tool input from stdin
INPUT=$(cat)
# Extract file_path from JSON input
FILE_PATH=$(echo "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
# If no file_path found, exit silently
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Check if file is a plugin interface file
is_interface_file() {
local file="$1"
case "$file" in
*/plugin.json) return 0 ;;
*/.claude-plugin/plugin.json) return 0 ;;
*/hooks.json) return 0 ;;
*/hooks/hooks.json) return 0 ;;
*/.mcp.json) return 0 ;;
*/agents/*.md) return 0 ;;
*/commands/*.md) return 0 ;;
*/skills/*.md) return 0 ;;
esac
return 1
}
# Exit if not an interface file
if ! is_interface_file "$FILE_PATH"; then
exit 0
fi
# Check if file exists and is in a git repo
if [[ ! -f "$FILE_PATH" ]]; then
exit 0
fi
# Get the directory containing the file
FILE_DIR=$(dirname "$FILE_PATH")
FILE_NAME=$(basename "$FILE_PATH")
# Try to get the previous version from git
cd "$FILE_DIR" 2>/dev/null || exit 0
# Check if we're in a git repo
if ! git rev-parse --git-dir > /dev/null 2>&1; then
exit 0
fi
# Get previous version (HEAD version before current changes)
PREV_CONTENT=$(git show HEAD:"$FILE_PATH" 2>/dev/null || echo "")
# If no previous version, this is a new file - no breaking changes possible
if [ -z "$PREV_CONTENT" ]; then
exit 0
fi
# Read current content
CURR_CONTENT=$(cat "$FILE_PATH" 2>/dev/null || echo "")
if [ -z "$CURR_CONTENT" ]; then
exit 0
fi
BREAKING_CHANGES=()
# Detect breaking changes based on file type
case "$FILE_PATH" in
*/plugin.json|*/.claude-plugin/plugin.json)
# Check for removed or renamed fields in plugin.json
# Check if name changed
PREV_NAME=$(echo "$PREV_CONTENT" | grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1)
CURR_NAME=$(echo "$CURR_CONTENT" | grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1)
if [ -n "$PREV_NAME" ] && [ "$PREV_NAME" != "$CURR_NAME" ]; then
BREAKING_CHANGES+=("Plugin name changed - consumers may need updates")
fi
# Check if version had major bump (semantic versioning)
PREV_VER=$(echo "$PREV_CONTENT" | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([0-9]*\)\..*/\1/')
CURR_VER=$(echo "$CURR_CONTENT" | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([0-9]*\)\..*/\1/')
if [ -n "$PREV_VER" ] && [ -n "$CURR_VER" ] && [ "$CURR_VER" -gt "$PREV_VER" ] 2>/dev/null; then
BREAKING_CHANGES+=("Major version bump detected - verify breaking changes documented")
fi
;;
*/hooks.json|*/hooks/hooks.json)
# Check for removed hook events
PREV_EVENTS=$(echo "$PREV_CONTENT" | grep -oE '"(PreToolUse|PostToolUse|UserPromptSubmit|SessionStart|SessionEnd|Notification|Stop|SubagentStop|PreCompact)"' | sort -u)
CURR_EVENTS=$(echo "$CURR_CONTENT" | grep -oE '"(PreToolUse|PostToolUse|UserPromptSubmit|SessionStart|SessionEnd|Notification|Stop|SubagentStop|PreCompact)"' | sort -u)
# Find removed events
REMOVED_EVENTS=$(comm -23 <(echo "$PREV_EVENTS") <(echo "$CURR_EVENTS") 2>/dev/null)
if [ -n "$REMOVED_EVENTS" ]; then
BREAKING_CHANGES+=("Hook events removed: $(echo $REMOVED_EVENTS | tr '\n' ' ')")
fi
# Check for changed matchers
PREV_MATCHERS=$(echo "$PREV_CONTENT" | grep -o '"matcher"[[:space:]]*:[[:space:]]*"[^"]*"' | sort -u)
CURR_MATCHERS=$(echo "$CURR_CONTENT" | grep -o '"matcher"[[:space:]]*:[[:space:]]*"[^"]*"' | sort -u)
if [ "$PREV_MATCHERS" != "$CURR_MATCHERS" ]; then
BREAKING_CHANGES+=("Hook matchers changed - verify tool coverage")
fi
;;
*/.mcp.json)
# Check for removed MCP servers
PREV_SERVERS=$(echo "$PREV_CONTENT" | grep -o '"[^"]*"[[:space:]]*:' | grep -v "mcpServers" | sort -u)
CURR_SERVERS=$(echo "$CURR_CONTENT" | grep -o '"[^"]*"[[:space:]]*:' | grep -v "mcpServers" | sort -u)
REMOVED_SERVERS=$(comm -23 <(echo "$PREV_SERVERS") <(echo "$CURR_SERVERS") 2>/dev/null)
if [ -n "$REMOVED_SERVERS" ]; then
BREAKING_CHANGES+=("MCP servers removed - tools may be unavailable")
fi
;;
*/agents/*.md)
# Check if agent file was significantly reduced (might indicate removal of capabilities)
PREV_LINES=$(echo "$PREV_CONTENT" | wc -l)
CURR_LINES=$(echo "$CURR_CONTENT" | wc -l)
# If more than 50% reduction, warn
if [ "$PREV_LINES" -gt 10 ] && [ "$CURR_LINES" -lt $((PREV_LINES / 2)) ]; then
BREAKING_CHANGES+=("Agent definition significantly reduced - capabilities may be removed")
fi
# Check if agent name/description changed in frontmatter
PREV_DESC=$(echo "$PREV_CONTENT" | head -20 | grep -i "description" | head -1)
CURR_DESC=$(echo "$CURR_CONTENT" | head -20 | grep -i "description" | head -1)
if [ -n "$PREV_DESC" ] && [ "$PREV_DESC" != "$CURR_DESC" ]; then
BREAKING_CHANGES+=("Agent description changed - verify consumer expectations")
fi
;;
*/commands/*.md|*/skills/*.md)
# Check if command/skill was significantly changed
PREV_LINES=$(echo "$PREV_CONTENT" | wc -l)
CURR_LINES=$(echo "$CURR_CONTENT" | wc -l)
if [ "$PREV_LINES" -gt 10 ] && [ "$CURR_LINES" -lt $((PREV_LINES / 2)) ]; then
BREAKING_CHANGES+=("Command/skill significantly reduced - behavior may change")
fi
;;
esac
# Output warnings if any breaking changes detected
if [[ ${#BREAKING_CHANGES[@]} -gt 0 ]]; then
echo ""
echo "$PREFIX WARNING: Potential breaking changes in $(basename "$FILE_PATH")"
echo "$PREFIX ============================================"
for change in "${BREAKING_CHANGES[@]}"; do
echo "$PREFIX - $change"
done
echo "$PREFIX ============================================"
echo "$PREFIX Consider updating CHANGELOG and notifying consumers"
echo ""
fi
# Always exit 0 - non-blocking
exit 0

View File

@@ -0,0 +1,21 @@
{
"hooks": {
"SessionStart": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/auto-validate.sh"
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/breaking-change-check.sh"
}
]
}
]
}
}