Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
}
23 changes: 23 additions & 0 deletions .editorconfig
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
20 changes: 20 additions & 0 deletions .shellcheckrc
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion docs/MIGRATION-PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`). 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 <repo-root>` 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

Expand Down
11 changes: 11 additions & 0 deletions plugins/markdown-formatter/.claude-plugin/plugin.json
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"]
}
53 changes: 53 additions & 0 deletions plugins/markdown-formatter/README.md
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).
106 changes: 106 additions & 0 deletions plugins/markdown-formatter/hooks/hook-utils.sh
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a path boundary for project membership

When CLAUDE_PROJECT_DIR is /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 a Write/Edit to that path will still run markdownlint-cli2 --fix outside the project; compare against $norm_project/ or use a relative-path check with a real boundary.

Useful? React with 👍 / 👎.

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)
)}'
}
16 changes: 16 additions & 0 deletions plugins/markdown-formatter/hooks/hooks.json
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
}
]
}
]
}
}
51 changes: 51 additions & 0 deletions plugins/markdown-formatter/hooks/markdown-format.sh
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid treating npx alone as an available formatter

In repos where npx exists but markdownlint-cli2 is not installed locally/globally and the npm registry is unavailable or blocked, this branch still runs npx markdownlint-cli2; npx --help describes it as running from a “local or remote npm package”, so the hook captures npm fetch failures and reports them as markdownlint findings on every Markdown edit instead of being a no-op. Check for an actually local/resolvable package or suppress this fallback when the package cannot be executed without fetching.

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
Loading