diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62a6a541..ab53c704 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -965,89 +965,11 @@ jobs: run: bash distribution/sync-manifest.test.sh - name: Claude-permissions tombstone contract test run: bash harness/shell/run-tests.sh components/claude-permissions/claude-permissions.test.sh - - name: Plugin-baseline drift-report contract test - run: bash harness/shell/run-tests.sh distribution/check-plugin-baseline.test.sh - name: Claude-settings target conformance test run: bash harness/shell/run-tests.sh distribution/check-claude-settings-targets.test.sh - name: Validate production manifest run: bash distribution/sync-manifest.sh validate - # Fleet plugin list vs the dotfiles workstation seed. The two lists are - # separate on purpose — the fleet list is the cloud install set and - # first-party only, the seed is workstation enablement and additionally - # carries third-party entries — but a first-party plugin added to the fleet - # list and never reaching the seed is drift nobody sees until someone runs - # the comparator by hand, which is how four ids went missing. - # - # A separate job rather than a step in `distribution`, for the reason the - # pr-convention-lockstep job records: this reads a consumer repository over - # the network, and that non-hermetic dependency must not contaminate the - # hermetic component lane. - plugin-seed-drift: - if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} - permissions: - contents: read - runs-on: ubuntu-24.04 - timeout-minutes: 15 - env: - HAS_APP_CREDENTIAL: >- - ${{ secrets.STANDARDS_SYNC_APP_CLIENT_ID != '' - && secrets.STANDARDS_SYNC_APP_PRIVATE_KEY != '' }} - steps: - - name: Check out - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - # dotfiles is private, so the seed is read through the contents API with - # a contents-read token from the standards-sync App — the - # pr-convention-lockstep precedent. No new secret: dotfiles is already a - # sync target, so it is in that installation, and the token is narrowed - # to that one repository. The mint is guarded on the secrets being - # present so an absent credential reaches the fetch tokenless and dies - # there instead of inside the mint action; the fetch then fails and the - # lane goes red. Not a skip either way. Two run kinds see no repository - # secrets: fork pull requests, which carry none by design, and - # Dependabot pull requests, which resolve the same two names from this - # repository's Dependabot secrets. Mirror both there whenever the App - # key rotates, or every Dependabot pull request fails this lane too. - - name: Mint a contents-read App token for the dotfiles seed - id: seed-token - if: env.HAS_APP_CREDENTIAL == 'true' - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.STANDARDS_SYNC_APP_CLIENT_ID }} - private-key: ${{ secrets.STANDARDS_SYNC_APP_PRIVATE_KEY }} - owner: melodic-software - repositories: dotfiles - permission-contents: read - # Only a fleet plugin the seed never names gates, and `-strict` is how - # the comparator says so: exit 3. Plain --compare-seed exits 1 on ANY - # divergence, and the steady state carries deliberate per-plugin seed - # opt-outs plus one seed-only entry, so that code alone would be red on - # every run; grepping the report's prose instead would put the go/no-go - # on a message the script is free to reword, which is the same silent - # green one layer down. Exit 2 — missing jq, unreadable JSON, a seed - # with no claudeSettings.seed.enabledPlugins object — still fails the - # lane, so a broken comparator or a structurally broken seed cannot read - # as "no drift". The report itself streams to the log either way. - - name: Compare the fleet plugin list against the dotfiles seed - env: - GH_TOKEN: ${{ steps.seed-token.outputs.token }} - run: | - set -euo pipefail - seed="$RUNNER_TEMP/dotfiles-claude.json" - gh api -H 'Accept: application/vnd.github.raw+json' \ - repos/melodic-software/dotfiles/contents/.chezmoidata/claude.json >"$seed" - bash distribution/check-plugin-baseline.sh --compare-seed-strict "$seed" && rc=0 || rc=$? - case "$rc" in - 0 | 1) ;; - 3) - echo '::error::the dotfiles seed is missing a plugin the fleet list enables' - exit 1 - ;; - *) exit "$rc" ;; - esac - exec-bit: if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} permissions: @@ -1252,7 +1174,6 @@ jobs: - cloud-environment - mcp-launcher - distribution - - plugin-seed-drift - lefthook - exec-bit - machine-specific-paths @@ -1321,7 +1242,6 @@ jobs: ${{ needs.harness.result }} ${{ needs.cloud-environment.result }} ${{ needs.distribution.result }} - ${{ needs.plugin-seed-drift.result }} ${{ needs.lefthook.result }} ${{ needs.exec-bit.result }} ${{ needs.machine-specific-paths.result }} diff --git a/components/cloud-environment/README.md b/components/cloud-environment/README.md index aee01dfb..22f7d302 100644 --- a/components/cloud-environment/README.md +++ b/components/cloud-environment/README.md @@ -60,19 +60,22 @@ its next cache rebuild (see [Update lifecycle](#update-lifecycle)). ## Plugin install Before the repo bootstrap, a generic, data-driven stage installs plugins from -one settings-shaped file: **the fleet list**, -[`fleet-plugins.json`](fleet-plugins.json) beside this script, the one place -the organization's cloud plugin set is declared. The script fetches it from -the same `raw.githubusercontent.com` path the bootstrap fetches this script -from, writes it into the snapshot at `/opt/melodic-fleet-plugins.json` +one settings-shaped file: **the fleet list**. The script fetches the +melodic-software marketplace catalog, +[`.claude-plugin/marketplace.json`](https://github.com/melodic-software/claude-code-plugins/blob/main/.claude-plugin/marketplace.json) +in claude-code-plugins, and enables every plugin whose catalog entry leaves +`defaultEnabled` unset or sets it `true`; any other value leaves the plugin +off. It writes the list into the snapshot at +`/opt/melodic-fleet-plugins.json` (falling back to `/tmp/melodic-fleet-plugins.json` with a logged `WARN` when `/opt` is unwritable, mirroring the stamp), and installs it. Every snapshot gets the fleet whatever repo it was built for, and the snapshot stays repo-agnostic. A repo's own `.claude/settings.json` carries only the deltas it declares -beyond the fleet (an extra marketplace, a plugin beyond the fleet, or a -`false` opt-out, which project scope applies over the user-scope install). +beyond the fleet (an extra marketplace, a `true` opt-in to an off-by-default +plugin, or a `false` opt-out, which project scope applies over the user-scope +install). Those are not installed by this stage: the [repo bootstrap](#repo-bootstrap-handoff) that runs next applies them as an overlay on top of this list, from the snapshot copy (see the @@ -88,17 +91,13 @@ add`, skipping ones already registered) and every `enabledPlugins` entry set to `true` is installed (`claude plugin install --scope user -y`, skipping ones already installed). Every step is best-effort with a `WARN` line to the log; the whole stage skips cleanly when the `claude` CLI or `jq` -is unavailable, and a failed fleet fetch installs no plugins that build (the -next rebuild fetches the list again). - -The fleet list is settings-shaped on purpose: `jq` expressions written for a -repo's settings file read it unchanged, and -[`distribution/check-plugin-baseline.sh`](../../distribution/check-plugin-baseline.sh) -uses it as the baseline every repo and the dotfiles seed are compared -against. Every entry in it is `true`; a `false` is a per-repo decision and -belongs in that repo's own file. To add a plugin to the fleet, add its entry -here in byte order (`setup.test.sh` checks both), then force a snapshot -rebuild (see [Update lifecycle](#update-lifecycle)). +is unavailable, and a failed catalog fetch installs no plugins that build (the +next rebuild fetches the catalog again). + +The fleet list is settings-shaped so the repo bootstrap reads it with the +same `jq` expressions it uses on a repo's settings file. To add a plugin to or +remove one from the cloud fleet, change its `defaultEnabled` in the catalog, +then force a snapshot rebuild (see [Update lifecycle](#update-lifecycle)). The timing is load-bearing: Claude Code builds its plugin registry at process start and never re-reads it, so only installs already in the @@ -121,7 +120,7 @@ unattended-loop `allow` grants ([claude-code-plugins#3172](https://github.com/melodic-software/claude-code-plugins/issues/3172)). The floor is fetched from the same `raw.githubusercontent.com` host as the -fleet list, and the merge follows the dotfiles template: +plugin catalog, and the merge follows the dotfiles template: `claudePermissions.allow` and `.deny` are unioned into the file's `permissions.allow` and `permissions.deny`, then the floor's `withdraw` tombstones are removed from `allow`. The file is never overwritten. Every @@ -232,12 +231,12 @@ the snapshot. - Rollback: environments keep booting from their cached snapshot until rebuilt, so reverting the commit and forcing a rebuild restores the prior state; in an emergency the bootstrap can pin a commit SHA in the raw URL - instead of `main`. The pin covers this script only: the fleet list and the - permission floor are still fetched from `main`. + instead of `main`. The pin covers this script only: the plugin catalog and + the permission floor are still fetched from `main`. The scope boundary holds as elsewhere in this repository: this component owns -the shared environment baseline, which includes the fleet plugin list and the -composition of the fleet permission floor. +the shared environment baseline, which includes deriving the fleet plugin +list and composing the fleet permission floor. Repo-specific dependencies and plugin deltas belong to each repo's committed `.claude/cloud-bootstrap.sh` and `.claude/settings.json` (templates in the fleet guide above), never to this script. diff --git a/components/cloud-environment/fleet-plugins.json b/components/cloud-environment/fleet-plugins.json deleted file mode 100644 index 65aba6cc..00000000 --- a/components/cloud-environment/fleet-plugins.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "extraKnownMarketplaces": { - "melodic-software": { - "source": { - "source": "github", - "repo": "melodic-software/claude-code-plugins" - }, - "autoUpdate": true - } - }, - "enabledPlugins": { - "actionlint@melodic-software": true, - "adhd@melodic-software": true, - "ai-briefing@melodic-software": true, - "ai-slop@melodic-software": true, - "architecture@melodic-software": true, - "attribution@melodic-software": true, - "autonomy@melodic-software": true, - "bash-format@melodic-software": true, - "biome-format@melodic-software": true, - "bugs@melodic-software": true, - "code-metrics@melodic-software": true, - "code-tidying@melodic-software": true, - "codebase-health@melodic-software": true, - "computer-use@melodic-software": true, - "context-budget@melodic-software": true, - "context-guard@melodic-software": true, - "context7@melodic-software": true, - "coupling@melodic-software": true, - "debugging@melodic-software": true, - "desktop-notification@melodic-software": true, - "discipline@melodic-software": true, - "discovery@melodic-software": true, - "disk-hygiene@melodic-software": true, - "docs-hygiene@melodic-software": true, - "domain-driven-design@melodic-software": true, - "dometrain@melodic-software": true, - "education@melodic-software": true, - "eol-normalizer@melodic-software": true, - "evals@melodic-software": true, - "event-storming@melodic-software": true, - "firecrawl@melodic-software": true, - "github@melodic-software": true, - "go-format@melodic-software": true, - "guardrails@melodic-software": true, - "harness-config@melodic-software": true, - "harness-memory@melodic-software": true, - "harness-ops@melodic-software": true, - "implementation@melodic-software": true, - "improvement@melodic-software": true, - "instruction-placement@melodic-software": true, - "kindle-dedrm@melodic-software": true, - "knowledge@melodic-software": true, - "machine-health@melodic-software": true, - "markdown-format@melodic-software": true, - "mcp-tools@melodic-software": true, - "miro@melodic-software": true, - "mutation-testing@melodic-software": true, - "naming@melodic-software": true, - "overengineering@melodic-software": true, - "performance@melodic-software": true, - "planning@melodic-software": true, - "playbooks@melodic-software": true, - "playgrounds@melodic-software": true, - "playwright@melodic-software": true, - "plugin-quality@melodic-software": true, - "powershell-format@melodic-software": true, - "prototype@melodic-software": true, - "rate-limit-guard@melodic-software": true, - "repo-fleet-hygiene@melodic-software": true, - "repo-hygiene@melodic-software": true, - "review@melodic-software": true, - "ruff-format@melodic-software": true, - "session-flow@melodic-software": true, - "skill-quality@melodic-software": true, - "songwriting@melodic-software": true, - "source-control@melodic-software": true, - "tdd@melodic-software": true, - "testing@melodic-software": true, - "toolchain@melodic-software": true, - "typos-format@melodic-software": true, - "verification@melodic-software": true, - "visualization@melodic-software": true, - "wizard@melodic-software": true, - "work-items@melodic-software": true, - "writing@melodic-software": true, - "x@melodic-software": true - } -} diff --git a/components/cloud-environment/setup.sh b/components/cloud-environment/setup.sh index 17d97e4f..06f489ce 100755 --- a/components/cloud-environment/setup.sh +++ b/components/cloud-environment/setup.sh @@ -36,18 +36,19 @@ # interleaves; the main shell's LOG is untouched by design. set -u -SCRIPT_VERSION='2026-10-01.1' +SCRIPT_VERSION='2026-10-02.1' STAMP='/opt/melodic-env-setup.done' STAMP_FALLBACK='/tmp/melodic-env-setup.done' -# Fleet plugin list: the one standards-hosted, settings-shaped file every -# cloud snapshot installs at user scope (fleet-plugins.json beside this -# script). It arrives through the same raw.githubusercontent.com host this -# script does, and is written into the snapshot at FLEET_PLUGINS so each -# repo's session bootstrap can repair drift from it without a network round -# trip. It is the only install source at cache build; a repo's own -# enabledPlugins block carries deltas beyond the fleet, which the session -# bootstrap applies as an overlay on top of this list. -FLEET_PLUGINS_URL='https://raw.githubusercontent.com/melodic-software/standards/main/components/cloud-environment/fleet-plugins.json' +# Fleet plugin list: every plugin in the melodic-software marketplace catalog +# whose entry leaves defaultEnabled unset or true, derived at cache build +# (fleet_list_from_catalog) into a settings-shaped file every cloud snapshot +# installs at user scope. It is written into the snapshot at FLEET_PLUGINS so +# each repo's session bootstrap can repair drift from it without a network +# round trip. It is the only install source at cache build; a repo's own +# enabledPlugins block carries deltas beyond the fleet (false to opt out, true +# to opt in to an off-by-default plugin), which the session bootstrap applies +# as an overlay on top of this list. +FLEET_CATALOG_URL='https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/.claude-plugin/marketplace.json' FLEET_PLUGINS='/opt/melodic-fleet-plugins.json' FLEET_PLUGINS_FALLBACK='/tmp/melodic-fleet-plugins.json' # Fleet permission floor: the reviewed allow/deny set of the @@ -116,6 +117,25 @@ install_plugins_from() { | select(.value == true) | .key' "$file" 2>/dev/null) } +# fleet_list_from_catalog : write the fleet list, a +# settings-shaped file that registers the melodic-software marketplace and +# enables every catalog entry whose defaultEnabled is absent or true; any other +# value, a mistyped "false" included, leaves the entry off. A catalog whose +# entries are all off still yields a list with nothing enabled, so a repo's +# opt-ins overlay it. Returns non-zero and leaves no when the catalog is +# unreadable or has no entries. +fleet_list_from_catalog() { + jq -e 'select((.plugins | length) > 0) | { + extraKnownMarketplaces: {"melodic-software": {source: {source: "github", + repo: "melodic-software/claude-code-plugins"}}}, + enabledPlugins: ([.plugins[] + | select(.defaultEnabled == null or .defaultEnabled == true) + | {key: "\(.name)@melodic-software", value: true}] | from_entries) + }' "$1" >"$2" 2>/dev/null && return 0 + rm -f "$2" 2>/dev/null + return 1 +} + # compose_permissions_floor : union the floor's # claudePermissions.allow and .deny into a Claude Code settings file, then drop # the floor's withdraw tombstones from allow, the same shape the dotfiles @@ -369,15 +389,15 @@ for track in "A gh + powershell|$track_a_log" \ done # Generic plugin install from one settings-shaped file (extraKnownMarketplaces -# + enabledPlugins): the fleet list at FLEET_PLUGINS_URL, which every snapshot -# installs whatever repo it was built for. No repo-specific logic — a repo's +# + enabledPlugins): the fleet list derived from FLEET_CATALOG_URL, which +# every snapshot installs whatever repo it was built for. No repo-specific logic — a repo's # own enabledPlugins block declares deltas beyond the fleet and the repo # bootstrap below applies them as an overlay, so the snapshot itself stays the # same for every repo built against it. This must happen here, at cache build: # Claude Code reads its plugin registry at process start and never re-reads # it, so only snapshot-baked installs are loaded at a session's first turn. # It must also happen BEFORE the repo bootstrap: that bootstrap's plugin stage -# reads this fetched list and skips outright when it is absent, so a bootstrap +# reads this derived list and skips outright when it is absent, so a bootstrap # that ran first would bake none of the repo's deltas and leave them to arrive # on a later resume. # Github-source marketplaces' install/update semantics already handle @@ -389,16 +409,17 @@ if ! command -v claude >/dev/null 2>&1; then elif ! command -v jq >/dev/null 2>&1; then log 'plugins: jq not available; skipping' else - # The fetched copy lands in the snapshot (FLEET_PLUGINS, or its /tmp + # The derived list lands in the snapshot (FLEET_PLUGINS, or its /tmp # fallback with a WARN, mirroring the stamp) so the repo bootstrap below — # and every later drift repair it runs on resume — reads the same list # offline. A fetch failure costs this build's plugin install, never the # build: the next rebuild fetches again, and a session on a snapshot without # the list installs nothing rather than guessing at a set. rm -f "$FLEET_PLUGINS" "$FLEET_PLUGINS_FALLBACK" 2>/dev/null + catalog_file="$(mktemp 2>/dev/null || echo "/tmp/melodic-fleet-catalog.$$")" if curl -fsSL --proto '=https' --retry 2 --retry-delay 3 \ - "$FLEET_PLUGINS_URL" -o "$FLEET_PLUGINS_FALLBACK" >>"$LOG" 2>&1 && - jq -e '(.enabledPlugins // {}) != {}' "$FLEET_PLUGINS_FALLBACK" >/dev/null 2>&1; then + "$FLEET_CATALOG_URL" -o "$catalog_file" >>"$LOG" 2>&1 && + fleet_list_from_catalog "$catalog_file" "$FLEET_PLUGINS_FALLBACK"; then fleet_file="$FLEET_PLUGINS_FALLBACK" if cp "$FLEET_PLUGINS_FALLBACK" "$FLEET_PLUGINS" 2>/dev/null; then rm -f "$FLEET_PLUGINS_FALLBACK" 2>/dev/null @@ -406,12 +427,12 @@ else else log "WARN plugins (fleet): $FLEET_PLUGINS unwritable; list kept at $FLEET_PLUGINS_FALLBACK" fi - log "plugins (fleet): list fetched to $fleet_file ($(jq -r '.enabledPlugins | length' "$fleet_file") entries)" + log "plugins (fleet): list derived from the catalog to $fleet_file ($(jq -r '.enabledPlugins | length' "$fleet_file") entries)" install_plugins_from "$fleet_file" fleet else - rm -f "$FLEET_PLUGINS_FALLBACK" 2>/dev/null - log 'WARN plugins (fleet): list fetch failed or empty; no plugins install this build' + log 'WARN plugins (fleet): catalog fetch failed or held no plugins; no plugins install this build' fi + rm -f "$catalog_file" 2>/dev/null fi # Bake the checked-out repo's committed bootstrap (.claude/cloud-bootstrap.sh diff --git a/components/cloud-environment/setup.test.sh b/components/cloud-environment/setup.test.sh index 10869616..8cff6ec0 100755 --- a/components/cloud-environment/setup.test.sh +++ b/components/cloud-environment/setup.test.sh @@ -67,46 +67,71 @@ grep -q '>"\$STAMP_FALLBACK"' "$script" rc=$? assert_exit 'setup.sh falls back when the primary stamp write fails' 0 "$rc" -# Fleet plugin list: the settings-shaped file every snapshot installs. It must -# parse, enable everything it names (a false entry is a per-repo delta, not a -# fleet decision), keep its keys in byte order so a single entry can be -# flipped without disturbing the rest, and declare every marketplace its -# entries name. -fleet="$root/components/cloud-environment/fleet-plugins.json" -assert_file_exists 'fleet-plugins.json exists beside setup.sh' "$fleet" -jq empty "$fleet" 2>/dev/null +# Fleet plugin list: derived at cache build from the marketplace catalog. An +# entry set true, or with no defaultEnabled at all, is enabled; false or any +# non-boolean value leaves it out. The output is settings-shaped and +# registers the marketplace its entries name, which is what both +# install_plugins_from here and the repo bootstrap read. Sourced via +# MELODIC_SETUP_LIBONLY so the apt/dotnet/nvm tracks do not run. +# Do not `shellcheck source=` this: LIBONLY returns immediately, and following +# it marks the test bodies below unreachable (SC2317). +# shellcheck disable=SC1090,SC1091 +MELODIC_SETUP_LIBONLY=1 source "$script" +cat_tmp="$(mktemp -d)" +cat >"$cat_tmp/catalog.json" <<'JSON' +{ + "name": "melodic-software", + "plugins": [ + { "name": "on-explicit", "defaultEnabled": true }, + { "name": "off", "defaultEnabled": false }, + { "name": "off-string", "defaultEnabled": "false" }, + { "name": "on-default" } + ] +} +JSON +fleet_list_from_catalog "$cat_tmp/catalog.json" "$cat_tmp/fleet.json" +rc=$? +assert_exit 'fleet list derives from a catalog' 0 "$rc" +assert_eq 'fleet list enables only entries with defaultEnabled absent or true' \ + '{"on-default@melodic-software":true,"on-explicit@melodic-software":true}' \ + "$(jq -cS '.enabledPlugins' "$cat_tmp/fleet.json")" +assert_eq 'fleet list registers the marketplace its entries name' \ + 'melodic-software melodic-software/claude-code-plugins' \ + "$(jq -r '.extraKnownMarketplaces | to_entries[] | "\(.key) \(.value.source.repo)"' "$cat_tmp/fleet.json")" +printf '%s' '{"plugins":[{"name":"off","defaultEnabled":false}]}' >"$cat_tmp/alloff.json" +fleet_list_from_catalog "$cat_tmp/alloff.json" "$cat_tmp/alloff-fleet.json" +rc=$? +assert_exit 'an all-off catalog still yields a fleet list for repo opt-ins' 0 "$rc" +assert_eq 'an all-off catalog enables no plugins' '{}' \ + "$(jq -c '.enabledPlugins' "$cat_tmp/alloff-fleet.json")" +printf '%s' '{"plugins":[]}' >"$cat_tmp/empty.json" +fleet_list_from_catalog "$cat_tmp/empty.json" "$cat_tmp/none.json" rc=$? -assert_exit 'fleet-plugins.json parses' 0 "$rc" -assert_eq 'fleet-plugins.json enables every entry it names' \ - '0' "$(jq -r '[.enabledPlugins // {} | to_entries[] | select(.value != true)] | length' "$fleet")" -assert_eq 'fleet-plugins.json keys are in byte order' \ - "$(jq -r '.enabledPlugins | keys_unsorted[]' "$fleet" | LC_ALL=C sort)" \ - "$(jq -r '.enabledPlugins | keys_unsorted[]' "$fleet")" -assert_eq 'fleet-plugins.json declares every marketplace its entries name' \ - '' "$(jq -r '(.extraKnownMarketplaces // {} | keys) as $mps - | [.enabledPlugins // {} | keys[] | split("@") | .[1:] | join("@")] | unique - | map(select(IN($mps[]) | not)) | .[]' "$fleet")" +assert_nonzero 'a catalog with no entries yields no fleet list' "$rc" +assert_eq 'a refused derivation leaves no fleet list file' 'absent' \ + "$([[ -e "$cat_tmp/none.json" ]] && echo present || echo absent)" +printf '%s' '{"plugins": ' >"$cat_tmp/broken.json" +fleet_list_from_catalog "$cat_tmp/broken.json" "$cat_tmp/none.json" +rc=$? +assert_nonzero 'an unparsable catalog yields no fleet list' "$rc" +rm -rf "$cat_tmp" -# Fetch lockstep: the URL setup.sh fetches must name the file this repository -# publishes, at the same host the README's bootstrap already relies on. -fleet_url="$(sed -n "s/^FLEET_PLUGINS_URL='\(.*\)'\$/\1/p" "$script")" -assert_eq 'setup.sh fetches the fleet list from its published path' \ - 'https://raw.githubusercontent.com/melodic-software/standards/main/components/cloud-environment/fleet-plugins.json' \ - "$fleet_url" +assert_eq 'setup.sh derives the fleet list from the published catalog' \ + 'https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/.claude-plugin/marketplace.json' \ + "$(sed -n "s/^FLEET_CATALOG_URL='\(.*\)'\$/\1/p" "$script")" fleet_path="$(sed -n "s/^FLEET_PLUGINS='\(.*\)'\$/\1/p" "$script")" if [[ -n "$fleet_path" ]]; then pass 'setup.sh declares the snapshot path for the fleet list' else fail 'setup.sh declares the snapshot path for the fleet list' "no FLEET_PLUGINS='...' assignment found" fi -# shellcheck disable=SC2016 # the $ is a literal in the grep pattern -grep -q '>"\$FLEET_PLUGINS_FALLBACK"\|-o "\$FLEET_PLUGINS_FALLBACK"' "$script" -rc=$? -assert_exit 'setup.sh falls back when the snapshot path for the fleet list is unwritable' 0 "$rc" +# shellcheck disable=SC2016 # the $ is a literal in the needle +assert_contains 'setup.sh falls back when the snapshot path for the fleet list is unwritable' \ + "$(cat "$script")" 'fleet_list_from_catalog "$catalog_file" "$FLEET_PLUGINS_FALLBACK"' assert_contains 'README documents the fleet list snapshot path' \ "$(cat "$readme")" "$fleet_path" -assert_contains 'README documents the fleet list file' \ - "$(cat "$readme")" 'fleet-plugins.json' +assert_contains 'README documents the catalog the fleet list derives from' \ + "$(cat "$readme")" '.claude-plugin/marketplace.json' # README/script drift guards. stamp_path="$(sed -n "s/^STAMP='\(.*\)'\$/\1/p" "$script")" @@ -182,14 +207,9 @@ fi # Spawn census for install_plugins_from: two sources must share one # marketplace list and one plugin list, the memoization cloud-bootstrap.sh -# relies on when it reads the fleet list and then the repo overlay. Sourced -# via MELODIC_SETUP_LIBONLY so the apt/dotnet/nvm tracks do not run. +# relies on when it reads the fleet list and then the repo overlay. # Membership is in-process (no grep -qxF per entry). plug_tmp="$(mktemp -d)" -# Do not `shellcheck source=` this: LIBONLY returns immediately, and following -# it marks the census body unreachable (SC2317). -# shellcheck disable=SC1090,SC1091 -MELODIC_SETUP_LIBONLY=1 source "$script" mkdir -p "$plug_tmp/bin" "$plug_tmp/counts" cat >"$plug_tmp/bin/claude" < # dotfiles seed -distribution/check-plugin-baseline.sh --compare-seed-strict # gateable -``` - -A repository still carrying a full mirrored block reports as matching; one -that has reduced its block to deltas reports every fleet entry as `missing vs -baseline`, which is the expected shape during that migration, not drift. The -`--compare-seed` mode reads a dotfiles data file -(`claudeSettings.seed.enabledPlugins`) and reports fleet plugins the seed -never names, fleet plugins the seed opts out of, and seed entries for a fleet -marketplace that the fleet list lacks; entries for other marketplaces are the -seed's own. Any of those exits 1. The steady state carries deliberate -opt-outs, so a gate wants only the first class: -`--compare-seed-strict` runs the same comparison and prints the same report, -but exits 3 when the seed is missing a fleet plugin, so a caller keys on an -exit code rather than on a report line it would have to keep in step by hand. -Either mode exits 2 on a seed that parses but carries no -`claudeSettings.seed.enabledPlugins` object, which a comparison would -otherwise read as an empty seed or as a match. - -Fleet mode fetches each target's settings via `gh api`, so it reads private -repositories with the caller's own auth. Before the per-target diffs it reports -how the baseline itself compares to each marketplace catalog it declares, -because measuring every repo against the baseline cannot show the baseline -falling behind: when a plugin is added to the marketplace and no settings file -learns about it, every repo reports `matches baseline` while none of them -installs it, and the gap surfaces only as a slash command that does not -resolve. Coverage is reported for the baseline alone: a target carrying a -deliberate subset would otherwise emit that subset as drift on every run. The -same comparison runs offline for a single pair: - -```sh -distribution/check-plugin-baseline.sh --compare-catalog \ - -``` - -Each repository also names undeclared catalog plugins in its own cloud -sessions: `cloud-bootstrap.sh` prints them as an inventory line beside its -install summary, reading the marketplace clone already on disk. - -To propagate a baseline change for a materialized target, edit that repo's file -under -`components/claude-settings/targets/` in this repository; sync delivers -exact bytes. Targets not yet materialized still use ordinary per-repo pull -requests. The report tells you which entries moved. +### Cloud plugin baseline + +Every cloud snapshot installs each plugin in the melodic-software marketplace +catalog whose entry leaves `defaultEnabled` unset or sets it `true` (see the +[cloud-environment component](../components/cloud-environment/README.md#plugin-install)). +Each repository's checked-in `.claude/settings.json` carries only its deltas +from that set: `false` to opt out of a plugin, `true` to opt in to an +off-by-default one. Cloud sessions install the catalog-derived list, then the +repo file's `true` entries. `cloud-bootstrap.sh` also prints an inventory line +naming catalog plugins that neither source enables, reading the marketplace +clone already on disk. + +To change a materialized target's deltas, edit that repo's file under +`components/claude-settings/targets/` in this repository; sync delivers exact +bytes. Targets not yet materialized still use ordinary per-repo pull requests. ## Adopting a new repository diff --git a/distribution/check-plugin-baseline.sh b/distribution/check-plugin-baseline.sh deleted file mode 100755 index 9da53b22..00000000 --- a/distribution/check-plugin-baseline.sh +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env bash -# Report-only fleet drift check for the plugin catalog each repo declares. -# -# The baseline is the fleet cloud plugin list, -# components/cloud-environment/fleet-plugins.json — the settings-shaped file -# every cloud snapshot installs at user scope, so its enabledPlugins set and -# marketplace declarations are the reference. Each target repository's checked -# in .claude/settings.json carries what that repo declares beyond the fleet -# (and, until it drops its mirrored block, the whole fleet again); this script -# only makes divergence from the baseline visible, it never edits anything. A -# repo may diverge on purpose — the signal is the diff existing, not the diff -# being wrong. -# -# Usage: -# distribution/check-plugin-baseline.sh [owner/repo ...] -# No arguments: checks every target listed in sync-manifest.yml, fetching -# each repo's .claude/settings.json@HEAD via `gh api` (works for private -# repositories with the caller's gh auth). -# distribution/check-plugin-baseline.sh --compare -# Offline single comparison between two settings files (also what the -# test exercises). -# distribution/check-plugin-baseline.sh --compare-catalog -# Offline coverage of one marketplace catalog by one settings file. -# distribution/check-plugin-baseline.sh --compare-seed [baseline.json] -# Offline comparison of the fleet list against a dotfiles seed -# (.chezmoidata/claude.json, claudeSettings.seed.enabledPlugins): the -# personal-machine list and the cloud list are two files on purpose, and -# this is what keeps them from drifting apart unnoticed. -# distribution/check-plugin-baseline.sh --compare-seed-strict [baseline.json] -# The same comparison, with one divergence class raised to its own exit -# code: a fleet plugin the seed never names exits 3. Opt-in, so callers of -# --compare-seed keep today's semantics; a gate uses this instead of -# grepping the report's prose for a message that is free to be reworded. -# -# Exit status: 0 always in report mode (drift is a signal, not a failure); -# the --compare* modes exit 1 when the candidate diverges, 2 on a usage error -# or a file jq cannot read or whose shape it cannot use, and (strict seed mode -# only) 3 when the seed is missing a fleet plugin, so callers can script them. -set -euo pipefail - -root="$(git rev-parse --show-toplevel)" -baseline="$root/components/cloud-environment/fleet-plugins.json" - -# compare_settings