-
Notifications
You must be signed in to change notification settings - Fork 1
feat: markdown-formatter plugin (pilot migration) #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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_<NAME>_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) | ||
| )}' | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "hooks": { | ||
| "PostToolUse": [ | ||
| { | ||
| "matcher": "Write|Edit", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/markdown-format.sh", | ||
| "timeout": 15 | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/stdin`: on Windows Git | ||
| # Bash, CC spawns hooks with stdin = a Win32 pipe that `/dev/stdin` cannot | ||
| # resolve (ENOENT → silent no-op). hook::read_file_path reads bare fd0. | ||
| # shellcheck source=hook-utils.sh | ||
| source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" | ||
|
|
||
| hook::check_enabled "MARKDOWN_FORMAT" | ||
|
|
||
| FILE=$(hook::read_file_path) || exit 0 | ||
| case "$FILE" in | ||
| *.md | *.mdc) ;; | ||
| *) exit 0 ;; | ||
| esac | ||
|
|
||
| MDLINT=() | ||
| if command -v markdownlint-cli2 >/dev/null 2>&1; then | ||
| MDLINT=(markdownlint-cli2) | ||
| elif command -v npx >/dev/null 2>&1; then | ||
| MDLINT=(npx markdownlint-cli2) | ||
|
Comment on lines
+28
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In repos where Useful? React with 👍 / 👎. |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
CLAUDE_PROJECT_DIRis/repo/app, this raw prefix check also accepts/repo/app-backup/file.md. The guard is supposed to skip files outside the current project, but with similarly named sibling directories aWrite/Editto that path will still runmarkdownlint-cli2 --fixoutside the project; compare against$norm_project/or use a relative-path check with a real boundary.Useful? React with 👍 / 👎.