Skip to content
Merged
6 changes: 6 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
"source": "./plugins/desktop-notification",
"category": "notifications",
"tags": ["notification", "desktop", "toast", "bell", "osc9", "hook"]
},
{
"name": "powershell-format",
"source": "./plugins/powershell-format",
"category": "formatting",
"tags": ["powershell", "psscriptanalyzer", "pwsh", "formatter", "linter", "hook"]
}
]
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ Browse and manage with `/plugin`. To refresh after updates: `/plugin marketplace
| [`bash-lint`](plugins/bash-lint) | Hook | Lints shell scripts on edit via ShellCheck, and formats via shfmt when the repo opts in with an `.editorconfig`, using the consuming repo's own config. |
| [`biome-format`](plugins/biome-format) | Hook | Formats and lints JS/TS/JSX/JSON on edit via Biome, only when the repo opts in with a `biome.json`, using the consuming repo's own Biome config. |
| [`ruff-format`](plugins/ruff-format) | Hook | Formats and lints Python on edit via Ruff, only when the repo opts in with a Ruff config (`ruff.toml`, `.ruff.toml`, or `pyproject.toml` `[tool.ruff]`), using the consuming repo's own Ruff config. |
| [`eol-normalizer`](plugins/eol-normalizer) | Hook | Normalizes a file's working-tree line endings on edit to its `.gitattributes` `eol=` value via `git check-attr` — symmetric CRLF↔LF, binary-safe, using the consuming repo's own attributes. |
| [`desktop-notification`](plugins/desktop-notification) | Hook | Alerts you when Claude Code needs input via an audible bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts. |
| [`powershell-format`](plugins/powershell-format) | Hook | Formats and lints PowerShell on edit via PSScriptAnalyzer, only when the repo opts in with a `PSScriptAnalyzerSettings.psd1`, using the consuming repo's own analyzer settings. |

Install one: `/plugin install <plugin-name>@melodic-software`.

Expand Down
11 changes: 11 additions & 0 deletions plugins/powershell-format/.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": "powershell-format",
"version": "0.1.0",
"description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings.",
"author": {
"name": "Melodic Software",
"email": "info@melodicsoftware.com"
},
"keywords": ["powershell", "psscriptanalyzer", "pwsh", "formatter", "linter", "hook"]
}
83 changes: 83 additions & 0 deletions plugins/powershell-format/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# powershell-format

A Claude Code plugin that formats and lints PowerShell the moment you edit it. On
every `Write` or `Edit` of a `.ps1`, `.psm1`, or `.psd1` file it runs
[PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/overview)'s
`Invoke-Formatter` (formatting in place) and `Invoke-ScriptAnalyzer` (linting),
then surfaces any residual findings back to Claude as advisory context.

It uses **your repository's own analyzer settings**. It ships no rules of its own
and runs only when your repo has opted into a `PSScriptAnalyzerSettings.psd1`.

## Behavior

- **Opt-in on a settings file.** PSScriptAnalyzer runs **only when a
`PSScriptAnalyzerSettings.psd1` governs the edited file**, found by walking up
from the file to the repository root and stopping at the closest one. Unlike
some formatters, PSScriptAnalyzer does not auto-discover its settings —
`Invoke-Formatter` and `Invoke-ScriptAnalyzer` take an explicit settings path —
so the hook both gates on that file and passes it through. A repo without a
settings file is left untouched rather than formatted and linted with
PSScriptAnalyzer's built-in defaults, so the plugin never imposes a style you
did not choose.
- **Format on edit.** `Invoke-Formatter` applies your settings' formatting rules
(indentation, alias expansion, brace placement, and so on) in place.
- **Findings are advisory.** Semantic diagnostics your settings enable (for
example `PSAvoidGlobalVars`) are *reported* but never auto-applied.
- **Advisory, never blocking.** The hook always exits `0`. Findings are reported
via `additionalContext`; they never reject the edit. Make a commit hook or CI
your hard gate.
- **Graceful degrade.** If PowerShell (`pwsh`) is not installed, or the
PSScriptAnalyzer module is not available, the hook is a clean silent no-op —
no error spam. `pwsh` is resolved from `PATH` and is never downloaded.

## Trust model

The opt-in `PSScriptAnalyzerSettings.psd1` is **executed-adjacent configuration**,
not inert data. A settings file may declare a
[`CustomRulePath`](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/using-scriptanalyzer#custom-rules)
pointing at PowerShell rule modules, and PSScriptAnalyzer **loads and runs** those
modules' exported functions during analysis. Treat the settings file — and any
module it references — with the same trust you give your build and CI
configuration: it runs on your machine on every edit. The hook only reads a
settings file at or below your project root (bounded by `CLAUDE_PROJECT_DIR` when
set), so it never picks up one from an ancestor directory outside the project.
Do not enable this plugin against an untrusted working tree.

## Requirements

- **PowerShell 7+** (`pwsh`) on `PATH` — the hook probes `pwsh` only; legacy
Windows PowerShell 5.1 (`powershell.exe`) is not used. If absent, the hook is
a silent no-op.
- The **PSScriptAnalyzer** module installed
(`Install-Module PSScriptAnalyzer`). If absent, the hook is a silent no-op.
- A **`PSScriptAnalyzerSettings.psd1`** in your repo — the opt-in.

The hook itself runs on Bash 3.2+. Telemetry timing uses `EPOCHREALTIME`
(Bash 5.0+); on older bash the telemetry envelope is skipped while formatting and
linting still run.

## Install

```shell
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install powershell-format@melodic-software
```

## Configuration

This plugin has no `userConfig` — its only "configuration" is the
`PSScriptAnalyzerSettings.psd1` already in your repository, which it reads
automatically. To change the rules, edit that file.

### Disable without uninstalling

Set the kill switch in your settings `env` block:

```json
{ "env": { "HOOK_POWERSHELL_FORMAT_ENABLED": "false" } }
```

## License

[MIT](../../LICENSE).
247 changes: 247 additions & 0 deletions plugins/powershell-format/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
# shellcheck shell=bash
# Shared hook utility library for this marketplace's hook plugins. Sourced
# (not executed): kill switch, file_path parsing + path normalization,
# repo-root resolution, additionalContext accumulator, telemetry envelope.
#
# SINGLE SOURCE OF TRUTH: lib/hook-utils.sh at the marketplace repo root. The
# copies at plugins/*/hooks/hook-utils.sh exist because installed plugins are
# cache-isolated and must be self-contained — never edit a copy. Edit the
# source and run scripts/sync-hook-utils.sh; CI rejects drifted copies.

# Guard against double-sourcing.
[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0
readonly _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 for the membership comparison below: backslashes → forward
# slashes, and — only on Windows/MSYS, whose filesystem is case-insensitive —
# fold a leading drive (POSIX `/c/...` or `c:/...`) to an upper-case drive
# letter + lower-cased remainder so the byte-exact comparison is effectively
# case-insensitive. The fold is gated on the host (OSTYPE), NOT on the path
# shape: on a case-sensitive POSIX filesystem a real single-letter top-level
# directory such as `/c/Repo` must pass through unchanged, otherwise it would
# collapse with `/c/repo` and the membership guard would admit a sibling
# outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the
# emitted path is always the caller's original.
hook::normalize_path() {
local p="${1//\\//}"
case "${OSTYPE:-}" in
msys* | cygwin* | win32)
if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then
local rest="${p:2}"
printf '%s' "${BASH_REMATCH[1]^}:${rest,,}"
return
fi
;;
*) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below
esac
printf '%s' "$p"
}

# Canonicalize to a physical path — symlinks resolved — for the membership
# comparison below, so an in-project symlink pointing outside the project root
# cannot defeat the guard (the lexical path would pass the prefix check while
# the write lands elsewhere). GNU realpath ships with Git Bash and Linux
# coreutils; readlink -f covers the BSD/macOS hosts that have no realpath.
# When neither resolver exists the caller falls back to comparing the lexical
# path as before — the guard is defense-in-depth scoping for a file the agent
# already wrote via its own tools, so degrading to the historical comparison
# beats silently disabling the hook on those hosts.
hook::physical_path() {
local resolved
if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then
if [[ -n "$resolved" ]]; then
printf '%s' "$resolved"
return
fi
fi
printf '%s' "$1"
}

# Parse file_path from PostToolUse JSON on stdin; validate existence and (when
# CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership
# comparison are canonicalized (symlinks resolved) first, so neither an
# escaping symlink nor a project root reached via a symlinked path (e.g.
# macOS /tmp) skews the verdict. 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 "$(hook::physical_path "$file")")
norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")")
norm_project="${norm_project%/}"
# Anchor on a path-segment boundary: accept the project root itself or a
# child under it, but not a sibling whose name merely shares the prefix
# (e.g. /c/repo must not admit /c/repo-backup/...).
if [[ "$norm_file" != "$norm_project" && "$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
}

# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers
# gate telemetry-payload construction on this (repo-relative path
# normalization, data JSON) so the unwired default path spawns zero
# telemetry-only subprocesses. Pure shell test, no subprocess.
# hook::emit_telemetry re-checks the sink itself, so skipping this probe
# costs only wasted payload work, never correctness.
hook::telemetry_enabled() {
[[ -n "${HOOK_TELEMETRY_SINK:-}" ]]
}

# Emit one telemetry envelope per hook run to the consumer-set sink.
# Fire-and-forget: sink is dispatched in the background; the hook never waits
# on it and its failure never affects the hook's own exit code or stdout.
# Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately.
# Fail-open: jq absent → return 0 immediately.
#
# Usage:
# hook::emit_telemetry <hook_id> <hook_event> <status> <start_epoch> <data_json> [repo_root]
#
# <start_epoch> Value of $EPOCHREALTIME captured by the caller before work began.
# Handles both '.' and ',' as the decimal separator (LC_NUMERIC).
# <data_json> Pre-built JSON object for the `data` field.
# <repo_root> Optional consuming-repo root, used to resolve a RELATIVE
# HOOK_TELEMETRY_SINK. The caller passes the root it already
# resolved for data.file; ignored when the sink is absolute.
#
# Sink path resolution: HOOK_TELEMETRY_SINK may be absolute OR relative to the
# consuming repo root. Absolute (POSIX /… or Windows X:\ / X:/) is used as-is; a
# relative value is joined onto <repo_root> (or $CLAUDE_PROJECT_DIR when no root
# is passed), and skipped fail-open if neither is available. Relative is the
# portable, team-shared wiring form: CC injects settings.json env values
# literally (no ${VAR} expansion), so a relative path tracked in settings.json is
# the only clone-portable, worktree-safe option.
#
# NEVER writes to fd1 (the hook's stdout / additionalContext channel).
hook::emit_telemetry() {
# Opt-in guard.
[[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0
# Fail-open when jq is absent.
command -v jq >/dev/null 2>&1 || return 0

local hook_id="$1"
local hook_event="$2"
local status="$3"
local start_epoch="$4"
local data_json="$5"
local repo_root="${6:-}"

# Compute duration_ms from caller's $EPOCHREALTIME snapshot to now.
# Both '.' and ',' separators handled; 10# prefix prevents octal misreading
# of fractional parts with leading zeros (e.g. .045123 → 10#045123 = 45123).
local now=$EPOCHREALTIME
local s_s="${start_epoch%[.,]*}" s_f="${start_epoch#*[.,]}"
local e_s="${now%[.,]*}" e_f="${now#*[.,]}"
local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000))

# True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie).
local timestamp
timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1)

# Build the envelope. Redirect jq stderr to /dev/null; output goes to a local
# variable — never to fd1.
local envelope
envelope=$(jq -n \
--arg schema_version "1.0" \
--arg timestamp "$timestamp" \
--arg hook "$hook_id" \
--arg hook_event "$hook_event" \
--arg status "$status" \
--argjson duration_ms "$duration_ms" \
--argjson data "$data_json" \
'{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:$data}' \
2>/dev/null) || return 0

# Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the
# consuming repo root (portable, tracked wiring); absolute is used as-is. A
# relative value with no anchor is skipped fail-open — never exec a path the
# drifted hook CWD would resolve incorrectly.
local sink="$HOOK_TELEMETRY_SINK"
case "$sink" in
/* | [A-Za-z]:[/\\]*) ;;
*)
local root="${repo_root:-${CLAUDE_PROJECT_DIR:-}}"
[[ -n "$root" ]] || return 0
sink="${root%/}/$sink"
;;
esac

# Fire-and-forget: pipe the envelope to the sink in a background subshell.
# The subshell's stdout AND stderr are redirected to /dev/null so the sink
# cannot write to the hook's fd1 (the additionalContext channel) and the
# backgrounded subshell does not hold a copy of the hook's fd1 open — which
# would block any command substitution wrapping the hook until the sink exits
# (the "C1 fd1-inheritance blocker"). The sink is quoted — it is a single
# executable path (wrap in a script to pass arguments).
printf '%s\n' "$envelope" | ("$sink" >/dev/null 2>&1) &
}

# 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/powershell-format/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/powershell-format.sh",
"timeout": 30
}
]
}
]
}
}
Loading
Loading