
Health Check
- 64 installs
- 49 repo stars
- Updated August 4, 2026
- laurigates/claude-plugins
Helps with ai & agent building tasks.
About
health-check is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- health-check
- AI & Agent Building
- AI-coding skill
Health Check by the numbers
- 64 all-time installs (skills.sh)
- Ranked #6,160 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/laurigates/claude-plugins --skill health-checkAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 64 |
|---|---|
| repo stars | ★ 49 |
| Last updated | August 4, 2026 |
| Repository | laurigates/claude-plugins ↗ |
What it does
Helps with ai & agent building tasks.
Files
/health:check
Single entry point for Claude Code health diagnostics. Runs environment checks (plugin registry, settings, hooks, MCP servers, SessionStart executability, pre-commit validity, permissions coverage, marketplace enrollment) plus optional deeper audits, and routes --fix to the appropriate internal workflow.
When to Use This Skill
| Use this skill when... | Use another approach when... |
|---|---|
| Running Claude Code diagnostics | Viewing raw settings (use Read on settings.json) |
| Troubleshooting plugin registry issues | Inspecting marketplace metadata manually |
| Auditing plugins for project fit | Installing a specific plugin (use /plugin install) |
| Checking skill agentic-optimisation quality | Editing a single known skill |
One-stop --fix across registry/stack/agentic | Precise surgical edits to a single file |
Context
- Current project: !
pwd - Project settings exists: !
find .claude -maxdepth 1 -name 'settings.json' - Local settings exists: !
find .claude -maxdepth 1 -name 'settings.local.json'
Parameters
Parse these from $ARGUMENTS:
| Parameter | Description |
|---|---|
| `--scope=<all\ | registry\ |
--fix | Apply fixes to findings (prompts for confirmation). |
--dry-run | Preview fixes without modifying files. |
--verbose | Include detailed diagnostics. |
Scope semantics:
| Scope | Covers |
|---|---|
registry | Plugin registry health (orphaned projectPath, stale enabledPlugins, registry-vs-settings drift) |
stack | Enabled plugins vs detected project tech stack |
agentic | Skill/command/agent agentic-optimisation compliance |
runtime | ~/.claude.json bloat (dead projects[], dead githubRepoPaths[*], orphaned disabledMcpServers, duplicate MCP naming). Read-only audit. |
all | Environment checks + all four audits |
Execution
Execute this diagnostic router. Default scope is all when --scope is not provided.
Step 1: Run environment checks (always)
Environment checks run regardless of --scope. They cover the baseline health of the Claude Code installation and the current project's .claude/ directory.
1a. Core environment scripts
bash "${CLAUDE_SKILL_DIR}/scripts/check-plugins.sh" --home-dir "$HOME" --project-dir "$(pwd)"
bash "${CLAUDE_SKILL_DIR}/scripts/check-settings.sh" --home-dir "$HOME" --project-dir "$(pwd)"
bash "${CLAUDE_SKILL_DIR}/scripts/check-hooks.sh" --home-dir "$HOME" --project-dir "$(pwd)"
bash "${CLAUDE_SKILL_DIR}/scripts/check-mcp.sh" --home-dir "$HOME" --project-dir "$(pwd)"Parse STATUS= and ISSUES: from each. Pass --verbose when set on $ARGUMENTS.
1b. SessionStart smoke test
Check whether scripts/install_pkgs.sh (or any script registered in the SessionStart hook in .claude/settings.json) is executable and exits cleanly in both remote and local contexts.
1. Locate the SessionStart hook command from .claude/settings.json (look for the command field). 2. If a script is found, run:
CLAUDE_CODE_REMOTE=true bash <script-path>Capture exit code. Expected: 0. 3. Run again to verify idempotency — expected: 0. 4. Run with remote guard off:
CLAUDE_CODE_REMOTE=false bash <script-path>Expected: 0 (typically a no-op). 5. Report:
- OK: All three exit 0
- WARN: Script exists but is not registered in settings.json hook
- ERROR: Script exits non-zero, or script referenced in hook does not exist
1c. Pre-commit config validator
If .pre-commit-config.yaml exists:
pre-commit validate-config .pre-commit-config.yamlReport:
- OK: exits 0 (config is valid)
- WARN:
pre-commitnot installed — skip check, suggestpip install pre-commit - ERROR: exits non-zero — show validation error
1d. Permissions coverage check
Compare tools referenced in project files against permissions.allow in .claude/settings.json.
1. Read permissions.allow from .claude/settings.json. Extract the command prefix from each Bash(<prefix>:*) entry. 2. Scan these files for tool invocations:
justfile/Justfile— commands on recipe linesMakefile— shell commands on recipe lines.pre-commit-config.yaml—entry:fields
3. For each tool found in project files:
- Flag as MISSING if no matching
Bash(<tool>:*)entry exists inpermissions.allow
4. For each Bash(<tool>:*) entry in permissions.allow:
- Flag as UNUSED if the tool is not found in any project file (informational, not an error)
Scoring:
- OK: No missing permissions
- WARN: 1–3 missing permissions
- ERROR: 4+ missing permissions
1e. Marketplace enrollment check
The local marketplace key (set by claude marketplace add <name>) is user-chosen and varies between installs (commonly laurigates-claude-plugins, sometimes claude-plugins). Identify the marketplace by its stable source.repo, not by a hardcoded local key.
1. Read .claude/settings.json. 2. Scan all entries under extraKnownMarketplaces and find the one whose source.repo equals "laurigates/claude-plugins". Capture that entry's key as $MP_KEY. 3. Check that enabledPlugins contains at least one key with the suffix @$MP_KEY. 4. Report:
- OK: Both checks pass
- WARN:
enabledPluginshas no@$MP_KEYentries (marketplace enrolled but no plugins enabled) - ERROR: no
extraKnownMarketplacesentry withsource.repo = laurigates/claude-plugins(run/configure:claude-plugins --fixto add it)
Reference jq snippet (for verification or fix scripts):
MP_KEY=$(jq -r '.extraKnownMarketplaces // {} | to_entries | map(select(.value.source.repo == "laurigates/claude-plugins")) | .[0].key // empty' .claude/settings.json)
if [ -z "$MP_KEY" ]; then
echo "ERROR: no extraKnownMarketplaces entry with source.repo = laurigates/claude-plugins"
else
jq -e --arg k "@$MP_KEY" '.enabledPlugins // {} | to_entries | map(select(.key | endswith($k))) | length > 0' .claude/settings.json >/dev/null \
&& echo "OK: marketplace enrolled as $MP_KEY with enabled plugins" \
|| echo "WARN: marketplace $MP_KEY enrolled but no @${MP_KEY} entries in enabledPlugins"
fiStep 2: Run scope-specific audits
For --scope=registry or all:
bash "${CLAUDE_PLUGIN_ROOT}/skills/health-plugins/scripts/check-registry.sh" \
--home-dir "$HOME" --project-dir "$(pwd)"Parse STATUS=, PLUGIN_COUNT=, ORPHANED_ENTRIES=, STALE_ENABLED_ENTRIES=, and ISSUES:.
For --scope=stack or all: follow the tech-stack audit steps from the internal health-audit skill (see ${CLAUDE_PLUGIN_ROOT}/skills/health-audit/SKILL.md and its REFERENCE.md).
For --scope=agentic or all: follow the skill-quality audit steps from the internal health-agentic-audit skill (see ${CLAUDE_PLUGIN_ROOT}/skills/health-agentic-audit/SKILL.md and its REFERENCE.md).
For --scope=runtime or all:
bash "${CLAUDE_SKILL_DIR}/scripts/check-runtime.sh" --home-dir "$HOME" --project-dir "$(pwd)"Parse STATUS=, RUNTIME_SIZE_BYTES=, PROJECTS_TOTAL=, PROJECTS_DEAD=, GH_PATHS_TOTAL=, GH_PATHS_DEAD=, ORPHAN_DISABLED_MCP=, DUPLICATE_MCP=, CLEANUP_SUGGESTED=, and ISSUES:. Pass --verbose to list every dead path / orphaned server (default is a single rolled-up issue per category to keep output compact).
The runtime scope audits ~/.claude.json — the harness state file that grows with every session and is never auto-pruned. It reports four classes of bloat: dead projects[] keys, dead githubRepoPaths[*] worktree paths, orphaned disabledMcpServers[] entries, and bare-vs-namespaced duplicate MCP names. The audit is read-only: it prints suggested jq filters for the operator to run manually after closing other Claude Code sessions.
Concurrent-write warning. The harness rewrites ~/.claude.json on session end. Before acting on the audit's suggested cleanups, close every other Claude Code session — otherwise the in-memory state of a live session will clobber your edits when it next writes the file. An automated cleanup writer is out of scope for this audit.Step 3: Report findings
Print a consolidated report grouped by scope:
1. Environment — plugins/settings/hooks/MCP status + counts, SessionStart smoke test, pre-commit validity, permissions coverage, marketplace enrollment 2. Registry — orphaned projectPath entries, stale enabledPlugins keys, registry-vs-settings drift 3. Stack — detected stack + relevant/irrelevant/missing plugin recommendations 4. Agentic — skills missing optimisation tables, bare CLI commands, stale reviews 5. Runtime — ~/.claude.json size, dead projects/githubRepoPaths, orphaned disabledMcpServers, duplicate MCP naming (read-only — no --fix path)
Use STATUS= indicators (OK/WARN/ERROR) and issue counts per scope. Include a summary table:
| Check | Status | Issues |
|---|---|---|
| Plugin registry | OK/WARN/ERROR | ... |
| Settings files | OK/WARN/ERROR | ... |
| Hooks configuration | OK/WARN/ERROR | ... |
| MCP servers | OK/WARN/ERROR | ... |
| SessionStart smoke test | OK/WARN/ERROR | ... |
| Pre-commit config | OK/WARN/ERROR/SKIP | ... |
| Permissions coverage | OK/WARN/ERROR | ... |
| Marketplace enrollment | OK/WARN/ERROR | ... |
| Registry audit | OK/WARN/ERROR | ... |
| Stack audit | OK/WARN/ERROR | ... |
| Agentic audit | OK/WARN/ERROR | ... |
| Runtime audit | OK/WARN/ERROR | ... |
See REFERENCE.md for the full report template.
Step 4: Apply fixes (if --fix)
If --fix is set:
1. If --scope=all AND findings exist in multiple scopes, use AskUserQuestion to let the user pick which scopes to fix (multi-select: registry, stack, agentic). 2. For each selected scope, delegate:
| Scope | Delegate to |
|---|---|
registry | bash "${CLAUDE_PLUGIN_ROOT}/skills/health-plugins/scripts/fix-registry.sh" --home-dir "$HOME" --project-dir "$(pwd)" (pass --dry-run when set) |
stack | Follow the --fix flow in ${CLAUDE_PLUGIN_ROOT}/skills/health-audit/SKILL.md (Step 6) |
agentic | Follow the --fix flow in ${CLAUDE_PLUGIN_ROOT}/skills/health-agentic-audit/SKILL.md (Step 6) |
3. Parse each script's output (STATUS=, REMOVED_COUNT=, MESSAGE=, RESTART_REQUIRED=) and report what changed. 4. If any fix reports RESTART_REQUIRED=true, remind the user to restart Claude Code.
Step 5: Verify
Re-run the relevant checks and confirm issue counts have dropped.
Agentic Optimizations
| Context | Command |
|---|---|
| Full scan | /health:check |
| Registry only | /health:check --scope=registry |
| Stack relevance only | /health:check --scope=stack |
| Agentic audit only | /health:check --scope=agentic |
| Runtime state audit (~/.claude.json) | /health:check --scope=runtime |
| Fix everything (interactive) | /health:check --fix |
| Dry-run preview of fixes | /health:check --fix --dry-run |
| Detailed diagnostics | /health:check --verbose |
| Check plugin registry exists | find ~/.claude/plugins -name 'installed_plugins.json' |
| Validate settings JSON | find .claude -maxdepth 1 -name 'settings.json' |
| Smoke-test install script | CLAUDE_CODE_REMOTE=true bash scripts/install_pkgs.sh |
| Validate pre-commit config | pre-commit validate-config .pre-commit-config.yaml |
| Check marketplace enrollment | find .claude -maxdepth 1 -name 'settings.json' then grep for extraKnownMarketplaces |
Known Issues
| Issue | Symptom | Fix path |
|---|---|---|
| #14202 | Plugin shows "installed" but not active | /health:check --scope=registry --fix |
Stale enabledPlugins key in settings.json | Plugin appears enabled but no registry/marketplace entry | /health:check --scope=registry --fix |
Orphaned projectPath | Plugin installed for deleted project | /health:check --scope=registry --fix |
| Invalid settings JSON | Settings file won't load | /health:check |
| Missing marketplace enrollment | laurigates/claude-plugins skills unavailable in web sessions | /configure:claude-plugins --fix |
Health Check Reference
Diagnostic Report Template
Claude Code Health Check
========================
Project: <current-directory>
Date: <timestamp>
Plugin Registry
---------------
Status: [OK|WARN|ERROR]
- Installed plugins: N
- Project-scoped: N
- Orphaned entries: N
- Issues: <details if any>
Settings Files
--------------
Status: [OK|WARN|ERROR]
- User settings: [OK|MISSING|INVALID]
- Project settings: [OK|MISSING|INVALID]
- Local settings: [OK|MISSING|N/A]
- Permission patterns: N configured
- Issues: <details if any>
Hooks
-----
Status: [OK|WARN|ERROR|N/A]
- Configured hooks: N
- Issues: <details if any>
MCP Servers
-----------
Status: [OK|WARN|ERROR|N/A]
- Configured servers: N
- Issues: <details if any>
Summary
-------
[All checks passed | N issues found]
Recommended Actions:
1. <action if needed>
2. <action if needed>
Run `/health:plugins --fix` to fix plugin registry issues.
Run `/health:settings --fix` to fix settings issues.Known Issues Database
| Issue | Symptoms | Solution |
|---|---|---|
| #14202 | Plugin shows "installed" but not active in project | Run /health:plugins --fix |
| Orphaned projectPath | Plugin was installed for deleted project | Run /health:plugins --fix |
| Invalid JSON | Settings file won't load | Validate and fix JSON syntax |
| Hook timeout | Commands hang or fail silently | Check hook timeout settings |
#!/usr/bin/env bash
# Check Hooks Configuration
# Validates hook commands and timeouts from Claude Code settings files.
# Usage: bash check-hooks.sh --home-dir <path> --project-dir <path> [--verbose]
set -uo pipefail
home_dir=""
project_dir=""
verbose_mode=false
while [ $# -gt 0 ]; do
case "$1" in
--home-dir) home_dir="$2"; shift 2 ;;
--project-dir) project_dir="$2"; shift 2 ;;
--verbose) verbose_mode=true; shift ;;
*) shift ;;
esac
done
: "${home_dir:=$HOME}"
: "${project_dir:=$(pwd)}"
echo "=== HOOKS CONFIGURATION ==="
issue_count=0
check_status="OK"
issues_list=""
total_hooks=0
# Check jq availability
if ! command -v jq >/dev/null 2>&1; then
echo "JQ_AVAILABLE=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=missing_tool MSG=jq is required but not installed"
echo "=== END HOOKS CONFIGURATION ==="
exit 1
fi
# Settings files that may contain hooks
settings_files=(
"${home_dir}/.claude/settings.json"
"${project_dir}/.claude/settings.json"
"${home_dir}/.claude/settings.local.json"
"${project_dir}/.claude/settings.local.json"
)
for settings_file in "${settings_files[@]}"; do
if [ ! -f "$settings_file" ]; then
continue
fi
# Check if hooks key exists
has_hooks=$(jq 'has("hooks")' "$settings_file" 2>/dev/null || echo "false")
if [ "$has_hooks" != "true" ]; then
continue
fi
# Get hook event names
hook_events=$(jq -r '.hooks | keys[]' "$settings_file" 2>/dev/null)
while IFS= read -r event_name; do
[ -z "$event_name" ] && continue
# Get hooks array for this event
hook_count=$(jq -r ".hooks[\"${event_name}\"] | length" "$settings_file" 2>/dev/null || echo "0")
for ((i=0; i<hook_count; i++)); do
# Get hook entries for this event item
entry_hooks=$(jq -r ".hooks[\"${event_name}\"][$i].hooks | length" "$settings_file" 2>/dev/null || echo "0")
for ((j=0; j<entry_hooks; j++)); do
total_hooks=$((total_hooks + 1))
hook_type=$(jq -r ".hooks[\"${event_name}\"][$i].hooks[$j].type // \"unknown\"" "$settings_file" 2>/dev/null)
hook_command=$(jq -r ".hooks[\"${event_name}\"][$i].hooks[$j].command // \"\"" "$settings_file" 2>/dev/null)
hook_timeout=$(jq -r ".hooks[\"${event_name}\"][$i].hooks[$j].timeout // \"default\"" "$settings_file" 2>/dev/null)
hook_matcher=$(jq -r ".hooks[\"${event_name}\"][$i].matcher // \"*\"" "$settings_file" 2>/dev/null)
if [ "$verbose_mode" = true ]; then
echo "HOOK: event=${event_name} matcher=${hook_matcher} type=${hook_type} timeout=${hook_timeout} file=${settings_file}"
[ -n "$hook_command" ] && echo " COMMAND=${hook_command}"
fi
# Validate command exists (for command-type hooks)
if [ "$hook_type" = "command" ] && [ -n "$hook_command" ]; then
# Extract the base tool from the command (first word)
base_tool=$(echo "$hook_command" | awk '{print $1}' | sed 's/"//g')
if [ "$base_tool" != "bash" ] && ! command -v "$base_tool" >/dev/null 2>&1; then
# Check if it's a file path
if [ ! -f "$base_tool" ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=missing_command EVENT=${event_name} COMMAND=${base_tool} FILE=${settings_file}\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
fi
fi
fi
# Flag high timeouts (over 60 seconds)
if [ "$hook_timeout" != "default" ] && [ "$hook_timeout" != "null" ]; then
timeout_ms="${hook_timeout}"
if [ "$timeout_ms" -gt 60000 ] 2>/dev/null; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=high_timeout EVENT=${event_name} TIMEOUT=${timeout_ms}ms FILE=${settings_file}\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
fi
fi
done
done
done <<< "$hook_events"
done
if [ "$total_hooks" -eq 0 ]; then
echo "HOOKS_CONFIGURED=false"
check_status="N_A"
else
echo "HOOKS_CONFIGURED=true"
fi
echo "HOOK_COUNT=${total_hooks}"
echo "STATUS=${check_status}"
echo "ISSUE_COUNT=${issue_count}"
if [ -n "$issues_list" ]; then
echo "ISSUES:"
echo -e "$issues_list" | sed '/^$/d'
fi
echo "=== END HOOKS CONFIGURATION ==="
#!/usr/bin/env bash
# Check MCP Server Configuration
# Validates MCP servers from .mcp.json and settings files.
# Usage: bash check-mcp.sh --home-dir <path> --project-dir <path> [--verbose]
set -uo pipefail
home_dir=""
project_dir=""
verbose_mode=false
while [ $# -gt 0 ]; do
case "$1" in
--home-dir) home_dir="$2"; shift 2 ;;
--project-dir) project_dir="$2"; shift 2 ;;
--verbose) verbose_mode=true; shift ;;
*) shift ;;
esac
done
: "${home_dir:=$HOME}"
: "${project_dir:=$(pwd)}"
echo "=== MCP SERVERS ==="
issue_count=0
check_status="OK"
issues_list=""
server_count=0
# Check jq availability
if ! command -v jq >/dev/null 2>&1; then
echo "JQ_AVAILABLE=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=missing_tool MSG=jq is required but not installed"
echo "=== END MCP SERVERS ==="
exit 1
fi
# MCP configuration sources
mcp_sources=(
"${home_dir}/.mcp.json"
"${project_dir}/.mcp.json"
)
echo "MCP_SOURCES:"
for mcp_file in "${mcp_sources[@]}"; do
if [ -f "$mcp_file" ]; then
echo " - FILE=${mcp_file} EXISTS=true"
# Validate JSON
json_error=$(jq empty "$mcp_file" 2>&1)
if [ $? -ne 0 ]; then
echo " VALID=false ERROR=${json_error}"
issues_list="${issues_list} - SEVERITY=ERROR TYPE=invalid_json FILE=${mcp_file} MSG=${json_error}\n"
issue_count=$((issue_count + 1))
check_status="ERROR"
continue
fi
# List servers
server_keys=$(jq -r '.mcpServers // {} | keys[]' "$mcp_file" 2>/dev/null)
while IFS= read -r server_name; do
[ -z "$server_name" ] && continue
server_count=$((server_count + 1))
server_command=$(jq -r ".mcpServers[\"${server_name}\"].command // \"\"" "$mcp_file" 2>/dev/null)
server_args=$(jq -r ".mcpServers[\"${server_name}\"].args // [] | join(\" \")" "$mcp_file" 2>/dev/null)
if [ "$verbose_mode" = true ]; then
echo " SERVER: name=${server_name} command=${server_command} args=${server_args}"
fi
# Validate command exists
if [ -n "$server_command" ]; then
if ! command -v "$server_command" >/dev/null 2>&1; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=missing_command SERVER=${server_name} COMMAND=${server_command} FILE=${mcp_file}\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
fi
fi
# Check for required environment variables
env_keys=$(jq -r ".mcpServers[\"${server_name}\"].env // {} | keys[]" "$mcp_file" 2>/dev/null)
while IFS= read -r env_key; do
[ -z "$env_key" ] && continue
env_value=$(jq -r ".mcpServers[\"${server_name}\"].env[\"${env_key}\"]" "$mcp_file" 2>/dev/null)
# Check if env var is empty or references an unset variable
if [ -z "$env_value" ] || [ "$env_value" = "null" ]; then
if [ -z "${!env_key:-}" ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=missing_env SERVER=${server_name} VAR=${env_key} FILE=${mcp_file}\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
fi
fi
done <<< "$env_keys"
done <<< "$server_keys"
else
echo " - FILE=${mcp_file} EXISTS=false"
fi
done
# Check settings files for enabledMcpjsonServers
settings_files=(
"${home_dir}/.claude/settings.json"
"${project_dir}/.claude/settings.json"
)
for settings_file in "${settings_files[@]}"; do
if [ -f "$settings_file" ]; then
mcp_enabled=$(jq -r '.enabledMcpjsonServers // {} | length' "$settings_file" 2>/dev/null || echo "0")
if [ "$mcp_enabled" -gt 0 ]; then
echo "ENABLED_MCP_SERVERS_IN=$(basename "$(dirname "$settings_file")")/$(basename "$settings_file") COUNT=${mcp_enabled}"
if [ "$verbose_mode" = true ]; then
jq -r '.enabledMcpjsonServers // {} | to_entries[] | " ENABLED: \(.key)=\(.value)"' "$settings_file" 2>/dev/null
fi
fi
fi
done
if [ "$server_count" -eq 0 ]; then
echo "MCP_CONFIGURED=false"
check_status="N_A"
else
echo "MCP_CONFIGURED=true"
fi
echo "SERVER_COUNT=${server_count}"
echo "STATUS=${check_status}"
echo "ISSUE_COUNT=${issue_count}"
if [ -n "$issues_list" ]; then
echo "ISSUES:"
echo -e "$issues_list" | sed '/^$/d'
fi
echo "=== END MCP SERVERS ==="
#!/usr/bin/env bash
# Check Plugin Registry
# Validates installed_plugins.json for orphaned entries, scope conflicts,
# and mismatches between installed and enabled plugins.
# Usage: bash check-plugins.sh --home-dir <path> --project-dir <path> [--fix] [--verbose]
set -uo pipefail
home_dir=""
project_dir=""
fix_mode=false
verbose_mode=false
while [ $# -gt 0 ]; do
case "$1" in
--home-dir) home_dir="$2"; shift 2 ;;
--project-dir) project_dir="$2"; shift 2 ;;
--fix) fix_mode=true; shift ;;
--verbose) verbose_mode=true; shift ;;
*) shift ;;
esac
done
: "${home_dir:=$HOME}"
: "${project_dir:=$(pwd)}"
echo "=== PLUGIN REGISTRY ==="
registry_file="${home_dir}/.claude/plugins/installed_plugins.json"
user_settings="${home_dir}/.claude/settings.json"
issue_count=0
check_status="OK"
issues_list=""
# Check jq availability
if ! command -v jq >/dev/null 2>&1; then
echo "JQ_AVAILABLE=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=missing_tool MSG=jq is required but not installed"
echo "=== END PLUGIN REGISTRY ==="
exit 1
fi
# Check registry exists
if [ ! -f "$registry_file" ]; then
echo "REGISTRY_EXISTS=false"
echo "REGISTRY_PATH=${registry_file}"
echo "STATUS=WARN"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=WARN TYPE=missing_registry MSG=Plugin registry file not found"
echo "=== END PLUGIN REGISTRY ==="
exit 0
fi
echo "REGISTRY_EXISTS=true"
echo "REGISTRY_PATH=${registry_file}"
# Validate JSON syntax
json_error=$(jq empty "$registry_file" 2>&1)
if [ $? -ne 0 ]; then
echo "REGISTRY_VALID=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=invalid_json MSG=${json_error}"
echo "=== END PLUGIN REGISTRY ==="
exit 1
fi
echo "REGISTRY_VALID=true"
# Count plugins (tr -d '\r' to strip Windows CR from jq output)
plugin_count=$(jq '.plugins | length' "$registry_file" 2>/dev/null | tr -d '\r' || echo "0")
echo "PLUGIN_COUNT=${plugin_count}"
# Count by scope
project_scoped=0
global_scoped=0
orphaned_count=0
# Extract plugin entries with project paths.
# tr -d '\r' strips CR line endings that jq emits on Windows; without it,
# every key but the last carries a trailing \r and breaks subsequent lookups.
plugin_keys=$(jq -r '.plugins | keys[]' "$registry_file" 2>/dev/null | tr -d '\r')
while IFS= read -r plugin_key; do
[ -z "$plugin_key" ] && continue
plugin_path=$(jq -r ".plugins[\"${plugin_key}\"][0].projectPath // \"\"" "$registry_file" 2>/dev/null | tr -d '\r')
if [ -n "$plugin_path" ]; then
project_scoped=$((project_scoped + 1))
# Check if project path exists
if [ ! -d "$plugin_path" ]; then
orphaned_count=$((orphaned_count + 1))
issues_list="${issues_list} - SEVERITY=WARN TYPE=orphaned PLUGIN=${plugin_key} PATH=${plugin_path}\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
fi
else
global_scoped=$((global_scoped + 1))
fi
if [ "$verbose_mode" = true ]; then
plugin_source=$(jq -r ".plugins[\"${plugin_key}\"][0].source // \"unknown\"" "$registry_file" 2>/dev/null)
plugin_scope=$(jq -r ".plugins[\"${plugin_key}\"][0].scope // \"user\"" "$registry_file" 2>/dev/null)
echo "PLUGIN: name=${plugin_key} scope=${plugin_scope} source=${plugin_source}"
fi
done <<< "$plugin_keys"
echo "PROJECT_SCOPED=${project_scoped}"
echo "GLOBAL_SCOPED=${global_scoped}"
echo "ORPHANED_ENTRIES=${orphaned_count}"
# Check enabled plugins vs installed plugins
if [ -f "$user_settings" ]; then
enabled_count=$(jq '.enabledPlugins // {} | length' "$user_settings" 2>/dev/null | tr -d '\r' || echo "0")
echo "ENABLED_IN_SETTINGS=${enabled_count}"
# Find enabled but not installed (tr -d '\r' for Windows CRLF, see comment above)
enabled_keys=$(jq -r '.enabledPlugins // {} | keys[]' "$user_settings" 2>/dev/null | tr -d '\r')
while IFS= read -r enabled_key; do
[ -z "$enabled_key" ] && continue
if ! jq -e ".plugins[\"${enabled_key}\"]" "$registry_file" >/dev/null 2>&1; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=enabled_not_installed PLUGIN=${enabled_key}\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
fi
done <<< "$enabled_keys"
# Find disabled plugins (tr -d '\r' so the numeric compare below works on Windows)
disabled_count=$(jq '[.enabledPlugins // {} | to_entries[] | select(.value == false)] | length' "$user_settings" 2>/dev/null | tr -d '\r' || echo "0")
if [ "$disabled_count" -gt 0 ]; then
echo "DISABLED_PLUGINS=${disabled_count}"
if [ "$verbose_mode" = true ]; then
jq -r '.enabledPlugins // {} | to_entries[] | select(.value == false) | .key' "$user_settings" 2>/dev/null | tr -d '\r' | while IFS= read -r disabled_name; do
echo " DISABLED: ${disabled_name}"
done
fi
fi
fi
# Fix mode: remove orphaned entries
if [ "$fix_mode" = true ] && [ "$orphaned_count" -gt 0 ]; then
echo "FIX_MODE=true"
# Create backup
backup_file="${registry_file}.backup.$(date -u +%Y%m%dT%H%M%SZ)"
cp "$registry_file" "$backup_file"
echo "BACKUP_CREATED=${backup_file}"
# Build jq filter to remove orphaned entries
jq_filter='.plugins'
while IFS= read -r plugin_key; do
[ -z "$plugin_key" ] && continue
plugin_path=$(jq -r ".plugins[\"${plugin_key}\"][0].projectPath // \"\"" "$registry_file" 2>/dev/null | tr -d '\r')
if [ -n "$plugin_path" ] && [ ! -d "$plugin_path" ]; then
jq_filter="${jq_filter} | del(.\"${plugin_key}\")"
echo "FIX_REMOVED=${plugin_key}"
fi
done <<< "$plugin_keys"
# Apply fix
jq ".plugins = (${jq_filter})" "$registry_file" > "${registry_file}.tmp" && mv "${registry_file}.tmp" "$registry_file"
echo "FIX_APPLIED=true"
echo "FIX_ORPHANS_REMOVED=${orphaned_count}"
fi
echo "STATUS=${check_status}"
echo "ISSUE_COUNT=${issue_count}"
if [ -n "$issues_list" ]; then
echo "ISSUES:"
echo -e "$issues_list" | sed '/^$/d'
fi
echo "FIX_SUPPORTED=true"
echo "=== END PLUGIN REGISTRY ==="
#!/usr/bin/env bash
# Check Runtime State (~/.claude.json)
# Audits the harness runtime state file for stale entries:
# - projects[] keys pointing at deleted directories
# - githubRepoPaths[*] entries referencing deleted worktrees
# - disabledMcpServers[] referencing servers no longer in mcpServers
# - duplicate / non-canonical MCP naming (bare vs plugin:scope:name)
#
# Read-only audit. Does not write to ~/.claude.json. Prints suggested
# follow-up jq invocations the operator can run after closing other
# Claude Code sessions (the harness rewrites this file during sessions).
#
# Usage: bash check-runtime.sh --home-dir <path> --project-dir <path> [--verbose]
set -uo pipefail
home_dir=""
project_dir=""
verbose_mode=false
while [ $# -gt 0 ]; do
case "$1" in
--home-dir) home_dir="$2"; shift 2 ;;
--project-dir) project_dir="$2"; shift 2 ;;
--verbose) verbose_mode=true; shift ;;
*) shift ;;
esac
done
: "${home_dir:=$HOME}"
: "${project_dir:=$(pwd)}"
echo "=== RUNTIME STATE ==="
runtime_file="${home_dir}/.claude.json"
issue_count=0
check_status="OK"
issues_list=""
# Check jq availability (shared convention with sibling scripts)
if ! command -v jq >/dev/null 2>&1; then
echo "JQ_AVAILABLE=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=missing_tool MSG=jq is required but not installed"
echo "=== END RUNTIME STATE ==="
exit 1
fi
# Check runtime file exists
if [ ! -f "$runtime_file" ]; then
echo "RUNTIME_EXISTS=false"
echo "RUNTIME_PATH=${runtime_file}"
echo "STATUS=WARN"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=WARN TYPE=missing_runtime MSG=~/.claude.json not found (no sessions recorded yet)"
echo "=== END RUNTIME STATE ==="
exit 0
fi
echo "RUNTIME_EXISTS=true"
echo "RUNTIME_PATH=${runtime_file}"
# Validate JSON syntax
json_error=$(jq empty "$runtime_file" 2>&1)
jq_rc=$?
if [ $jq_rc -ne 0 ]; then
echo "RUNTIME_VALID=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=invalid_json FILE=${runtime_file} MSG=${json_error}"
echo "=== END RUNTIME STATE ==="
exit 1
fi
echo "RUNTIME_VALID=true"
# File size in bytes (informational; correlates with cruft accumulation)
runtime_size=$(wc -c <"$runtime_file" | tr -d ' ')
echo "RUNTIME_SIZE_BYTES=${runtime_size}"
# 1. Dead projects[] entries -------------------------------------------------
projects_total=0
projects_dead=0
dead_projects=""
if jq -e 'has("projects")' "$runtime_file" >/dev/null 2>&1; then
projects_total=$(jq -r '.projects // {} | length' "$runtime_file" 2>/dev/null || echo "0")
# Each key in .projects is an absolute directory path
while IFS= read -r project_path; do
[ -z "$project_path" ] && continue
if [ ! -d "$project_path" ]; then
projects_dead=$((projects_dead + 1))
dead_projects="${dead_projects}${project_path}\n"
if [ "$verbose_mode" = true ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=dead_project PATH=${project_path}\n"
fi
fi
done < <(jq -r '.projects // {} | keys[]' "$runtime_file" 2>/dev/null)
fi
echo "PROJECTS_TOTAL=${projects_total}"
echo "PROJECTS_DEAD=${projects_dead}"
if [ "$projects_dead" -gt 0 ]; then
issue_count=$((issue_count + projects_dead))
[ "$check_status" = "OK" ] && check_status="WARN"
if [ "$verbose_mode" = false ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=dead_projects COUNT=${projects_dead} MSG=projects[] keys pointing at deleted directories (use --verbose to list)\n"
fi
fi
# 2. Dead githubRepoPaths[*] entries ----------------------------------------
gh_paths_total=0
gh_paths_dead=0
if jq -e 'has("githubRepoPaths")' "$runtime_file" >/dev/null 2>&1; then
# githubRepoPaths shape: { "<repo>": ["/path1", "/path2", ...], ... }
# OR a flat array of paths. Handle both.
gh_kind=$(jq -r '.githubRepoPaths | type' "$runtime_file" 2>/dev/null)
case "$gh_kind" in
object)
gh_paths_total=$(jq -r '[.githubRepoPaths // {} | .[] | .[]?] | length' "$runtime_file" 2>/dev/null || echo "0")
while IFS= read -r gh_path; do
[ -z "$gh_path" ] && continue
if [ ! -d "$gh_path" ]; then
gh_paths_dead=$((gh_paths_dead + 1))
if [ "$verbose_mode" = true ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=dead_gh_path PATH=${gh_path}\n"
fi
fi
done < <(jq -r '[.githubRepoPaths // {} | .[] | .[]?] | .[]' "$runtime_file" 2>/dev/null)
;;
array)
gh_paths_total=$(jq -r '.githubRepoPaths | length' "$runtime_file" 2>/dev/null || echo "0")
while IFS= read -r gh_path; do
[ -z "$gh_path" ] && continue
if [ ! -d "$gh_path" ]; then
gh_paths_dead=$((gh_paths_dead + 1))
if [ "$verbose_mode" = true ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=dead_gh_path PATH=${gh_path}\n"
fi
fi
done < <(jq -r '.githubRepoPaths[]' "$runtime_file" 2>/dev/null)
;;
esac
fi
echo "GH_PATHS_TOTAL=${gh_paths_total}"
echo "GH_PATHS_DEAD=${gh_paths_dead}"
if [ "$gh_paths_dead" -gt 0 ]; then
issue_count=$((issue_count + gh_paths_dead))
[ "$check_status" = "OK" ] && check_status="WARN"
if [ "$verbose_mode" = false ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=dead_gh_paths COUNT=${gh_paths_dead} MSG=githubRepoPaths entries referencing deleted directories (use --verbose to list)\n"
fi
fi
# 3. Orphaned disabledMcpServers --------------------------------------------
# Global mcpServers keys form the "live" set. Per-project disabledMcpServers
# entries that name a server not in the live set are orphans.
orphaned_disabled=0
if jq -e 'has("projects") and has("mcpServers")' "$runtime_file" >/dev/null 2>&1; then
# Build space-separated list of live server names
live_servers=$(jq -r '.mcpServers // {} | keys[]' "$runtime_file" 2>/dev/null | tr '\n' ' ')
# Walk projects[]/disabledMcpServers[]
while IFS=$'\t' read -r project_path disabled_name; do
[ -z "$disabled_name" ] && continue
# Membership test: is disabled_name in live_servers?
case " ${live_servers} " in
*" ${disabled_name} "*) ;;
*)
orphaned_disabled=$((orphaned_disabled + 1))
if [ "$verbose_mode" = true ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=orphan_disabled_mcp PROJECT=${project_path} SERVER=${disabled_name}\n"
fi
;;
esac
done < <(jq -r '
.projects // {}
| to_entries[]
| . as $p
| (.value.disabledMcpServers // [])[]
| [$p.key, .] | @tsv
' "$runtime_file" 2>/dev/null)
fi
echo "ORPHAN_DISABLED_MCP=${orphaned_disabled}"
if [ "$orphaned_disabled" -gt 0 ]; then
issue_count=$((issue_count + orphaned_disabled))
[ "$check_status" = "OK" ] && check_status="WARN"
if [ "$verbose_mode" = false ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=orphan_disabled_mcp COUNT=${orphaned_disabled} MSG=disabledMcpServers refer to servers no longer in global mcpServers (use --verbose to list)\n"
fi
fi
# 4. Duplicate / non-canonical MCP names ------------------------------------
# When both "foo" and "plugin:<scope>:foo" appear across mcpServers /
# disabledMcpServers, the bare form is a migration artifact.
duplicate_mcp=0
if jq -e 'has("mcpServers") or has("projects")' "$runtime_file" >/dev/null 2>&1; then
# Collect every MCP name referenced anywhere, dedupe.
all_names=$(jq -r '
[
(.mcpServers // {} | keys[]),
(.projects // {} | .[] | (.mcpServers // {} | keys[])?),
(.projects // {} | .[] | (.disabledMcpServers // [])[]?)
] | unique | .[]
' "$runtime_file" 2>/dev/null)
# For every "plugin:<scope>:<name>", if the bare "<name>" also appears,
# flag the bare form as a duplicate.
while IFS= read -r mcp_name; do
[ -z "$mcp_name" ] && continue
case "$mcp_name" in
plugin:*:*)
bare="${mcp_name##*:}"
if echo "$all_names" | grep -qxF "$bare"; then
duplicate_mcp=$((duplicate_mcp + 1))
if [ "$verbose_mode" = true ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=duplicate_mcp BARE=${bare} NAMESPACED=${mcp_name}\n"
fi
fi
;;
esac
done <<< "$all_names"
fi
echo "DUPLICATE_MCP=${duplicate_mcp}"
if [ "$duplicate_mcp" -gt 0 ]; then
issue_count=$((issue_count + duplicate_mcp))
[ "$check_status" = "OK" ] && check_status="WARN"
if [ "$verbose_mode" = false ]; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=duplicate_mcp COUNT=${duplicate_mcp} MSG=bare MCP names coexist with plugin:scope:name form (migration artifact; use --verbose to list)\n"
fi
fi
# Suggested cleanup (read-only audit; the operator runs these manually) -----
if [ "$issue_count" -gt 0 ]; then
echo "CLEANUP_SUGGESTED=true"
echo "CLEANUP_NOTE=Close other Claude Code sessions before editing ~/.claude.json (the harness rewrites this file on session end). Suggested jq filters:"
if [ "$projects_dead" -gt 0 ]; then
echo " CLEANUP_PROJECTS=jq 'reduce (.projects | keys[]) as \$k (.; if (\$k | test(\"^/\") and (\$k | @sh | \"test -d \" + . | @sh)) then . else del(.projects[\$k]) end)' ~/.claude.json # preview first"
fi
if [ "$gh_paths_dead" -gt 0 ]; then
echo " CLEANUP_GH_PATHS=Run a shell loop that filters .githubRepoPaths through 'test -d' before writing back to a temp file"
fi
if [ "$orphaned_disabled" -gt 0 ]; then
echo " CLEANUP_DISABLED_MCP=jq '.projects |= map_values(.disabledMcpServers |= map(select(. as \$n | (input_filename | .mcpServers | has(\$n)))))' ~/.claude.json # adapt to your shell"
fi
else
echo "CLEANUP_SUGGESTED=false"
fi
echo "STATUS=${check_status}"
echo "ISSUE_COUNT=${issue_count}"
if [ -n "$issues_list" ]; then
echo "ISSUES:"
echo -e "$issues_list" | sed '/^$/d'
fi
echo "FIX_SUPPORTED=false"
echo "=== END RUNTIME STATE ==="
#!/usr/bin/env bash
# Check Settings Files
# Validates JSON syntax and structure of all Claude Code settings files.
# Usage: bash check-settings.sh --home-dir <path> --project-dir <path> [--verbose]
set -uo pipefail
home_dir=""
project_dir=""
verbose_mode=false
while [ $# -gt 0 ]; do
case "$1" in
--home-dir) home_dir="$2"; shift 2 ;;
--project-dir) project_dir="$2"; shift 2 ;;
--verbose) verbose_mode=true; shift ;;
*) shift ;;
esac
done
: "${home_dir:=$HOME}"
: "${project_dir:=$(pwd)}"
echo "=== SETTINGS FILES ==="
issue_count=0
check_status="OK"
# Check jq availability
if ! command -v jq >/dev/null 2>&1; then
echo "JQ_AVAILABLE=false"
echo "STATUS=ERROR"
echo "ISSUE_COUNT=1"
echo "ISSUES:"
echo " - SEVERITY=ERROR TYPE=missing_tool MSG=jq is required but not installed"
echo "=== END SETTINGS FILES ==="
exit 1
fi
echo "JQ_AVAILABLE=true"
# Define settings files to check
declare -A settings_files=(
["USER_SETTINGS"]="${home_dir}/.claude/settings.json"
["USER_LOCAL_SETTINGS"]="${home_dir}/.claude/settings.local.json"
["PROJECT_SETTINGS"]="${project_dir}/.claude/settings.json"
["PROJECT_LOCAL_SETTINGS"]="${project_dir}/.claude/settings.local.json"
)
issues_list=""
for settings_key in USER_SETTINGS USER_LOCAL_SETTINGS PROJECT_SETTINGS PROJECT_LOCAL_SETTINGS; do
settings_file="${settings_files[$settings_key]}"
if [ ! -f "$settings_file" ]; then
echo "${settings_key}=MISSING"
if [ "$verbose_mode" = true ]; then
echo "${settings_key}_PATH=${settings_file}"
fi
continue
fi
# Validate JSON syntax
json_error=$(jq empty "$settings_file" 2>&1)
if [ $? -ne 0 ]; then
echo "${settings_key}=INVALID"
echo "${settings_key}_ERROR=${json_error}"
issues_list="${issues_list} - SEVERITY=ERROR TYPE=invalid_json FILE=${settings_file} MSG=${json_error}\n"
issue_count=$((issue_count + 1))
check_status="ERROR"
continue
fi
echo "${settings_key}=OK"
if [ "$verbose_mode" = true ]; then
echo "${settings_key}_PATH=${settings_file}"
# Count permission patterns if present
allow_count=$(jq -r '.permissions.allow // [] | length' "$settings_file" 2>/dev/null || echo "0")
deny_count=$(jq -r '.permissions.deny // [] | length' "$settings_file" 2>/dev/null || echo "0")
echo "${settings_key}_ALLOW_PATTERNS=${allow_count}"
echo "${settings_key}_DENY_PATTERNS=${deny_count}"
# Count hooks if present
hook_count=$(jq -r '.hooks // {} | length' "$settings_file" 2>/dev/null || echo "0")
echo "${settings_key}_HOOKS=${hook_count}"
# Count enabled plugins if present
plugin_count=$(jq -r '.enabledPlugins // {} | length' "$settings_file" 2>/dev/null || echo "0")
echo "${settings_key}_ENABLED_PLUGINS=${plugin_count}"
fi
done
# Check for permission conflicts (allow and deny same pattern)
user_settings="${home_dir}/.claude/settings.json"
project_settings="${project_dir}/.claude/settings.json"
for sf in "$user_settings" "$project_settings"; do
if [ -f "$sf" ]; then
allow_patterns=$(jq -r '.permissions.allow // [] | .[]' "$sf" 2>/dev/null)
deny_patterns=$(jq -r '.permissions.deny // [] | .[]' "$sf" 2>/dev/null)
if [ -n "$allow_patterns" ] && [ -n "$deny_patterns" ]; then
while IFS= read -r pattern; do
if echo "$deny_patterns" | grep -qxF "$pattern"; then
issues_list="${issues_list} - SEVERITY=WARN TYPE=permission_conflict FILE=${sf} PATTERN=${pattern}\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
fi
done <<< "$allow_patterns"
fi
fi
done
# Aggregate permission counts across all files
total_allow=0
total_deny=0
for sf in "$user_settings" "${home_dir}/.claude/settings.local.json" "$project_settings" "${project_dir}/.claude/settings.local.json"; do
if [ -f "$sf" ]; then
ac=$(jq -r '.permissions.allow // [] | length' "$sf" 2>/dev/null || echo "0")
dc=$(jq -r '.permissions.deny // [] | length' "$sf" 2>/dev/null || echo "0")
total_allow=$((total_allow + ac))
total_deny=$((total_deny + dc))
fi
done
echo "TOTAL_ALLOW_PATTERNS=${total_allow}"
echo "TOTAL_DENY_PATTERNS=${total_deny}"
echo "STATUS=${check_status}"
echo "ISSUE_COUNT=${issue_count}"
if [ -n "$issues_list" ]; then
echo "ISSUES:"
echo -e "$issues_list" | sed '/^$/d'
fi
echo "=== END SETTINGS FILES ==="