diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fe2f48add..70b4ad551 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,8 +6,12 @@ "email": "info@melodicsoftware.com" }, "description": "Reusable, repo-agnostic Claude Code plugins — skills, hooks, and agents.", - "metadata": { - "pluginRoot": "./plugins" - }, - "plugins": [] + "plugins": [ + { + "name": "markdown-formatter", + "source": "./plugins/markdown-formatter", + "category": "formatting", + "tags": ["markdown", "markdownlint", "formatter", "linter", "hook"] + } + ] } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..cb371579b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +# Global defaults. LF everywhere (matches .gitattributes `* text=auto eol=lf`). +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# Markdown — trailing whitespace is significant (hard line breaks); indent varies +# per CommonMark. Includes .mdc (Cursor MDC: markdown + YAML frontmatter). +[*.{md,mdc}] +trim_trailing_whitespace = false +indent_size = unset + +# Shell — shfmt reads these keys (it does not read .shellcheckrc). +[*.{sh,bash}] +indent_size = 2 +binary_next_line = true +switch_case_indent = true +shell_variant = bash diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 000000000..f4349b44b --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,20 @@ +# ShellCheck configuration. ShellCheck auto-discovers this by walking up from +# the script's directory. Optional checks are selectively enabled — NOT +# enable=all (the wiki warns optional checks are subjective and may conflict). +# Ref: https://github.com/koalaman/shellcheck/wiki/Optional + +shell=bash + +# Allow sourcing external files; resolve them relative to the script's directory. +external-sources=true +source-path=SCRIPTDIR + +# --- Optional checks (selectively enabled) --- +enable=add-default-case +enable=avoid-negated-conditions +enable=avoid-nullary-conditions +enable=check-set-e-suppressed +enable=check-unassigned-uppercase +enable=deprecate-which +enable=require-double-brackets +enable=useless-use-of-cat diff --git a/README.md b/README.md index 06aebb119..b3ef2b55e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,14 @@ consumers without editing the plugin itself. Browse and manage with `/plugin`. To refresh after updates: `/plugin marketplace update melodic-software`. +## Catalog + +| Plugin | Type | What it does | +|---|---|---| +| [`markdown-formatter`](plugins/markdown-formatter) | Hook | Auto-formats and lints Markdown on edit via markdownlint-cli2, using the consuming repo's own markdownlint config. | + +Install one: `/plugin install markdown-formatter@melodic-software`. + ## What's here - `.claude-plugin/marketplace.json` — the marketplace catalog. diff --git a/docs/MIGRATION-PLAYBOOK.md b/docs/MIGRATION-PLAYBOOK.md index 17cd6f317..5e26acdbc 100644 --- a/docs/MIGRATION-PLAYBOOK.md +++ b/docs/MIGRATION-PLAYBOOK.md @@ -75,7 +75,11 @@ For each skill/hook/agent being migrated: 8. **Validate.** `claude plugin validate`; test with `--plugin-dir` in a clean repo that is NOT the source repo (proves repo-agnosticism). 9. **Version.** Set an explicit semver `version` in `plugin.json`. -10. **Publish.** Add the entry to `.claude-plugin/marketplace.json` and document it in the README. +10. **Publish.** Add the entry to `.claude-plugin/marketplace.json` — the plugin `source` is the + `./`-prefixed relative path (e.g. `./plugins/`). Bare names fail `claude plugin validate --strict` + even with `metadata.pluginRoot` set, despite the marketplaces-doc example to the contrary (verified + 2026-06-23). Then run `claude plugin validate --strict ` to validate the **catalog manifest + itself** — a bad entry surfaces only there, not in per-plugin validation. Document the plugin in the README. ## What to wait on / avoid for now diff --git a/plugins/markdown-formatter/.claude-plugin/plugin.json b/plugins/markdown-formatter/.claude-plugin/plugin.json new file mode 100644 index 000000000..f6c75a407 --- /dev/null +++ b/plugins/markdown-formatter/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "markdown-formatter", + "version": "0.1.0", + "description": "Auto-format and lint Markdown on edit via markdownlint-cli2, using the consuming repo's own markdownlint config.", + "author": { + "name": "Melodic Software", + "email": "info@melodicsoftware.com" + }, + "keywords": ["markdown", "markdownlint", "formatter", "linter", "hook"] +} diff --git a/plugins/markdown-formatter/README.md b/plugins/markdown-formatter/README.md new file mode 100644 index 000000000..06c90bc33 --- /dev/null +++ b/plugins/markdown-formatter/README.md @@ -0,0 +1,53 @@ +# markdown-formatter + +A Claude Code plugin that auto-formats and lints Markdown the moment you edit it. +On every `Write` or `Edit` of a `.md` or `.mdc` file it runs +[`markdownlint-cli2 --fix`](https://github.com/DavidAnson/markdownlint-cli2) from +the file's repository root, applying every auto-fixable rule and surfacing the +residual (unfixable) findings back to Claude as advisory context. + +It uses **your repository's own markdownlint configuration** — it ships none and +imposes no rules of its own. + +## Behavior + +- **Auto-fix on edit.** Fixable violations (final newline, list-marker style, + trailing spaces, …) are corrected in place. +- **Advisory, never blocking.** The hook always exits `0`. Unfixable findings are + reported via `additionalContext`; they never reject the edit. Make a commit + hook or CI your hard gate. +- **Config from the consumer.** `markdownlint-cli2` discovers config + (`.markdownlint-cli2.jsonc`, `.markdownlint.json`, …) by walking up from the + repository root. The hook `cd`s to that root before linting so the right + cascade applies regardless of the session's working directory. + +## Requirements + +`markdownlint-cli2` must be resolvable — installed globally, or reachable via +`npx` (the hook falls back to `npx markdownlint-cli2`). When neither is present +the hook is a silent no-op. + +## Install + +```shell +/plugin marketplace add melodic-software/claude-code-plugins +/plugin install markdown-formatter@melodic-software +``` + +## Configuration + +This plugin has no `userConfig` — its only "configuration" is the markdownlint +config already in your repository, which it reads automatically. To change the +rules, edit your repo's markdownlint config. + +### Disable without uninstalling + +Set the kill switch in your settings `env` block: + +```json +{ "env": { "HOOK_MARKDOWN_FORMAT_ENABLED": "false" } } +``` + +## License + +[MIT](../../LICENSE). diff --git a/plugins/markdown-formatter/hooks/hook-utils.sh b/plugins/markdown-formatter/hooks/hook-utils.sh new file mode 100644 index 000000000..9d2d7fa08 --- /dev/null +++ b/plugins/markdown-formatter/hooks/hook-utils.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Minimal hook utility library for the markdown-formatter plugin. +# Sourced (not executed). Trimmed subset of a larger shared library — only the +# functions this plugin's hook needs: kill switch, file_path parsing + path +# normalization, repo-root resolution, and the additionalContext accumulator. + +# Guard against double-sourcing. +[[ -n "${_MDFMT_HOOK_UTILS_LOADED:-}" ]] && return 0 +readonly _MDFMT_HOOK_UTILS_LOADED=1 + +# Per-hook kill switch via HOOK__ENABLED env var. Exits 0 (allow) if +# disabled. Place after source, before stdin parsing. +# hook::check_enabled "MARKDOWN_FORMAT" # checks HOOK_MARKDOWN_FORMAT_ENABLED +hook::check_enabled() { + local var_name="HOOK_${1}_ENABLED" + if [[ "${!var_name:-true}" != "true" ]]; then + exit 0 + fi +} + +# Normalize a path: backslashes → forward slashes, then POSIX/lowercase drive +# letter → uppercase Windows drive letter (idempotent). On macOS/Linux the +# regex does not match (multi-char first segments) — no-op. +hook::normalize_path() { + local p="${1//\\//}" + if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then + printf '%s' "${BASH_REMATCH[1]^}:${p:2}" + else + printf '%s' "$p" + fi +} + +# Parse file_path from PostToolUse JSON on stdin; validate existence and (when +# CLAUDE_PROJECT_DIR is set) project membership. Outputs the path on success. +# Returns 1 to skip. +# FILE=$(hook::read_file_path) || exit 0 +hook::read_file_path() { + local file + file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + [[ -n "$file" ]] || return 1 + [[ -f "$file" ]] || return 1 + if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + local norm_file norm_project + norm_file=$(hook::normalize_path "$file") + norm_project=$(hook::normalize_path "${CLAUDE_PROJECT_DIR}") + if [[ "$norm_file" != "$norm_project"* ]]; then + return 1 + fi + fi + printf '%s' "$file" +} + +# Resolve the repository root (working-tree top) for a path inside the tree. +# markdownlint config auto-discovery is CWD-anchored, so the hook cd's here +# before linting. File-anchored (`git -C "$hint" rev-parse --show-toplevel`) +# so it is correct for clones, linked worktrees, and bare-hub clones; falls +# back to the hint (with a trailing /.claude stripped) when git cannot resolve. +# ROOT=$(hook::repo_root "$some_path") +hook::repo_root() { + local hint="${1:-.}" + local root + root=$(git -C "$hint" rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + if [[ -z "$root" ]]; then + root="$hint" + root="${root%/.claude}" + root="${root%\\.claude}" + fi + printf '%s' "$root" +} + +# Per-hook stdout context accumulator. ctx_reset at entry, ctx_append per line, +# ctx_flush once at exit with the hook event name. +_HOOK_CTX_BUFFER="" + +hook::ctx_reset() { + _HOOK_CTX_BUFFER="" +} + +hook::ctx_append() { + _HOOK_CTX_BUFFER+="$1"$'\n' +} + +# Emit the accumulated context as hookSpecificOutput JSON, then clear the buffer. +hook::ctx_flush() { + local event_name="$1" + local trimmed="${_HOOK_CTX_BUFFER%"${_HOOK_CTX_BUFFER##*[![:space:]]}"}" + trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" + hook::emit_additional_context "$event_name" "$trimmed" + hook::ctx_reset +} + +# Print cross-host hook JSON to stdout (exit 0). No-op when context is empty. +# Shape: { hookSpecificOutput: { hookEventName[, additionalContext] } }. +hook::emit_additional_context() { + local event_name="$1" + local context="$2" + [[ -n "$context" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -n \ + --arg event "$event_name" \ + --arg ctx "$context" \ + '{hookSpecificOutput: ( + {hookEventName: $event} + + (if $ctx != "" then {additionalContext: $ctx} else {} end) + )}' +} diff --git a/plugins/markdown-formatter/hooks/hooks.json b/plugins/markdown-formatter/hooks/hooks.json new file mode 100644 index 000000000..740a89ced --- /dev/null +++ b/plugins/markdown-formatter/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/markdown-format.sh", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/plugins/markdown-formatter/hooks/markdown-format.sh b/plugins/markdown-formatter/hooks/markdown-format.sh new file mode 100755 index 000000000..088a83786 --- /dev/null +++ b/plugins/markdown-formatter/hooks/markdown-format.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# PostToolUse hook: auto-format and lint Markdown via markdownlint-cli2. +# Triggered on Write|Edit of *.md and *.mdc (Cursor MDC = markdown + frontmatter). +# +# ADVISORY: always exits 0. markdownlint-cli2 --fix auto-format always applies; +# unfixable markdownlint violations surface via additionalContext but never +# block the edit. Uses the consuming repo's own markdownlint config — ships none. + +set -uo pipefail + +# Read inherited fd0 directly (bare jq) — NEVER `/dev/null 2>&1; then + MDLINT=(markdownlint-cli2) +elif command -v npx >/dev/null 2>&1; then + MDLINT=(npx markdownlint-cli2) +else + exit 0 +fi + +# Run markdownlint-cli2 from the linted file's repo root. Its config +# auto-discovery is CWD-anchored, and the hook's CWD is not guaranteed to be +# the file's repo root, so a drifted CWD would silently lose the repo's +# markdownlint config cascade and fall back to tool defaults. Running from the +# repo root applies the consumer's own rules. repo_root is file-anchored. +REPO_ROOT="$(hook::repo_root "$(dirname "$FILE")")" + +if FIX_OUTPUT=$(cd "$REPO_ROOT" && "${MDLINT[@]}" --fix "$FILE" 2>&1); then + exit 0 +fi + +hook::ctx_reset +hook::ctx_append "markdown-format: $(basename "$FILE") has markdownlint findings:" +while IFS= read -r line; do + hook::ctx_append " $line" +done <<<"$FIX_OUTPUT" +hook::ctx_flush PostToolUse +exit 0 diff --git a/plugins/markdown-formatter/hooks/markdown-format.test.sh b/plugins/markdown-formatter/hooks/markdown-format.test.sh new file mode 100755 index 000000000..7ebd387a6 --- /dev/null +++ b/plugins/markdown-formatter/hooks/markdown-format.test.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Black-box contract test for markdown-format.sh (the markdown-formatter plugin hook). +# +# Proves WIRING, not baseline parity: that the hook fires on *.md/*.mdc, skips +# otherwise, runs markdownlint-cli2 --fix from the linted file's repo root +# (config discovery is CWD-anchored), preserves --fix bytes, and surfaces +# residual findings via additionalContext with no medley-policy prose. +# +# Self-contained: builds a throwaway git repo with its own markdownlint config +# and runtime-generated fixtures (CRLF preserved via printf, never committed — +# a committed CRLF fixture would be LF-normalized by .gitattributes). The hook +# is invoked as a subprocess from an UNRELATED cwd so the `cd repo-root` config +# discovery is genuinely exercised (running from the repo root would false-pass +# a hook that skips the cd). + +set -uo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$HOOK_DIR/markdown-format.sh" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +WORK="$(mktemp -d)" +UNRELATED="$(mktemp -d)" +cleanup() { rm -rf "$WORK" "$UNRELATED"; } +trap cleanup EXIT + +# --- Build the throwaway consumer repo -------------------------------------- +REPO="$WORK/consumer" +mkdir -p "$REPO" +git -C "$REPO" init -q +git -C "$REPO" config user.email t@t.t +git -C "$REPO" config user.name t + +# Consumer's own markdownlint config — the cascade the hook must discover by +# cd-ing to the repo root. MD004 dash makes `*` markers a fixable violation; +# MD024 siblings_only makes a duplicate sibling heading an unfixable residual. +cat >"$REPO/.markdownlint-cli2.jsonc" <<'JSONC' +{ + "config": { + "MD004": { "style": "dash" }, + "MD024": { "siblings_only": true }, + "MD013": false + } +} +JSONC + +# Invoke the hook as a subprocess from an unrelated cwd. CLAUDE_PROJECT_DIR is +# left UNSET so read_file_path's project-membership guard is disabled (it is not +# part of the fire-gate contract); this isolates formatting behavior from any +# POSIX-vs-Windows path-form mismatch in the guard. +run_hook() { + local file_path="$1" + (cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$file_path" \ + | env -u CLAUDE_PROJECT_DIR HOOK_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK") +} + +# --- Fixture A: fixable only (Path A) — clean after fix, no additionalContext - +# CRLF, no final newline. Only issue is the missing final newline (MD047); +# after --fix the file is clean → empty stdout. +FA="$REPO/fixtureA.md" +printf '# Title A\r\n\r\nSome text\r\n\r\n- item one\r\n- item two' >"$FA" + +OUT_A="$(run_hook "$FA")" +RC_A=$? + +if [[ $RC_A -eq 0 ]]; then ok "fixtureA exit 0"; else fail "fixtureA exit $RC_A"; fi +if [[ -z "$OUT_A" ]]; then ok "fixtureA empty stdout (clean after fix)"; else fail "fixtureA stdout not empty: $OUT_A"; fi +# --fix added a final newline; CRLF preserved. +EXPECT_A="$(printf '# Title A\r\n\r\nSome text\r\n\r\n- item one\r\n- item two\r\n')" +TAIL_A="$(tail -c 2 "$FA" | od -An -tx1 | tr -d ' \n')" +if [[ "$(cat "$FA")" == "$EXPECT_A" && "$TAIL_A" == "0d0a" ]]; then + ok "fixtureA --fix added final newline, CRLF preserved" +else + fail "fixtureA bytes wrong: $(od -c "$FA" | tail -3)" +fi + +# --- Fixture B: fixable + unfixable (Path B) — fix applied + residual finding - +# `* star item` (MD004 dash, fixable) + duplicate sibling `## Section` (MD024, +# unfixable). --fix converts `*`→`-`; MD024 residual surfaces via context. +FB="$REPO/fixtureB.md" +printf '# Doc B\n\n## Section\n\ntext\n\n## Section\n\n* star item\n' >"$FB" + +OUT_B="$(run_hook "$FB")" +RC_B=$? + +if [[ $RC_B -eq 0 ]]; then ok "fixtureB exit 0"; else fail "fixtureB exit $RC_B"; fi +# Fixable MD004 applied: marker is now a dash. +if grep -q '^- star item$' "$FB" && ! grep -q '^\* star item$' "$FB"; then + ok "fixtureB --fix applied MD004 dash" +else + fail "fixtureB MD004 not fixed: $(cat "$FB")" +fi +# additionalContext carries the MD024 finding line (substring identity). +CTX_B="" +if [[ -n "$OUT_B" ]] && printf '%s' "$OUT_B" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null 2>&1; then + CTX_B="$(printf '%s' "$OUT_B" | jq -r '.hookSpecificOutput.additionalContext')" + ok "fixtureB emitted hookSpecificOutput.additionalContext" +else + fail "fixtureB no additionalContext JSON: $OUT_B" +fi +if printf '%s' "$CTX_B" | grep -q 'fixtureB.md:7'; then + ok "fixtureB ctx has finding line :7" +else + fail "fixtureB ctx missing :7: $CTX_B" +fi +if printf '%s' "$CTX_B" | grep -q 'MD024'; then + ok "fixtureB ctx names MD024" +else + fail "fixtureB ctx missing MD024: $CTX_B" +fi +# Genericized wrapper: medley-policy tail must be gone. +if printf '%s' "$CTX_B" | grep -qi 'commit/CI will block'; then + fail "fixtureB ctx still has medley policy tail" +else + ok "fixtureB ctx dropped medley policy tail" +fi + +# --- Fire gate: non-.md extension skips ------------------------------------- +SKIP="$REPO/skip.other" +printf 'whatever\n' >"$SKIP" +OUT_S="$(run_hook "$SKIP")" +RC_S=$? +if [[ $RC_S -eq 0 && -z "$OUT_S" ]]; then + ok "non-md extension skipped" +else + fail "non-md not skipped (rc=$RC_S out=$OUT_S)" +fi + +# --- Fire gate: non-existent .md skips -------------------------------------- +OUT_M="$(run_hook "$REPO/does-not-exist.md")" +RC_M=$? +if [[ $RC_M -eq 0 && -z "$OUT_M" ]]; then + ok "missing .md skipped" +else + fail "missing .md not skipped (rc=$RC_M out=$OUT_M)" +fi + +# --- Kill switch: disabled hook is a no-op ---------------------------------- +OUT_K="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$FB" \ + | env -u CLAUDE_PROJECT_DIR HOOK_MARKDOWN_FORMAT_ENABLED=false bash "$HOOK")" +RC_K=$? +if [[ $RC_K -eq 0 && -z "$OUT_K" ]]; then + ok "kill switch disables hook" +else + fail "kill switch failed (rc=$RC_K out=$OUT_K)" +fi + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ $FAIL -eq 0 ]]