
Health Plugins
- 56 installs
- 49 repo stars
- Updated August 4, 2026
- laurigates/claude-plugins
Helps with ai & agent building tasks.
About
health-plugins is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- health-plugins
- AI & Agent Building
- AI-coding skill
Health Plugins by the numbers
- 56 all-time installs (skills.sh)
- Ranked #6,750 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-pluginsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 56 |
|---|---|
| repo stars | ★ 49 |
| Last updated | August 4, 2026 |
| Repository | laurigates/claude-plugins ↗ |
What it does
Helps with ai & agent building tasks.
Files
/health:plugins
Diagnose and fix issues with the Claude Code plugin registry. This command specifically addresses issue #14202 where project-scoped plugins incorrectly appear as globally installed.
When to Use This Skill
| Use this skill when... | Use another approach when... |
|---|---|
| Fixing plugin registry corruption (issue #14202) | Comprehensive health check (use /health:check) |
| Diagnosing project-scope vs global plugin issues | Auditing plugins for relevance (use /health:audit) |
| Cleaning up orphaned plugin entries | Settings validation only needed |
| Resolving "plugin already installed" errors | Agentic optimization audit (use /health:agentic-audit) |
| Manually inspecting registry JSON | Just viewing installed plugins (read registry file) |
Context
- Current project: !
pwd - Current project has plugins: !
find . -maxdepth 2 -path '*/.claude-plugin/plugin.json' -type f - Project settings exists: !
find . -maxdepth 1 -name '.claude/settings.json' - Project plugins dir: !
find . -maxdepth 1 -type d -name '.claude-plugin'
Background: Issue #14202
When a plugin is installed with --scope project in one project, other projects incorrectly show the plugin as "(installed)" in the Marketplaces view. This happens because:
1. The plugin registry at ~/.claude/plugins/installed_plugins.json stores projectPath for project-scoped installs 2. The Marketplaces view only checks if a plugin key exists, not whether it's installed for the current project 3. The install command refuses to install because it thinks the plugin already exists
Impact: Users cannot install the same plugin across multiple projects with project-scope isolation.
Parameters
Parse these from $ARGUMENTS:
| Parameter | Description |
|---|---|
--fix | Apply fixes to the plugin registry |
--dry-run | Show what would be fixed without making changes |
--plugin <name> | Check/fix a specific plugin only |
Execution
Execute this plugin registry diagnostic by running the scripts below. Pass --plugin <name> through from $ARGUMENTS when specified.
Step 1: Diagnose the registry
bash "${CLAUDE_SKILL_DIR}/scripts/check-registry.sh" --home-dir "$HOME" --project-dir "$(pwd)" [--plugin <name>] [--verbose]Parse the STATUS=, PLUGIN_COUNT=, ORPHANED_ENTRIES=, and ISSUES: lines from output. The === PLUGINS === section lists each installed plugin with scope, version, source, and projectPath.
Step 2: Report findings
Print a structured diagnostic report summarising:
1. Registry location, validity, and plugin counts (total / global / project-scoped) 2. Orphaned entries (projectPath directory missing) with severity and suggested fix 3. Plugins enabled in settings but not in the registry 4. Plugins from other projects (INFO only, shown with --verbose)
Use the ISSUES: lines as the authoritative list of problems.
Step 3: Apply fixes (if --fix)
If $ARGUMENTS contains --fix:
1. Confirm with the user (via AskUserQuestion) which orphaned plugins to remove, unless --dry-run is also set. 2. Run the fix script:
bash "${CLAUDE_SKILL_DIR}/scripts/fix-registry.sh" --home-dir "$HOME" --project-dir "$(pwd)" [--plugin <name>] [--dry-run]The script creates a timestamped backup at ~/.claude/plugins/installed_plugins.json.backup.<UTC-timestamp> before modifying the registry, validates the resulting JSON, and aborts safely on any error. 3. Parse STATUS=, REMOVED=, REMOVED_COUNT=, and BACKUP_PATH= lines to report what changed. 4. Surface durability warnings. If the output includes SETTINGS_CHEZMOI_MANAGED=true, relay the WARNING= line to the user: the edited ~/.claude/settings.json is chezmoi-managed, so the fix reverts on the next chezmoi apply unless the same key is also removed from the chezmoi source (printed as SETTINGS_CHEZMOI_SOURCE=).
Step 4: Handle "plugin needed in current project"
When a plugin exists in the registry under a different projectPath and the user wants it available in the current project, use AskUserQuestion to confirm, then:
1. Add a new entry to .claude/settings.json under enabledPlugins using the Edit tool. 2. Remind the user to run /plugin install via the Claude Code UI for proper registry registration.
Step 5: Verify the fix
After applying fixes, re-run Step 1 and confirm the issue count has dropped. Remind the user to restart Claude Code for changes to take effect.
Registry Structure Reference
{
"version": 2,
"plugins": {
"plugin-name@marketplace-name": [
{
"scope": "project",
"projectPath": "/path/to/project",
"installPath": "~/.claude/plugins/cache/marketplace/plugin-name/1.0.0",
"version": "1.0.0",
"installedAt": "2024-01-15T10:30:00Z",
"lastUpdated": "2024-01-15T10:30:00Z",
"gitCommitSha": "abc123"
}
]
}
}Scope types:
"scope": "project"— hasprojectPath, only active in that project"scope": "user"— noprojectPath, active globally
Manual Workaround
If automatic fix fails, users can manually edit ~/.claude/plugins/installed_plugins.json:
1. Open the file in an editor 2. Find the plugin entry 3. Either:
- Remove
projectPathto make it global - Change
projectPathto current project path - Add a new entry with different key for current project
4. Save and restart Claude Code
Agentic Optimizations
| Context | Command |
|---|---|
| Plugin registry diagnostics | /health:plugins |
| Fix registry issues | /health:plugins --fix |
| Dry-run mode | /health:plugins --dry-run |
| Diagnose only (script) | bash "${CLAUDE_SKILL_DIR}/scripts/check-registry.sh" --home-dir "$HOME" --project-dir "$(pwd)" |
| Fix only (script) | bash "${CLAUDE_SKILL_DIR}/scripts/fix-registry.sh" --home-dir "$HOME" --project-dir "$(pwd)" |
Flags
| Flag | Description |
|---|---|
--fix | Apply fixes (with confirmation prompts) |
--dry-run | Show what would be fixed without changes |
--plugin <name> | Target a specific plugin |
See Also
/health:check- Full diagnostic scan/health:settings- Settings file validation- Issue #14202 - Upstream bug report
#!/usr/bin/env bash
# Diagnose Claude Code plugin registry
# Detects orphaned projectPath entries, scope conflicts, and invalid entries
# in ~/.claude/plugins/installed_plugins.json.
# Usage:
# bash check-registry.sh --home-dir <path> --project-dir <path> [--plugin <name>] [--verbose]
set -uo pipefail
home_dir=""
project_dir=""
target_plugin=""
verbose_mode=false
while [ $# -gt 0 ]; do
case "$1" in
--home-dir) home_dir="$2"; shift 2 ;;
--project-dir) project_dir="$2"; shift 2 ;;
--plugin) target_plugin="$2"; shift 2 ;;
--verbose) verbose_mode=true; shift ;;
*) shift ;;
esac
done
: "${home_dir:=$HOME}"
: "${project_dir:=$(pwd)}"
echo "=== PLUGIN REGISTRY DIAGNOSTIC ==="
echo "HOME_DIR=${home_dir}"
echo "PROJECT_DIR=${project_dir}"
registry_file="${home_dir}/.claude/plugins/installed_plugins.json"
user_settings="${home_dir}/.claude/settings.json"
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 DIAGNOSTIC ==="
exit 1
fi
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 DIAGNOSTIC ==="
exit 0
fi
echo "REGISTRY_EXISTS=true"
echo "REGISTRY_PATH=${registry_file}"
json_error=$(jq empty "$registry_file" 2>&1)
json_rc=$?
if [ $json_rc -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 DIAGNOSTIC ==="
exit 1
fi
echo "REGISTRY_VALID=true"
plugin_count=$(jq '.plugins | length' "$registry_file" 2>/dev/null | tr -d '\r' || echo "0")
echo "PLUGIN_COUNT=${plugin_count}"
issue_count=0
check_status="OK"
issues_list=""
project_scoped=0
global_scoped=0
orphaned_count=0
other_project_count=0
# 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.
if [ -n "$target_plugin" ]; then
plugin_keys=$(jq -r --arg k "$target_plugin" '.plugins | keys[] | select(. == $k or startswith($k + "@"))' "$registry_file" 2>/dev/null | tr -d '\r')
else
plugin_keys=$(jq -r '.plugins | keys[]' "$registry_file" 2>/dev/null | tr -d '\r')
fi
echo "=== PLUGINS ==="
while IFS= read -r plugin_key; do
[ -z "$plugin_key" ] && continue
plugin_path=$(jq -r --arg k "$plugin_key" '.plugins[$k][0].projectPath // ""' "$registry_file" 2>/dev/null | tr -d '\r')
plugin_source=$(jq -r --arg k "$plugin_key" '.plugins[$k][0].source // "unknown"' "$registry_file" 2>/dev/null)
plugin_scope=$(jq -r --arg k "$plugin_key" '.plugins[$k][0].scope // "user"' "$registry_file" 2>/dev/null)
plugin_version=$(jq -r --arg k "$plugin_key" '.plugins[$k][0].version // "unknown"' "$registry_file" 2>/dev/null)
if [ -n "$plugin_path" ]; then
project_scoped=$((project_scoped + 1))
if [ ! -d "$plugin_path" ]; then
orphaned_count=$((orphaned_count + 1))
issues_list="${issues_list} - SEVERITY=WARN TYPE=orphaned PLUGIN=${plugin_key} PATH=${plugin_path} FIX=remove_entry\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
elif [ "$plugin_path" != "$project_dir" ]; then
other_project_count=$((other_project_count + 1))
if [ "$verbose_mode" = true ]; then
issues_list="${issues_list} - SEVERITY=INFO TYPE=different_project PLUGIN=${plugin_key} PATH=${plugin_path}\n"
fi
fi
else
global_scoped=$((global_scoped + 1))
fi
echo "PLUGIN: name=${plugin_key} scope=${plugin_scope} version=${plugin_version} source=${plugin_source} projectPath=${plugin_path:-none}"
done <<< "$plugin_keys"
echo "=== END PLUGINS ==="
echo "PROJECT_SCOPED=${project_scoped}"
echo "GLOBAL_SCOPED=${global_scoped}"
echo "ORPHANED_ENTRIES=${orphaned_count}"
echo "OTHER_PROJECT_ENTRIES=${other_project_count}"
stale_enabled_count=0
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}"
# Collect marketplace plugin names (if any marketplaces are configured).
marketplace_names=""
marketplaces_dir="${home_dir}/.claude/plugins/marketplaces"
if [ -d "$marketplaces_dir" ]; then
while IFS= read -r mp_file; do
[ -z "$mp_file" ] && continue
# tr -d '\r' so the names match cleanly under grep -Fxq on Windows
mp_plugins=$(jq -r '.plugins[]?.name // empty' "$mp_file" 2>/dev/null | tr -d '\r')
marketplace_names="${marketplace_names}
${mp_plugins}"
done < <(find "$marketplaces_dir" -maxdepth 3 -name '*.json' -type f 2>/dev/null)
fi
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
# enabledPlugins keys are of the form "<plugin>@<marketplace>"; the registry
# is keyed the same way but older Claude Code versions left stale entries
# behind when the plugin or marketplace was removed.
plugin_name="${enabled_key%@*}"
# Present in registry?
if jq -e --arg k "$enabled_key" '.plugins[$k]' "$registry_file" >/dev/null 2>&1; then
continue
fi
# Present in any known marketplace?
if [ -n "$marketplace_names" ] && printf '%s\n' "$marketplace_names" | grep -Fxq "$plugin_name"; 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"
continue
fi
# Neither installed nor in any marketplace -- fully stale.
stale_enabled_count=$((stale_enabled_count + 1))
issues_list="${issues_list} - SEVERITY=WARN TYPE=stale_enabled PLUGIN=${enabled_key} FIX=remove_enabled_key\n"
issue_count=$((issue_count + 1))
[ "$check_status" = "OK" ] && check_status="WARN"
done <<< "$enabled_keys"
fi
echo "STALE_ENABLED_ENTRIES=${stale_enabled_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 "FIX_SUPPORTED=true"
echo "=== END PLUGIN REGISTRY DIAGNOSTIC ==="
#!/usr/bin/env bash
# Fix Claude Code plugin registry issues
# Creates a timestamped backup, then removes orphaned projectPath entries.
# Validates JSON before and after modifications.
# Usage:
# bash fix-registry.sh --home-dir <path> --project-dir <path> [--plugin <name>] [--dry-run]
set -uo pipefail
home_dir=""
project_dir=""
target_plugin=""
dry_run=false
while [ $# -gt 0 ]; do
case "$1" in
--home-dir) home_dir="$2"; shift 2 ;;
--project-dir) project_dir="$2"; shift 2 ;;
--plugin) target_plugin="$2"; shift 2 ;;
--dry-run) dry_run=true; shift ;;
*) shift ;;
esac
done
: "${home_dir:=$HOME}"
: "${project_dir:=$(pwd)}"
# Allow tests/tools to point the script at alternative file paths without
# having to construct a full fake $HOME. When these env vars are set they
# override the defaults derived from --home-dir.
registry_file="${FIX_REGISTRY_FILE:-${home_dir}/.claude/plugins/installed_plugins.json}"
settings_file="${FIX_SETTINGS_FILE:-${home_dir}/.claude/settings.json}"
marketplaces_dir="${FIX_MARKETPLACES_DIR:-${home_dir}/.claude/plugins/marketplaces}"
backup_dir="$(dirname "$registry_file")"
# chezmoi binary (overridable for tests via FIX_CHEZMOI_BIN).
chezmoi_bin="${FIX_CHEZMOI_BIN:-chezmoi}"
# Warn when a settings file we edit is managed by chezmoi: editing only the
# target file is not durable, because the next `chezmoi apply` re-applies the
# source and reverts the fix. `chezmoi source-path` exits non-zero for an
# unmanaged path, so it doubles as the management test and the source locator.
# (issue #1481)
warn_if_chezmoi_managed() {
local file="$1" src
command -v "$chezmoi_bin" >/dev/null 2>&1 || return 0
if src="$("$chezmoi_bin" source-path "$file" 2>/dev/null)" && [ -n "$src" ]; then
echo "SETTINGS_CHEZMOI_MANAGED=true"
echo "SETTINGS_CHEZMOI_SOURCE=${src}"
echo "WARNING=${file} is chezmoi-managed — also apply this edit to the chezmoi source (${src}) or it reverts on the next 'chezmoi apply'."
fi
return 0
}
echo "=== PLUGIN REGISTRY FIX ==="
echo "HOME_DIR=${home_dir}"
echo "DRY_RUN=${dry_run}"
echo "REGISTRY_FILE=${registry_file}"
echo "SETTINGS_FILE=${settings_file}"
if ! command -v jq >/dev/null 2>&1; then
echo "STATUS=ERROR"
echo "ERROR=jq is required but not installed"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
if [ ! -f "$registry_file" ]; then
echo "STATUS=ERROR"
echo "ERROR=Registry file not found at ${registry_file}"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
if ! jq empty "$registry_file" >/dev/null 2>&1; then
echo "STATUS=ERROR"
echo "ERROR=Registry file contains invalid JSON"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
# Identify orphaned entries
if [ -n "$target_plugin" ]; then
plugin_keys=$(jq -r --arg k "$target_plugin" '.plugins | keys[] | select(. == $k or startswith($k + "@"))' "$registry_file" 2>/dev/null)
else
plugin_keys=$(jq -r '.plugins | keys[]' "$registry_file" 2>/dev/null)
fi
orphaned_keys=()
while IFS= read -r plugin_key; do
[ -z "$plugin_key" ] && continue
plugin_path=$(jq -r --arg k "$plugin_key" '.plugins[$k][0].projectPath // ""' "$registry_file" 2>/dev/null)
if [ -n "$plugin_path" ] && [ ! -d "$plugin_path" ]; then
orphaned_keys+=("$plugin_key")
echo "ORPHANED: plugin=${plugin_key} path=${plugin_path}"
fi
done <<< "$plugin_keys"
echo "ORPHANED_COUNT=${#orphaned_keys[@]}"
# Identify stale enabledPlugins entries: keys in settings.json whose plugin
# name is not installed AND not in any configured marketplace.
stale_enabled_keys=()
if [ -f "$settings_file" ] && jq empty "$settings_file" >/dev/null 2>&1; then
marketplace_names=""
if [ -d "$marketplaces_dir" ]; then
while IFS= read -r mp_file; do
[ -z "$mp_file" ] && continue
mp_plugins=$(jq -r '.plugins[]?.name // empty' "$mp_file" 2>/dev/null)
marketplace_names="${marketplace_names}
${mp_plugins}"
done < <(find "$marketplaces_dir" -maxdepth 3 -name '*.json' -type f 2>/dev/null)
fi
enabled_keys_raw=$(jq -r '.enabledPlugins // {} | keys[]' "$settings_file" 2>/dev/null)
while IFS= read -r enabled_key; do
[ -z "$enabled_key" ] && continue
plugin_name="${enabled_key%@*}"
# Keep if installed in registry.
if jq -e --arg k "$enabled_key" '.plugins[$k]' "$registry_file" >/dev/null 2>&1; then
continue
fi
# Keep if present in any known marketplace (still installable).
if [ -n "$marketplace_names" ] && printf '%s\n' "$marketplace_names" | grep -Fxq "$plugin_name"; then
continue
fi
stale_enabled_keys+=("$enabled_key")
echo "STALE_ENABLED: key=${enabled_key}"
done <<< "$enabled_keys_raw"
fi
echo "STALE_ENABLED_COUNT=${#stale_enabled_keys[@]}"
if [ "${#orphaned_keys[@]}" -eq 0 ] && [ "${#stale_enabled_keys[@]}" -eq 0 ]; then
echo "STATUS=OK"
echo "MESSAGE=No orphaned entries to fix"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 0
fi
if [ "$dry_run" = true ]; then
echo "STATUS=DRY_RUN"
echo "WOULD_REMOVE=${#orphaned_keys[@]}"
echo "WOULD_REMOVE_ENABLED=${#stale_enabled_keys[@]}"
if [ "${#stale_enabled_keys[@]}" -gt 0 ]; then
echo "MESSAGE=Would remove ${#stale_enabled_keys[@]} stale enabledPlugins entries"
echo "RESTART_REQUIRED=true"
warn_if_chezmoi_managed "$settings_file"
fi
echo "=== END PLUGIN REGISTRY FIX ==="
exit 0
fi
mkdir -p "$backup_dir"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_file=""
settings_backup_file=""
restart_required=false
# Stage 1: Remove orphaned entries from installed_plugins.json
if [ "${#orphaned_keys[@]}" -gt 0 ]; then
backup_file="${registry_file}.backup.${timestamp}"
if ! cp "$registry_file" "$backup_file"; then
echo "STATUS=ERROR"
echo "ERROR=Failed to create registry backup"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
echo "BACKUP_CREATED=${backup_file}"
jq_filter='.'
for plugin_key in "${orphaned_keys[@]}"; do
jq_filter="${jq_filter} | del(.plugins[\"${plugin_key}\"])"
done
tmp_file="${registry_file}.tmp.$$"
if ! jq "$jq_filter" "$registry_file" > "$tmp_file"; then
echo "STATUS=ERROR"
echo "ERROR=jq transformation failed"
rm -f "$tmp_file"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
if ! jq empty "$tmp_file" >/dev/null 2>&1; then
echo "STATUS=ERROR"
echo "ERROR=Resulting registry JSON is invalid; no changes applied"
rm -f "$tmp_file"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
mv "$tmp_file" "$registry_file"
for plugin_key in "${orphaned_keys[@]}"; do
echo "REMOVED=${plugin_key}"
done
restart_required=true
fi
# Stage 2: Remove stale enabledPlugins entries from settings.json
if [ "${#stale_enabled_keys[@]}" -gt 0 ] && [ -f "$settings_file" ]; then
settings_backup_file="${settings_file}.backup.${timestamp}"
if ! cp "$settings_file" "$settings_backup_file"; then
echo "STATUS=ERROR"
echo "ERROR=Failed to create settings backup"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
echo "SETTINGS_BACKUP_CREATED=${settings_backup_file}"
settings_filter='.'
for enabled_key in "${stale_enabled_keys[@]}"; do
settings_filter="${settings_filter} | del(.enabledPlugins[\"${enabled_key}\"])"
done
settings_tmp="${settings_file}.tmp.$$"
if ! jq "$settings_filter" "$settings_file" > "$settings_tmp"; then
echo "STATUS=ERROR"
echo "ERROR=jq transformation of settings.json failed"
rm -f "$settings_tmp"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
if ! jq empty "$settings_tmp" >/dev/null 2>&1; then
echo "STATUS=ERROR"
echo "ERROR=Resulting settings JSON is invalid; no changes applied"
rm -f "$settings_tmp"
echo "=== END PLUGIN REGISTRY FIX ==="
exit 1
fi
mv "$settings_tmp" "$settings_file"
for enabled_key in "${stale_enabled_keys[@]}"; do
echo "REMOVED_ENABLED=${enabled_key}"
done
echo "MESSAGE=Removed ${#stale_enabled_keys[@]} stale enabledPlugins entries"
warn_if_chezmoi_managed "$settings_file"
restart_required=true
fi
echo "STATUS=FIXED"
echo "REMOVED_COUNT=${#orphaned_keys[@]}"
echo "REMOVED_ENABLED_COUNT=${#stale_enabled_keys[@]}"
[ -n "$backup_file" ] && echo "BACKUP_PATH=${backup_file}"
[ -n "$settings_backup_file" ] && echo "SETTINGS_BACKUP_PATH=${settings_backup_file}"
if [ "$restart_required" = true ]; then
echo "RESTART_REQUIRED=true"
fi
echo "=== END PLUGIN REGISTRY FIX ==="
#!/usr/bin/env bash
# Regression test for fix-registry.sh stale enabledPlugins handling.
# Also guards health-check SKILL.md against the `!\`` context-command antipattern.
# Exit 0 on success, non-zero on failure.
set -uo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fix_script="${script_dir}/fix-registry.sh"
health_check_skill="${script_dir}/../../health-check/SKILL.md"
fail() {
echo "FAIL: $1" >&2
exit 1
}
pass() {
echo "PASS: $1"
}
# -----------------------------------------------------------------------------
# Guard 1: health-check SKILL.md must not use the `!\`` context-command antipattern
# -----------------------------------------------------------------------------
if grep -n '!`' "$health_check_skill" | grep -E '!`(echo|printf|eval)' >/dev/null; then
fail "health-check/SKILL.md contains !\`echo|printf|eval backtick antipattern"
fi
pass "health-check/SKILL.md free of forbidden !\`echo backtick"
# -----------------------------------------------------------------------------
# Guard 2: fix-registry.sh cleans stale enabledPlugins in --dry-run
# -----------------------------------------------------------------------------
if ! command -v jq >/dev/null 2>&1; then
echo "SKIP: jq not installed; cannot run fix-registry dry-run test"
exit 0
fi
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
registry_file="${tmp_dir}/installed_plugins.json"
settings_file="${tmp_dir}/settings.json"
marketplaces_dir="${tmp_dir}/marketplaces"
mkdir -p "$marketplaces_dir/test-mp"
# Registry contains only 'good-plugin'.
cat > "$registry_file" <<'JSON'
{
"version": 2,
"plugins": {
"good-plugin@test-mp": [
{"scope": "user", "version": "1.0.0", "installPath": "/tmp/good"}
]
}
}
JSON
# Marketplace lists 'good-plugin' and 'installable-plugin' (enabled but not installed).
cat > "${marketplaces_dir}/test-mp/marketplace.json" <<'JSON'
{
"name": "test-mp",
"plugins": [
{"name": "good-plugin"},
{"name": "installable-plugin"}
]
}
JSON
# settings.json has: good (valid), installable (enabled_not_installed), stale (fully gone).
cat > "$settings_file" <<'JSON'
{
"enabledPlugins": {
"good-plugin@test-mp": true,
"installable-plugin@test-mp": true,
"stale-plugin@dead-mp": true
}
}
JSON
output=$(
FIX_REGISTRY_FILE="$registry_file" \
FIX_SETTINGS_FILE="$settings_file" \
FIX_MARKETPLACES_DIR="$marketplaces_dir" \
bash "$fix_script" --home-dir "$tmp_dir" --project-dir "$tmp_dir" --dry-run
)
echo "--- fix-registry.sh --dry-run output ---"
echo "$output"
echo "----------------------------------------"
# Stale key must be reported.
if ! grep -q 'STALE_ENABLED: key=stale-plugin@dead-mp' <<<"$output"; then
fail "stale-plugin@dead-mp not reported as stale"
fi
pass "stale-plugin@dead-mp reported"
# Good keys must NOT be reported as stale.
if grep -q 'STALE_ENABLED: key=good-plugin@test-mp' <<<"$output"; then
fail "good-plugin@test-mp incorrectly flagged as stale"
fi
if grep -q 'STALE_ENABLED: key=installable-plugin@test-mp' <<<"$output"; then
fail "installable-plugin@test-mp incorrectly flagged as stale (should be kept)"
fi
pass "valid keys preserved"
# STALE_ENABLED_COUNT must be exactly 1.
if ! grep -q '^STALE_ENABLED_COUNT=1$' <<<"$output"; then
fail "expected STALE_ENABLED_COUNT=1"
fi
pass "STALE_ENABLED_COUNT=1"
# Dry-run must not modify settings.json.
if ! diff -q <(jq -S . "$settings_file") <(cat <<'JSON' | jq -S .
{
"enabledPlugins": {
"good-plugin@test-mp": true,
"installable-plugin@test-mp": true,
"stale-plugin@dead-mp": true
}
}
JSON
) >/dev/null; then
fail "--dry-run modified settings.json"
fi
pass "--dry-run preserved settings.json"
# RESTART_REQUIRED should be emitted on dry-run when stale keys would be removed.
if ! grep -q '^RESTART_REQUIRED=true$' <<<"$output"; then
fail "expected RESTART_REQUIRED=true in dry-run output"
fi
pass "RESTART_REQUIRED=true emitted in dry-run"
# -----------------------------------------------------------------------------
# Guard 3: Windows CRLF regression for check-plugins.sh and check-registry.sh
# (issue #1330) — simulate jq emitting CRLF line endings and verify the scripts
# do NOT flag every-key-but-the-last as enabled_not_installed / stale.
# -----------------------------------------------------------------------------
crlf_dir="${tmp_dir}/crlf"
mkdir -p "$crlf_dir/.claude/plugins"
cat > "${crlf_dir}/.claude/plugins/installed_plugins.json" <<'JSON'
{
"version": 2,
"plugins": {
"alpha-plugin@test-mp": [{"scope": "user", "version": "1.0.0"}],
"beta-plugin@test-mp": [{"scope": "user", "version": "1.0.0"}],
"gamma-plugin@test-mp": [{"scope": "user", "version": "1.0.0"}]
}
}
JSON
cat > "${crlf_dir}/.claude/settings.json" <<'JSON'
{
"enabledPlugins": {
"alpha-plugin@test-mp": true,
"beta-plugin@test-mp": true,
"gamma-plugin@test-mp": true
}
}
JSON
# Wrap jq with a stub that appends \r to every output line, emulating Windows.
jq_stub_dir="${tmp_dir}/jq-crlf-stub"
mkdir -p "$jq_stub_dir"
real_jq="$(command -v jq)"
cat > "${jq_stub_dir}/jq" <<EOF
#!/usr/bin/env bash
# Test stub: forwards to real jq but rewrites LF -> CRLF on stdout
# to reproduce Windows behaviour.
"${real_jq}" "\$@" | sed 's/\$/\r/'
EOF
chmod +x "${jq_stub_dir}/jq"
check_plugins="${script_dir}/../../health-check/scripts/check-plugins.sh"
crlf_output=$(PATH="${jq_stub_dir}:$PATH" bash "$check_plugins" \
--home-dir "$crlf_dir" --project-dir "$crlf_dir")
# With the fix, none of the three enabled plugins should be flagged.
if grep -q 'TYPE=enabled_not_installed' <<<"$crlf_output"; then
echo "--- check-plugins.sh CRLF output ---" >&2
echo "$crlf_output" >&2
echo "------------------------------------" >&2
fail "check-plugins.sh produced enabled_not_installed false positives under CRLF jq output (issue #1330 regression)"
fi
if ! grep -q '^STATUS=OK$' <<<"$crlf_output"; then
echo "--- check-plugins.sh CRLF output ---" >&2
echo "$crlf_output" >&2
echo "------------------------------------" >&2
fail "check-plugins.sh did not report STATUS=OK under CRLF jq output (issue #1330 regression)"
fi
pass "check-plugins.sh survives CRLF jq output"
check_registry="${script_dir}/check-registry.sh"
crlf_reg_output=$(PATH="${jq_stub_dir}:$PATH" bash "$check_registry" \
--home-dir "$crlf_dir" --project-dir "$crlf_dir")
if grep -qE 'TYPE=(enabled_not_installed|stale_enabled)' <<<"$crlf_reg_output"; then
echo "--- check-registry.sh CRLF output ---" >&2
echo "$crlf_reg_output" >&2
echo "-------------------------------------" >&2
fail "check-registry.sh produced stale/enabled-not-installed false positives under CRLF jq output (issue #1330 regression)"
fi
if ! grep -q '^STATUS=OK$' <<<"$crlf_reg_output"; then
echo "--- check-registry.sh CRLF output ---" >&2
echo "$crlf_reg_output" >&2
echo "-------------------------------------" >&2
fail "check-registry.sh did not report STATUS=OK under CRLF jq output (issue #1330 regression)"
fi
pass "check-registry.sh survives CRLF jq output"
# -----------------------------------------------------------------------------
# Guard 4: chezmoi durability warning when settings.json is chezmoi-managed
# (issue #1481) — editing only the target file is not durable; the next
# `chezmoi apply` reverts it. The fix must warn and point at the source path.
# -----------------------------------------------------------------------------
chez_dir="${tmp_dir}/chezmoi-case"
mkdir -p "$chez_dir/source"
chez_registry="${chez_dir}/installed_plugins.json"
chez_settings="${chez_dir}/settings.json"
chez_marketplaces="${chez_dir}/marketplaces"
mkdir -p "$chez_marketplaces"
chez_source_path="${chez_dir}/source/dot_claude/settings.json"
cat > "$chez_registry" <<'JSON'
{ "version": 2, "plugins": {} }
JSON
cat > "$chez_settings" <<'JSON'
{ "enabledPlugins": { "stale-plugin@dead-mp": true } }
JSON
# Fake chezmoi: `source-path <file>` echoes a source path and exits 0 only for
# our managed settings file; exits non-zero for anything else (unmanaged).
fake_chezmoi="${chez_dir}/chezmoi"
cat > "$fake_chezmoi" <<EOF
#!/usr/bin/env bash
if [ "\$1" = "source-path" ] && [ "\$2" = "${chez_settings}" ]; then
echo "${chez_source_path}"
exit 0
fi
exit 1
EOF
chmod +x "$fake_chezmoi"
chez_output=$(
FIX_REGISTRY_FILE="$chez_registry" \
FIX_SETTINGS_FILE="$chez_settings" \
FIX_MARKETPLACES_DIR="$chez_marketplaces" \
FIX_CHEZMOI_BIN="$fake_chezmoi" \
bash "$fix_script" --home-dir "$chez_dir" --project-dir "$chez_dir" --dry-run
)
if ! grep -q '^SETTINGS_CHEZMOI_MANAGED=true$' <<<"$chez_output"; then
echo "$chez_output" >&2
fail "chezmoi-managed settings.json did not emit SETTINGS_CHEZMOI_MANAGED=true"
fi
if ! grep -q "^SETTINGS_CHEZMOI_SOURCE=${chez_source_path}$" <<<"$chez_output"; then
echo "$chez_output" >&2
fail "chezmoi durability warning did not surface the source path"
fi
if ! grep -q '^WARNING=.*chezmoi-managed.*chezmoi apply' <<<"$chez_output"; then
echo "$chez_output" >&2
fail "chezmoi durability WARNING line missing or malformed"
fi
pass "chezmoi-managed settings.json emits durability warning with source path"
# Counter-case: when settings.json is NOT chezmoi-managed, no warning leaks.
unmanaged_chezmoi="${chez_dir}/chezmoi-unmanaged"
cat > "$unmanaged_chezmoi" <<'EOF'
#!/usr/bin/env bash
exit 1
EOF
chmod +x "$unmanaged_chezmoi"
unmanaged_output=$(
FIX_REGISTRY_FILE="$chez_registry" \
FIX_SETTINGS_FILE="$chez_settings" \
FIX_MARKETPLACES_DIR="$chez_marketplaces" \
FIX_CHEZMOI_BIN="$unmanaged_chezmoi" \
bash "$fix_script" --home-dir "$chez_dir" --project-dir "$chez_dir" --dry-run
)
if grep -q 'SETTINGS_CHEZMOI_MANAGED' <<<"$unmanaged_output"; then
echo "$unmanaged_output" >&2
fail "unmanaged settings.json incorrectly emitted a chezmoi warning"
fi
pass "unmanaged settings.json emits no chezmoi warning"
echo "ALL CHECKS PASSED"
exit 0