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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,22 @@ jobs:
- name: Check plugin hook configs for bare userConfig argv tokens
run: scripts/check-hook-userconfig-argv.sh

# Every option a plugin declares must be documented in that plugin's README.
# The block is generated from the manifest, so the failure this catches is an
# option added to plugin.json whose README block was never regenerated -- an
# undocumented knob is indistinguishable from one that does not exist, and the
# only place a user can discover it is the manifest itself.
plugin-options-docs-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check plugin option docs are in sync with each manifest
run: python3 scripts/sync-plugin-options-docs.py --check

silent-skip-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
Expand Down Expand Up @@ -1030,6 +1046,7 @@ jobs:
- cross-plugin-source-drift
- skill-leaf-name-gate
- userconfig-argv-gate
- plugin-options-docs-gate
- silent-skip-gate
- plugin-manifest-presence-gate
- orphaned-fixture-gate
Expand Down
30 changes: 26 additions & 4 deletions lib/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,33 @@ readonly _HOOK_UTILS_LOADED=1
# read from the hook-process CLAUDE_PLUGIN_OPTION_<NAME>_ENABLED mirror.
# Exits 0 (allow) if disabled. Place after source, before stdin parsing.
# hook::check_enabled "MARKDOWN_FORMAT" # checks CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED
hook::check_enabled() {
#
# Deliberately NOT layered with a marketplace-specific fleet switch. Claude Code
# already ships the coarse controls, and a parallel scheme here would become a
# second source of truth for the same question:
# * `--safe-mode` / `CLAUDE_CODE_SAFE_MODE` — start with every customization
# (CLAUDE.md, plugins, skills, hooks, MCP servers) disabled
# * `disableAllHooks` — disable all hooks and any custom status line
# * `claude plugin disable|enable <name>` — per-plugin, dependency-aware
# This helper stays scoped to the one thing it owns: the plugin's own
# `<name>_enabled` userConfig boolean, surfaced to hook processes as the native
# `$CLAUDE_PLUGIN_OPTION_<KEY>` mirror.

# hook::is_enabled <NAME> — the same check as a PREDICATE. Returns 0 when the
# plugin should run, 1 when it should not. For callers that must not terminate
# the process on a "disabled" answer.
#
# The statusline tee is exactly that caller: it is a TRANSPARENT WRAPPER around
# the user's real statusline, so exiting 0 on "disabled" would suppress the
# wrapped command's output and blank the status line. It needs to skip its own
# side effect and still pass through.
hook::is_enabled() {
local var_name="CLAUDE_PLUGIN_OPTION_${1}_ENABLED"
if [[ "${!var_name:-true}" != "true" ]]; then
exit 0
fi
[[ "${!var_name:-true}" == "true" ]]
}

hook::check_enabled() {
hook::is_enabled "$1" || exit 0
}

# --- Prerequisite visibility --------------------------------------------------
Expand Down
76 changes: 76 additions & 0 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2295,6 +2295,82 @@ else
fail "jq_fields early-return flag: expected 0, got '$HOOK_JQ_FIELDS_NUL'"
fi

# --- Test 20: hook::is_enabled / hook::check_enabled --------------------------
# `check_enabled` exits the process when a plugin is gated off, so a case cannot
# be asserted in-process: each runs in a child bash that sources the lib and
# prints a sentinel only if the gate let it through. Absence of the sentinel IS
# the "skipped" signal. Each probe unsets the control variable first so an
# ambient value in the test runner's own environment cannot mask a regression.
#
# Scope note: this gate reads ONLY the plugin's own userConfig mirror. Turning
# the whole fleet off is Claude Code's job (`--safe-mode`, `disableAllHooks`,
# `claude plugin disable`), not this library's — a second fleet-wide switch here
# would be a competing source of truth for the same question.
ce_probe() {
# ce_probe <RUN|SKIP> <description> [VAR=VALUE ...]
local want="$1" desc="$2"
shift 2
local got
got=$(
# shellcheck disable=SC2016 # $0 must NOT expand here: it is the child bash's
# own positional, bound to the library path passed after the -c string.
env -u CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED \
"$@" bash -c 'source "$0"; hook::check_enabled "RATE_LIMIT_GUARD"; printf RUN' \
"$HOOK_DIR/hook-utils.sh" 2>/dev/null
)
got="${got:-SKIP}"
if [[ "$got" == "$want" ]]; then
ok "check_enabled: $desc"
else
fail "check_enabled: $desc — got '$got', want '$want'"
fi
}

# The default must not move: every existing installation leaves this unset, so a
# regression here silently changes behavior for every user of the marketplace.
ce_probe RUN "unset stays enabled (backward compatibility)"
ce_probe RUN "explicit true" CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED=true
ce_probe SKIP "explicit false" CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED=false
# Anything that is not exactly "true" is off — a typo must not read as enabled.
ce_probe SKIP "a non-boolean value is not 'true'" CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED=yes
# An EMPTY value is treated as unset, not as false: `${var:-true}` falls back to
# the default. This is long-standing behavior, asserted here so a future change
# to the parameter expansion (`:-` to `-`) can't flip it silently. It matters
# because Claude Code exports every declared userConfig option to the hook
# environment, so an option the user never answered arrives as an empty string —
# and that must mean "default", not "disabled".
ce_probe RUN "empty value falls back to the default (treated as unset)" \
CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED=

# hook::is_enabled is the predicate form: same answer, but it RETURNS instead of
# exiting. The statusline tee depends on this — it wraps the user's real
# statusline, so an exit would blank the status line instead of just skipping
# the tee's own write.
ie_probe() {
local want="$1" desc="$2"
shift 2
local got
got=$(
# shellcheck disable=SC2016 # $0 must NOT expand here: it is the child bash's
# own positional, bound to the library path passed after the -c string.
env -u CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED \
"$@" bash -c 'source "$0"
if hook::is_enabled "RATE_LIMIT_GUARD"; then printf ENABLED; else printf DISABLED; fi
printf "+SURVIVED"' "$HOOK_DIR/hook-utils.sh" 2>/dev/null
)
if [[ "$got" == "$want" ]]; then
ok "is_enabled: $desc"
else
fail "is_enabled: $desc — got '$got', want '$want'"
fi
}
ie_probe "ENABLED+SURVIVED" "unset returns true and does not exit"
ie_probe "ENABLED+SURVIVED" "explicit true returns true" \
CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED=true
# The critical one: a disabled answer must NOT terminate the caller.
ie_probe "DISABLED+SURVIVED" "explicit false returns false WITHOUT exiting" \
CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED=false

echo
echo "PASS=$PASS FAIL=$FAIL"
[[ $FAIL -eq 0 ]]
2 changes: 1 addition & 1 deletion plugins/actionlint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "actionlint",
"version": "0.8.3",
"version": "0.8.4",
"description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.",
"author": {
"name": "Melodic Software",
Expand Down
10 changes: 10 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
All notable changes to the `actionlint` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.8.4]

### Changed

- **Carries the shared hook library's new `hook::is_enabled` predicate.** `hook::check_enabled`
exits the process when a plugin is gated off, which is correct for a hook but wrong for a
caller that must keep running afterward. The resolution is now also available as a predicate
that returns instead of exiting. No behaviour of this plugin changes; the version moves so
consumers receive the updated library.

## [0.8.3]

### Fixed
Expand Down
58 changes: 58 additions & 0 deletions plugins/actionlint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,64 @@ here applies across every project. To disable actionlint for a single
repository, disable the plugin in that project's `enabledPlugins` rather than
setting `actionlint_enabled`.

<!-- BEGIN GENERATED: plugin options — edit plugin.json, then run scripts/sync-plugin-options-docs.py -->

### Options reference

Generated from this plugin's `.claude-plugin/plugin.json`. Every option Claude Code
will prompt for when the plugin is enabled, with the environment variable each hook
reads it from.

| Option | Type | Default | Environment variable | Description |
| --- | --- | --- | --- | --- |
| `actionlint_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_ACTIONLINT_ENABLED` | Lint GitHub Actions workflow files on edit via actionlint |
| `stdin_read_timeout` | number<br>*min 1* | `2` | `CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT` | Idle bound on reading the hook payload from stdin — how long the pipe may go silent before the hook gives up and fails open |

### How to set these

Three supported routes, in the order most people want them:

1. **Interactively** — Claude Code prompts for declared options when you enable the
plugin. To change them later: `/plugin configure actionlint`.
2. **Headless, at install time** — repeat `--config` for each option. Replace
`<marketplace>` with the marketplace you installed this plugin from:

```shell
claude plugin install actionlint@<marketplace> --config actionlint_enabled=<value>
```

3. **By hand, in settings** — add the value under `pluginConfigs` in your **user**
settings (`~/.claude/settings.json`):

```json
{
"pluginConfigs": {
"actionlint@<marketplace>": {
"options": {
"actionlint_enabled": <value>
}
}
}
}
```

Plugin option values are read from **user**, `--settings`, and managed settings
only — **not** from a project's `.claude/settings.json`. To vary behavior per
repository, enable or disable the plugin in that project's `enabledPlugins`
instead of setting an option there.

Do not set the `CLAUDE_PLUGIN_OPTION_*` variables yourself. They are how Claude Code
hands a configured value to a hook process; the value comes from the routes above.

### Upstream documentation

- [User configuration](https://code.claude.com/docs/en/plugins-reference#user-configuration) — the `userConfig` schema and the `CLAUDE_PLUGIN_OPTION_<KEY>` export
- [Plugin settings](https://code.claude.com/docs/en/settings#plugin-settings) — `enabledPlugins`, `extraKnownMarketplaces`, `pluginConfigs`
- [Configuration scopes](https://code.claude.com/docs/en/settings#configuration-scopes) — user vs project vs local precedence
- [Manage installed plugins](https://code.claude.com/docs/en/discover-plugins#manage-installed-plugins) — enabling, disabling, `/plugin list`

<!-- END GENERATED: plugin options -->

## License

MIT (SPDX-License-Identifier: MIT).
30 changes: 26 additions & 4 deletions plugins/actionlint/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,33 @@ readonly _HOOK_UTILS_LOADED=1
# read from the hook-process CLAUDE_PLUGIN_OPTION_<NAME>_ENABLED mirror.
# Exits 0 (allow) if disabled. Place after source, before stdin parsing.
# hook::check_enabled "MARKDOWN_FORMAT" # checks CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED
hook::check_enabled() {
#
# Deliberately NOT layered with a marketplace-specific fleet switch. Claude Code
# already ships the coarse controls, and a parallel scheme here would become a
# second source of truth for the same question:
# * `--safe-mode` / `CLAUDE_CODE_SAFE_MODE` — start with every customization
# (CLAUDE.md, plugins, skills, hooks, MCP servers) disabled
# * `disableAllHooks` — disable all hooks and any custom status line
# * `claude plugin disable|enable <name>` — per-plugin, dependency-aware
# This helper stays scoped to the one thing it owns: the plugin's own
# `<name>_enabled` userConfig boolean, surfaced to hook processes as the native
# `$CLAUDE_PLUGIN_OPTION_<KEY>` mirror.

# hook::is_enabled <NAME> — the same check as a PREDICATE. Returns 0 when the
# plugin should run, 1 when it should not. For callers that must not terminate
# the process on a "disabled" answer.
#
# The statusline tee is exactly that caller: it is a TRANSPARENT WRAPPER around
# the user's real statusline, so exiting 0 on "disabled" would suppress the
# wrapped command's output and blank the status line. It needs to skip its own
# side effect and still pass through.
hook::is_enabled() {
local var_name="CLAUDE_PLUGIN_OPTION_${1}_ENABLED"
if [[ "${!var_name:-true}" != "true" ]]; then
exit 0
fi
[[ "${!var_name:-true}" == "true" ]]
}

hook::check_enabled() {
hook::is_enabled "$1" || exit 0
}

# --- Prerequisite visibility --------------------------------------------------
Expand Down
57 changes: 57 additions & 0 deletions plugins/ai-briefing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,60 @@ cannot escape that boundary.
Machine-local state and generated artifacts live under `${CLAUDE_PLUGIN_DATA}`, keyed by
profile. Tracked source, audience, and brand configuration always stays in the consumer
repository.

<!-- BEGIN GENERATED: plugin options — edit plugin.json, then run scripts/sync-plugin-options-docs.py -->

### Options reference

Generated from this plugin's `.claude-plugin/plugin.json`. Every option Claude Code
will prompt for when the plugin is enabled, with the environment variable each hook
reads it from.

| Option | Type | Default | Environment variable | Description |
| --- | --- | --- | --- | --- |
| `active_profile` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_ACTIVE_PROFILE` | Portable 1-63 character lowercase-kebab name of the ai-briefing profile to use; reserved Windows device names are not allowed. Leave unset when there is a single profile (or only the default). A per-invocation --profile <name> argument overrides this. |

### How to set these

Three supported routes, in the order most people want them:

1. **Interactively** — Claude Code prompts for declared options when you enable the
plugin. To change them later: `/plugin configure ai-briefing`.
2. **Headless, at install time** — repeat `--config` for each option. Replace
`<marketplace>` with the marketplace you installed this plugin from:

```shell
claude plugin install ai-briefing@<marketplace> --config active_profile=<value>
```

3. **By hand, in settings** — add the value under `pluginConfigs` in your **user**
settings (`~/.claude/settings.json`):

```json
{
"pluginConfigs": {
"ai-briefing@<marketplace>": {
"options": {
"active_profile": <value>
}
}
}
}
```

Plugin option values are read from **user**, `--settings`, and managed settings
only — **not** from a project's `.claude/settings.json`. To vary behavior per
repository, enable or disable the plugin in that project's `enabledPlugins`
instead of setting an option there.

Do not set the `CLAUDE_PLUGIN_OPTION_*` variables yourself. They are how Claude Code
hands a configured value to a hook process; the value comes from the routes above.

### Upstream documentation

- [User configuration](https://code.claude.com/docs/en/plugins-reference#user-configuration) — the `userConfig` schema and the `CLAUDE_PLUGIN_OPTION_<KEY>` export
- [Plugin settings](https://code.claude.com/docs/en/settings#plugin-settings) — `enabledPlugins`, `extraKnownMarketplaces`, `pluginConfigs`
- [Configuration scopes](https://code.claude.com/docs/en/settings#configuration-scopes) — user vs project vs local precedence
- [Manage installed plugins](https://code.claude.com/docs/en/discover-plugins#manage-installed-plugins) — enabling, disabling, `/plugin list`

<!-- END GENERATED: plugin options -->
2 changes: 1 addition & 1 deletion plugins/autonomy/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "autonomy",
"version": "0.14.3",
"version": "0.14.4",
"description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.",
"author": {
"name": "Melodic Software",
Expand Down
10 changes: 10 additions & 0 deletions plugins/autonomy/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to the `autonomy` plugin are documented here. Format follows
Versions 0.1.0–0.7.0 predate this file (introduced with 0.7.1); their history lives in the
merged work-package PRs (#333, #343, #356, #372, #377, #600, #676).

## [0.14.4]

### Changed

- **Carries the shared hook library's new `hook::is_enabled` predicate.** `hook::check_enabled`
exits the process when a plugin is gated off, which is correct for a hook but wrong for a
caller that must keep running afterward. The resolution is now also available as a predicate
that returns instead of exiting. No behaviour of this plugin changes; the version moves so
consumers receive the updated library.

## [0.14.3]

### Fixed
Expand Down
Loading
Loading