diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 33ed2cd84..e7693c872 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.35.4", + "version": "0.36.0", "description": "Claude Code operations toolkit. Eleven skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used — a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which drops descriptions least-invoked-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow \u2014 CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and a bundled known-performance-issues reference; separates the three documented suspects \u2014 accumulated state, version regression, component bloat \u2014 and routes remediation out; reports, never mutates), observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures \u2014 the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 79f79bdba..e833e1907 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,133 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.36.0] + +Remediates the `claude-ops:plugins` post-use audit of the `sync` action (#3112). Every claim about +CLI behaviour added or changed below was verified on **Claude Code 2.1.240**. + +### Added + +- **Catalog-version pre-filter for the Step 3 user-scope sweep.** `marketplace.json` entries carry + no version, which is why Step 3 previously called `claude plugin update` for *every* user-scope + install and let the CLI no-op. Each plugin's version does exist in the marketplace checkout, at + `//.claude-plugin/plugin.json`, readable with no network call and + no CLI invocation. `fleet-state.sh` now exposes it as `catalog_versions`, and a new + `--ids update-candidates-user` selector withholds only ids it positively proved already sit at + the catalog version. On the authoring machine's own already-current fleet this takes the sweep + from 66 `claude plugin update` calls to **0**. + + **The selector fails open by construction, and that is the dominant path, not an edge case.** An + id whose catalog version cannot be read — an object-valued `source`, an unmaterialized plugin + directory, a manifest with no `version`, unparsable JSON — is emitted as a candidate, exactly as + if no pre-filter existed. Measured across the nine marketplaces registered on the authoring + machine: the version resolves for **every** entry of five (`melodic-software` 70/70, plus four + single-plugin marketplaces), **partially** for two (`claude-plugins-official` 13/53, + `dotnet-agent-skills` 1/15), and for **none** of two (`anthropic-agent-skills` 0/5, `caveman` + 0/1). So a marketplace the pre-filter cannot narrow at all is an ordinary outcome, not a + malfunction. `fleet-state.test.sh` proves the degradation as an equality: with no catalog version + readable, `update-candidates-user` output is byte-identical to `installed-user`. + + **The manifest it reads must sit inside the marketplace checkout, and that is enforced + physically.** A catalog entry's `source` is third-party content, and the only unsafe direction + this pre-filter has is *withholding* an update — a foreign manifest that happens to carry the + installed version string would suppress a real update. A lexical `../` refusal is not sufficient, + because a symlink inside the checkout pointing outside it is reached by an ordinary `./name` + source that no string inspection can see. So the resolved manifest path is required to sit under + the resolved checkout root, with symlinks followed. `fleet-state.test.sh` covers both: a real + symlink escape (created with `MSYS=winsymlinks:nativestrict` so Git Bash emits a link rather than + silently deep-copying, and skipped where the platform yields no real symlink) is refused, while an + in-checkout directory of the same shape still resolves. +- **`project_root`** (top level) — the resolved project root, or `null`. Closes the F1 silent no-op: + `currentProject` is a tri-state whose `null` collapses "no project context resolved at all" + together with "this is a user-scope record", so a run from `$HOME` and a run inside a repo with + no in-repo installs produced an identical downstream signal and an identical report. +- **`user_scope_orphans`** (top level) plus an `--ids user-scope-orphans` selector — ids holding a + project/local record and no user-scope record. Structurally invisible before: `divergences[]` + discards any id with fewer than two records, and `missing_from_user_install` excludes ids that + are installed somewhere, so nothing in the output named them. +- **`projectPathPresent`** on every project/local `installed[]` record and every + `divergences[].scopes[]` entry — advisory only, never a filter. +- Five eval cases covering the silent-failure paths the suite never reached (skipped in-repo step, + `sync all` marketplace coverage, absent-`projectPath` handling, pre-filter fail-open, and the + version-capture divergence branch). + +### Fixed + +- **Step 2 no longer skips silently.** It now branches on `project_root` and the report carries a + fixed `In-repo:` row in all three states, including `skipped — no project context resolved`. +- **`sync all` no longer sweeps one marketplace while reporting as though it covered every one.** + Steps 2–5 are the per-marketplace loop body and every `--ids` call carries `--marketplace`. +- **Divergences are no longer routed to a `converge` command that cannot run.** Records whose + `projectPath` is not present get their own report section, outside the actionable Divergences + count, and `converge` emits them as *blocked* rather than as runnable commands. +- **`pluginConfigs` scope claim corrected.** It is read from user settings, `--settings`, and + managed settings only — project and local entries are ignored (since v2.1.207) — while + `enabledPlugins`, read by this same skill, still honors them. The old text said "some + `pluginConfigs` scope", which invited setting `install_new` in a repo where it does nothing. +- **`/reload-plugins --force` guidance restated as the docs' two-step.** The trigger is prompt-cache + invalidation; the MCP-server case is the common cause, not the only one. +- **Divergence count split** into run-caused versus pre-existing, so the report stops presenting + skew the sweep itself just created as discovered drift. +- **`sync` now reports when it updated `claude-ops` itself**, naming that the algorithm which ran is + the pre-update one. +- **TOCTOU wording matches the implementation**: the re-read boundary is the step, a loop body is + deliberately snapshot-driven, and the inert "outcome didn't match the snapshot" detector is + replaced with the one signal that is actually distinguishable. +- **`install_new: all` recurrence** and **unset-`userConfig` install notices** now have report slots + instead of living only in prose. +- **`setup`: the headless `--config` route no longer prescribes an uninstall/reinstall cycle.** + Rerunning the install writes the option against an already-installed plugin — it prints + `already installed` and still writes the value (verified on Claude Code 2.1.240, for a + non-sensitive option at `user` scope; a `sensitive` option and `project`/`local` scope were not + covered, and the wording says so). The old cycle was unnecessary and actively destructive: + uninstalling drops the whole stored `pluginConfigs` entry, resetting all fifteen options to + their manifest defaults (the previous text miscounted them as fourteen, and the + `*_audit_enabled` toggles as seven rather than eight). + + It also separates the two claims a reader conflates: the **write** lands, but the **running + session's** behavior does not change — `${user_config.*}` is injected at skill load and each + hook's `CLAUDE_PLUGIN_OPTION_*` comes from an environment fixed at session start, so a + same-session `check` still reports the OLD value and reading that as a failed write is wrong. + Verify in a fresh session. + + Wording matches the fleet-wide correction landed in #3115 verbatim apart from this plugin's own + option list, so the copies stay identical rather than drifting into a claude-ops variant. This + closes a live contradiction on `main`: #3115 regenerated this plugin's README with the corrected + guidance while `skills/setup/SKILL.md` still prescribed the destructive cycle, a gap that PR's + own reviewer flagged and could not fix because the file sits in this change set's fence. + +### Changed + +- The `versionsMatch` filter rule now has one origin (`context/scope-semantics.md`); `SKILL.md`, + `gotchas.md`, and `converge.md` point at it instead of restating it a fourth time. +- New gotchas: a `projectPath` outliving its directory, a spoke file never receiving + `${user_config.*}` substitution, and `sync` updating its own plugin mid-run. +- `SKILL.md`'s "index, not a substitute" rule now names its two deliberate exceptions and why they + must live in the hub. +- Version capture now instructs retaining the pre-sweep snapshot for the whole run — it is the sole + source of every ``. + +### Deferred (audit findings deliberately not closed in this release) + +- **`--run-log` written by `fleet-state.sh`** (audit remediation 19, F12's most ambitious tier). + The script's own header advertises it as read-only, and the auditor's correction notes the + tension. F12's cheapest tier — retain the pre-sweep snapshot — is implemented instead. A durable + log, if wanted, belongs in a sibling script that owns it rather than in the read-only inspector. +- **A fourth `install_new` value with declined-install memory** (remediation 20, F9's durable fix). + Needs persistent state the skill does not have today; the recurrence clause narrates the surprise + rather than removing it. +- **F14 (description trigger-phrase trim).** The repo's own `check-changed-skills.sh` gate enforces + trigger-keyword preservation against `HEAD` and fails a dropped phrase as an auto-invocation + regression. The finding is cosmetic (the skill sets `disable-model-invocation: true`, so the + phrases only serve as `/`-menu help) and is not worth fighting a validator for. +- **A `--selfcheck` that reports whether the rendered `install_new` value is the literal placeholder + token** (F8's most ambitious tier). F8 graded the *record*, not the claim — the defect was a stamp + with no recheck trigger, and that is fixed. Turning the prose stamp into a runtime observation is + a separate enhancement. +- **An upstream issue for the absent record-reaping verb** (remediation 21) — not a change to this + repository. + ## [0.35.4] ### Fixed diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md index bf220e98a..963b88ff5 100644 --- a/plugins/claude-ops/skills/plugins/SKILL.md +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -48,6 +48,13 @@ This table is an index, not a substitute: read the linked detail file before exe Each Description names the territory an action covers, never its algorithm — the steps, their ordering, and their failure handling live only in the linked file. +Two blocks below are deliberate exceptions to that index-only rule, and both have to live in the hub +rather than in a spoke. The **Report** template, because every action emits it. The +**`install_new` render**, because Claude Code substitutes `${user_config.*}` when it renders the +*skill*; a spoke opened later as a file read is plain bytes, so the same token in a spoke would +arrive as a literal placeholder with no error to warn anyone. See +[context/gotchas.md](context/gotchas.md). + | Action | Mutates | Description | Detail | |---|---|---|---| | `sync` (default) | Yes — CLI only | Marketplace, install, and enable-state maintenance for the effective fleet | [context/sync.md](context/sync.md) | @@ -84,10 +91,10 @@ whenever a step needs ids; never hand-write a `jq` extraction over the JSON, whi trailing `\r` on Windows and silently corrupts every id but the last (see [context/gotchas.md](context/gotchas.md)). -Read [context/scope-semantics.md](context/scope-semantics.md) before interpreting its output — in -particular, `divergences[].versionsMatch` separates a benign same-version multi-scope install -(normal, no action) from a real version skew (the actionable "run converge" signal); a raw -divergence-record count conflates the two and overstates the report. +Read [context/scope-semantics.md](context/scope-semantics.md) before interpreting its output. In +particular the `versionsMatch` filter rule — never count or present a raw `divergences[]` length — +is defined once there, under "Divergence is not automatically actionable"; every other mention in +this skill points at it rather than restating it. ## Action: audit @@ -100,6 +107,11 @@ contents (`installed_plugins.json`, `known_marketplaces.json`, committed setting an `audit` run, modulo any concurrent session or background `autoUpdate` sweep — note that caveat in the report rather than asserting byte-identical files. +Because `audit` issues no `marketplace update`, its Step 3 prediction is computed against an +**unrefreshed** catalog and is therefore a lower bound on what `sync` would update. Report it as one, +carrying the catalog's `lastUpdated` — see [context/sync.md](context/sync.md) Step 3. An `audit` +that predicts zero updates has not established that the fleet is current. + ## Report Terse, fixed sections. Detail only where action is required — do not enumerate rows that need no @@ -107,13 +119,81 @@ action. ```text Marketplace: (autoUpdate: ) + (repeat this line per marketplace in `all` mode — Steps 2–5 run once per marketplace) +In-repo: project/local install(s) updated in + | 0 — has no project/local installs + | skipped — no project context resolved from Updated: plugin(s) — @: (only when N > 0) Installed: new catalog plugin(s) — @ (only when N > 0; per install_new policy) -Divergences: project-scope install(s) behind user scope → run `/claude-ops:plugins converge` + (when the policy is `all`, append: policy install_new: all — these reinstall on every sync + unless you also disable them) +Divergences: actionable ( newly created by this run — by the in-repo update, by the + user-scope sweep, pre-existing) → run `/claude-ops:plugins converge` (N = actionable only — versionsMatch:false; same-version multi-scope installs are not counted or listed here) +Stale project records: record(s) across

path(s) not present on this machine + (omit section entirely when K = 0; never counted in Divergences — see below for the row shape) Action needed: (omit section entirely when empty) + enable gaps, CLI failures, unknown/orphaned plugins, user_scope_orphans, plugin(s) installed this + run with unset userConfig options> (omit section entirely when empty) +``` + +**The `In-repo:` row is fixed — it appears whether or not the step did anything.** Step 2 calls +itself the primary value path, so a run in which it did nothing has to say so in the default output, +not only when it succeeds. The three variants are not cosmetic: `skipped` and `0` answer genuinely +different questions ("there was no *here* to update" versus "here has nothing installed"), and +collapsing them is the whole defect this row exists to close. `fleet-state.sh`'s top-level +`project_root` is what distinguishes them — see [context/sync.md](context/sync.md) Step 2. + +Add a self-update row when Step 3's sweep updated `claude-ops` itself: + +```text +Note: this run updated claude-ops (). The algorithm that ran is the pre-update one — + ${CLAUDE_PLUGIN_ROOT} still resolves to the version loaded at session start. /reload-plugins + before relying on the new version. +``` + +(A plugin updated mid-session keeps resolving to the previous version's path — `plugins-reference`, +fetched 2026-08-22; observed on Claude Code 2.1.240. See +[context/gotchas.md](context/gotchas.md).) + +## Stale project records — reported, never converged, never reaped + +A project/local install record keeps its `projectPath` after that directory is gone. Ephemeral +checkouts make this ordinary rather than exceptional: a throwaway worktree can leave a record per +installed plugin behind, so one deleted directory can strand dozens of records at once. + +`fleet-state.sh` annotates every project/local record with `projectPathPresent` (see +[context/scope-semantics.md](context/scope-semantics.md)). Report these in their **own section**, and +observe three boundaries: + +- **Never counted in Divergences.** `converge`'s every project/local command is + `(cd "" && claude plugin …)`, because `-s project`/`-s local` have no path flag. A row + whose `projectPath` is absent cannot be `cd`'d into, so routing it to `converge` hands the user a + command guaranteed to fail. Folding these into the actionable count also inflates it with rows no + action can clear. They are a separate observation, not a divergence. +- **Never suppressed, and never called dead.** `projectPathPresent: false` means *not present on this + machine right now* — nothing more. An unmounted volume, an offline network share, an external drive + that is unplugged, and a deleted worktree are indistinguishable to a directory test. Filtering these + rows out would hide real drift from anyone whose repos live on removable or network storage. Say + "not present on this machine", never "dead" or "orphaned". +- **Never reaped by this skill.** No `claude plugin` verb removes an install record by path; + `prune` acts on auto-installed *dependencies* and its own `-s project` has the same + no-path-flag limitation (verified on Claude Code 2.1.240). Editing `installed_plugins.json` + directly is outside this skill's boundary — the same rule the rest of this skill follows. So this + section names the condition and stops. If the records came from a tool that owns those directories' + lifecycle, that tool is where they should be dropped at teardown; this skill does not reach into + another plugin's configuration to find out. + +Give the section a count plus the distinct paths, not one row per record — a hundred records naming +a dozen directories is a report about a dozen directories: + +```text +Stale project records: record(s) across

path(s) not present on this machine + - record(s) + (not counted as divergences: converge cannot cd into a path that is not present. A path can also + be absent because a volume is unmounted or a share is offline — this is an observation, not a + verdict that the directory is gone for good.) ``` A project-scope enable gap is a row `sync` deliberately does not fix — Step 5 enables automatically @@ -138,9 +218,14 @@ and `false` in another — are a known blind spot, not a reportable category: `f exposes the merged effective value, never each scope's raw map, so this skill cannot detect one to report it. See [context/converge.md](context/converge.md) "V1 scope".) -Close with reload guidance: recommend bare `/reload-plugins` by default; suggest `--force` only when -an updated/installed component ships an MCP server whose tools aren't deferred — that's the one case -`/reload-plugins` itself warns about and declines to apply without it (Claude Code ≥ 2.1.163; see +Close with reload guidance, stated as the docs' own two-step rather than as a prediction about which +case will trigger it: **recommend bare `/reload-plugins`; if it warns that the reload would +re-read the conversation, rerun it as `/reload-plugins --force`.** The general condition `--force` +exists for is prompt-cache invalidation — a plugin shipping an MCP server whose tools aren't deferred +is the common cause, not the only one, so do not present it as the sole trigger and do not tell the +user `--force` would be wrong when the bare command has already warned them. Never recommend +`--force` pre-emptively alongside every reload: it opts into a real token cost the bare command +declines to pay on its own (Claude Code ≥ 2.1.163; see [context/scope-semantics.md](context/scope-semantics.md)). If any updated component includes a monitor, call that out separately — monitors need a full session restart, `/reload-plugins` doesn't cover them. @@ -159,11 +244,21 @@ value in the report. **Configured value: `${user_config.install_new}`** — Claude Code text-substitutes a `userConfig` value into this skill's content before the model sees the rendered skill, but **only when the key is -explicitly set** in some `pluginConfigs` scope; declaring the option in `plugin.json` alone does not -make its value readable here. Crucially, the manifest's `"default": "ask"` is **not** substituted for +explicitly set** in user settings (`~/.claude/settings.json`), `--settings`, or managed settings — +precedence managed → `--settings` → user. It is **not** "some `pluginConfigs` scope": a project's +`.claude/settings.json` or `.claude/settings.local.json` entry is ignored, and setting `install_new` +there does nothing at all. Declaring the option in `plugin.json` alone does not make its value +readable here either. See [context/scope-semantics.md](context/scope-semantics.md) for the read path +and why it differs from `enabledPlugins`, which this same skill reads from project and local scope. + +Crucially, the manifest's `"default": "ask"` is **not** substituted for an unset key (verified 2026-07-23 against CC 2.1.218: an unset key leaves the placeholder token unchanged — the same shape as `${user_config.…}` — while a sibling `${CLAUDE_PLUGIN_ROOT}` substitutes -in the same render). So for the common default-config user — no `pluginConfigs` set anywhere — the +in the same render). **Recheck trigger:** re-verify on any Claude Code minor-version bump that +touches plugin `userConfig` substitution, or once `plugins-reference` gains text on unset-key +rendering — the docs are silent on it today, so this claim rests entirely on that one probe, and the +probe's CLI version has since moved (2.1.218 → 2.1.240) with the claim unre-tested. So for the +common default-config user — no `pluginConfigs` set anywhere — the **Configured value** line above still shows that literal placeholder token, not `ask`. Read that literal placeholder token as the **expected unset state → use the default `ask`**, and do NOT diff --git a/plugins/claude-ops/skills/plugins/context/converge.md b/plugins/claude-ops/skills/plugins/context/converge.md index bd1b7e221..ebf601070 100644 --- a/plugins/claude-ops/skills/plugins/context/converge.md +++ b/plugins/claude-ops/skills/plugins/context/converge.md @@ -16,8 +16,10 @@ treat it as autonomous and abort. Report why, and that `converge` can be re-run ## V1 scope: version divergence only -`converge` resolves entries in `fleet-state.sh`'s `divergences[]` with `versionsMatch: false` — -scopes disagree on version. It does **not** currently resolve, and cannot even detect, an +`converge` resolves entries in `fleet-state.sh`'s `divergences[]` with `versionsMatch: false` — the +filter rule is defined once in +[scope-semantics.md](scope-semantics.md#divergence-is-not-automatically-actionable); this action is +one of its consumers, not a second statement of it. It does **not** currently resolve, and cannot even detect, an enable-state mismatch (a plugin `true` in one scope's `enabledPlugins` and `false` in another) — that needs comparing each scope's *raw* `enabledPlugins` map, which `fleet-state.sh` doesn't expose today (only the merged effective value, in `enabled`). This is a genuine blind spot, not a deferred @@ -69,6 +71,28 @@ the other: each needs its own `cd`. Per [scope-semantics.md](scope-semantics.md) `projectPath` on the literal cwd while `fleet-state.sh` matches on the checkout root — that gap is a blind spot in its own right, recorded in [gotchas.md](gotchas.md). +### Precondition — never emit a `cd` command into a path that is not present + +Before emitting any `(cd "" && …)` command, check that row's `projectPathPresent` (see +[scope-semantics.md](scope-semantics.md)). When it is `false`, the `cd` fails and every command this +step could construct for that row is unrunnable — so emit the row as **blocked**, with the reason, +instead of as a runnable command: + +```text +- @ — BLOCKED: projectPath "" is not present on this machine. + converge cannot act on it: `-s project`/`-s local` have no path flag, so the command must cd into + that directory. Nothing here is safe to run. +``` + +Ephemeral checkouts make this a bulk condition rather than a curiosity — one removed worktree can +block every row that pointed into it. + +**A blocked row is not a resolved row, and `false` is not "dead".** Do not offer to "clean up" the +record: no `claude plugin` verb removes an install record by path (verified on Claude Code 2.1.240), +and hand-editing `installed_plugins.json` is outside this skill's boundary. Do not silently drop the +row either — the path may simply be an unmounted volume or an offline share, and a dropped row is +drift the user never learns about. Report it and move on. + Present every plugin's proposed strategy and exact CLI command(s) before running anything — do not batch-apply. Per Brief Decision 6 (V1): confirm **every** pin individually, even when many plugins share the same strategy — do not infer consent from one confirm to the next. @@ -82,8 +106,14 @@ choose the other strategy, or skip this one. ## Step 4 — Execute Run only the confirmed commands, one plugin at a time. Re-read `fleet-state.sh` state immediately -before each mutation (per `sync.md`'s concurrency note) — do not act on a snapshot taken during -Step 1 if meaningful time has passed or another mutation already landed. +before each mutation — do not act on a snapshot taken during Step 1 if meaningful time has passed or +another mutation already landed. + +`converge` genuinely is per-**mutation** here, where [sync.md](sync.md) draws its re-read boundary at +the **step**. That is a deliberate difference, not a contradiction: this action is confirm-gated and +low-volume (a handful of plugins, each with a human decision between them), so a re-read per mutation +costs nothing and the elapsed time between confirms is real. `sync`'s Step 3 loop issues dozens of +calls back to back with no pause, where the same discipline would buy only redundant work. ## Step 5 — Surface the resulting diff diff --git a/plugins/claude-ops/skills/plugins/context/gotchas.md b/plugins/claude-ops/skills/plugins/context/gotchas.md index 94548314a..b7ce1477d 100644 --- a/plugins/claude-ops/skills/plugins/context/gotchas.md +++ b/plugins/claude-ops/skills/plugins/context/gotchas.md @@ -4,6 +4,11 @@ Failure modes this skill is specifically built to avoid, and what breaks if the bypassed. Underlying facts are in [scope-semantics.md](scope-semantics.md) — this file is the "here's what goes wrong" companion, not a restatement. +Every claim here about Claude Code's or the `claude` CLI's own behaviour names the version it was +observed on. Where a section carries no version of its own, it was last checked against **Claude +Code 2.1.240**. **Recheck trigger:** any minor-version bump touching the plugin CLI, plugin +loading/caching, or `userConfig` substitution — a date alone is not a trigger. + ## `claude plugin update ` (bare) fails "Plugin not found" — always pass the full id **Verified empirically** (`claude plugin update -s user` → `Plugin "" not found`; @@ -84,10 +89,74 @@ evidence about either read. ## Dual-scope divergence is normal, not a defect A project pinning an older version at `project` scope while your personal `user` scope has moved on -is expected, common, and not itself something to "fix" silently. `fleet-state.sh`'s `versionsMatch` -field is what separates that benign case from a real, actionable version skew — see -scope-semantics.md. Never report a raw `divergences[].length` count; always filter to -`versionsMatch == false` first, or the report overstates drift with entries that need no action. +is expected, common, and not itself something to "fix" silently. The rule that separates that benign +case from an actionable one — filter on `versionsMatch == false`, never report a raw +`divergences[].length` — is defined in +[scope-semantics.md](scope-semantics.md#divergence-is-not-automatically-actionable). What goes +*wrong* when it is skipped is the point here: the report overstates drift with entries that need no +action, and routes the user to `converge` for rows it would decline to change. + +## A `projectPath` can outlive its directory + +A project/local install record keeps the `projectPath` it was created with. Delete the directory and +the record stays — nothing in the `claude plugin` CLI reaps it (verified on Claude Code 2.1.240: +`prune` is a *dependency* axis, and its own `-s project` has the same no-path-flag limitation that +makes these records unreachable in the first place). Ephemeral checkouts turn this from an edge case +into a bulk one: a throwaway worktree with a dozen project-scope installs strands a dozen records the +moment it is removed, and every one of them still shows up as installed state. + +What breaks: `converge` constructs every project/local command as +`(cd "" && claude plugin …)`, so a row naming an absent directory yields a command that +cannot execute. Routing such rows into the actionable Divergences count hands the user a list of +guaranteed failures. + +`fleet-state.sh` annotates each project/local record with `projectPathPresent` so the condition is +visible, and `SKILL.md` reports those rows in their own section, out of the Divergences count. + +**Do not turn that annotation into a filter, and do not call an absent path dead.** `[ -d ]` returns +false for an unmounted volume, an offline network share, and an unplugged external drive just as +readily as for a deleted worktree — and per +[scope-semantics.md](scope-semantics.md), two `git worktree` checkouts of one repo pin +independently, which makes worktree paths exactly the population most likely to look dead while +being perfectly recoverable. Suppressing a row on a directory test would hide real drift from anyone +whose repos do not live on a permanently-attached local disk. Annotate; never suppress. + +## A spoke file never receives `${user_config.*}` substitution + +Claude Code substitutes `userConfig` values when it renders the **skill**. A context file under +`context/` reaches the model as a later file read — plain bytes, no substitution pass. Write +`${user_config.install_new}` in a spoke and it arrives as that literal token, with **no error and no +warning**; the value simply never appears, and a step branching on it branches on a placeholder. + +This is why `SKILL.md` holds the `install_new` render and `sync.md` Step 4 branches on *that* line +rather than on its own prose. Verified empirically: `context/sync.md` on disk shows the raw +`${user_config.install_new}` token in the same session where `SKILL.md`'s render shows the +configured value. Nothing enforces this — a future spoke that inlines such a token fails silently, +so it is a review-time rule, not a checkable one. + +## `sync` updates the plugin that provides `sync` + +Step 3 sweeps every user-scope install, and `claude-ops` is one of them. When that update lands +mid-run, `${CLAUDE_PLUGIN_ROOT}` keeps resolving to the version loaded at session start, so every +remaining step — including every later `fleet-state.sh` call — executes the **pre-update** script +while the report describes a version the user now has installed but is not running. Per +`code.claude.com/docs/en/plugins-reference` (fetched 2026-08-22): "When a plugin updates +mid-session, hook commands, monitors, MCP servers, and LSP servers keep using the previous +version's path." Observed on **Claude Code 2.1.240**: a `sync` run's Step 3 moved the `claude-ops` +install record to 0.35.3, while the session went on rendering the 0.33.2 skill it had loaded at +session start — and every `fleet-state.sh` call for the rest of that run came from the 0.33.2 tree. + +Not a crash: per the same page, Claude Code "marks the previous version directory as orphaned and +removes it in a background sweep roughly 14 days later. The grace period lets concurrent Claude Code +sessions that already loaded the old version keep running without errors" — so the old script stays +readable to the end of the run. And the +*resolution* half is already handled — `fleet-state.sh`'s default-marketplace resolver carries a +version-agnostic fallback whose comment names this exact scenario, which is why the bare +(no `--marketplace`) path keeps working after the bump. Keep the two in step: if that fallback is +ever changed, this gotcha and the resolver comment both describe it. + +What is missing without a deliberate report row is any *statement* of it — see `SKILL.md`'s +self-update row. ## Internal-schema drift — fail loud, never guess diff --git a/plugins/claude-ops/skills/plugins/context/scope-semantics.md b/plugins/claude-ops/skills/plugins/context/scope-semantics.md index 99d0ba40d..1abd8ccd0 100644 --- a/plugins/claude-ops/skills/plugins/context/scope-semantics.md +++ b/plugins/claude-ops/skills/plugins/context/scope-semantics.md @@ -1,11 +1,18 @@ # Scope semantics — verified facts this skill depends on Every claim below was verified against a fetched official-docs page or an empirical test on a real -machine, not assumed from training data. Last re-verified 2026-07-18 against +machine, not assumed from training data. Last re-verified 2026-08-22 against [plugins-reference](https://code.claude.com/docs/en/plugins-reference), [discover-plugins](https://code.claude.com/docs/en/discover-plugins), the published plugin-manifest -JSON Schema, and the Claude Code changelog (version gates). Re-verify against those pages if Claude -Code's plugin CLI changes shape. +JSON Schema, and the Claude Code changelog (version gates), with CLI behaviour checked live on +**Claude Code 2.1.240**. + +**Recheck trigger** (a date alone is not one): re-verify this file on any Claude Code **minor** +version bump that touches the plugin CLI, `pluginConfigs`/`userConfig` substitution, or +`/reload-plugins` — those are the observable events that can invalidate what is below. Each claim +that rests on an empirical probe rather than on documentation names the CLI version it was taken +on, so a stamp older than the running CLI is the signal to re-run that probe, not to trust it +harder. ## Scope-by-cwd loading @@ -110,6 +117,32 @@ The two layers therefore disagree, which is a real blind spot — see [gotchas.md](gotchas.md). It also means two `git worktree` checkouts of one repo, sharing one `.git` and one tracked `.claude/settings.json`, hold independent records and pin independently. +## A `projectPath` outlives its directory, and no CLI verb reaps the record + +Removing the directory a project/local install was made from leaves the install record in place, +still naming the path. **Verified on Claude Code 2.1.240**: `claude plugin --help` lists no verb that +removes an install record by path, and `claude plugin prune --help` reports "Remove auto-installed +dependencies that are no longer needed" — a *dependency* axis, whose own `-s project` has the same +no-path-flag behaviour documented above, so it acts on the cwd and cannot reach a record belonging to +a directory that is gone. + +`fleet-state.sh` therefore annotates each project/local record (and each `divergences[].scopes[]` +entry) with `projectPathPresent: true|false|null` — a plain directory test, `null` where not +applicable. Read it precisely: + +- It answers **"is this path present on this machine right now"**, and nothing else. +- `false` is **not** a verdict that the directory is gone. An unmounted volume, an offline network + share, and unplugged removable media all produce `false`, and worktrees — which pin independently + per the section above — are exactly the population most likely to look absent while being + perfectly recoverable. +- It is **advisory**: it must never filter `installed[]` or `divergences[]`. Suppressing rows on a + directory test hides real drift from anyone whose repos are not on a permanently-attached disk. + +`sync` reports these rows in a section of their own, outside the actionable Divergences count, +because `converge`'s `(cd "" && …)` form cannot execute against an absent path. Naming +the condition is this skill's whole role here; reaping the record is not something it can or should +do. + ## `/reload-plugins` — bare by default, `--force` for the MCP-cache-invalidation case **Verified against `code.claude.com/docs/en/discover-plugins`**: `/reload-plugins` refreshes skills, @@ -134,13 +167,53 @@ when they say a plugin is already active, believe the summary rather than tellin again; and when the summary named the prompt-cache case, that is the same condition `--force` exists for below. -`--force` is real (Claude Code ≥ 2.1.163), but scoped to one specific case: a plugin that provides an -MCP server whose tools aren't deferred by tool search invalidates the prompt cache on reload, and -`/reload-plugins` warns and does **not** apply the reload rather than eating that cost silently; -`--force` applies it anyway. Only suggest `--force` when the updated/installed component in this -sync's report actually ships such an MCP server (or the report already surfaced that warning) — never -recommend it by default alongside every reload, since it exists specifically to opt into a real token -cost the bare command declines to pay automatically. +`--force` is real (Claude Code ≥ 2.1.163). **The general condition it exists for is prompt-cache +invalidation** — per `code.claude.com/docs/en/discover-plugins`: "When the reload would invalidate +the prompt cache, the command warns and skips until you rerun it with `--force`." + +The MCP case is the docs' worked example of that condition, not the condition itself: a plugin +providing an MCP server whose tools aren't deferred by tool search "costs more when its tools aren't +deferred by tool search", so it is **the common cause** of the warning — but treating it as the sole +trigger tells a reader that a warning arising any other way is not a `--force` case, when it is. + +So follow the docs' own two-step rather than predicting the cause: + +> Check the install summary: if it reports `Run /reload-plugins to activate.`, run `/reload-plugins`, +> and if that warns that the reload will re-read the conversation, rerun it as `/reload-plugins --force`. + +Never recommend `--force` pre-emptively alongside every reload — it exists specifically to opt into a +real token cost the bare command declines to pay automatically. Recommend bare; escalate on the +warning. + +## `pluginConfigs` and `enabledPlugins` have OPPOSITE scope rules + +This skill reads both surfaces, and they do not agree on which scopes count. Getting this backwards +is silent in both directions, so the asymmetry is stated here once and pointed at from everywhere +else. + +**`pluginConfigs` — three sources only.** Per `code.claude.com/docs/en/plugins-reference`: "Claude +Code reads all `pluginConfigs` values from only three settings sources" — user settings +(`~/.claude/settings.json`), `--settings`, and managed settings, with precedence +managed → `--settings` → user. And explicitly: + +> Entries in a project's `.claude/settings.json` or `.claude/settings.local.json` are ignored. Both +> files live in the workspace, so a cloned repository could supply values there, and those values +> would flow into plugin hook commands, MCP server configs, LSP commands, and monitor commands. +> Before v2.1.207, these entries were read. The restriction is specific to `pluginConfigs`: +> `enabledPlugins` still honors project and local settings. + +**`enabledPlugins` — user, project, and local all count**, merged local > project > user. That is +why `fleet-state.sh` reads all three settings maps for enablement, and why doing the same for +`pluginConfigs` would be wrong. + +Two consequences this skill must not get wrong: + +- Setting `install_new` in a repo's `.claude/settings.json` does **nothing**. The value is ignored, + the render falls back to the unset placeholder, and `sync` proceeds under the `ask` default with no + indication the configured value was discarded. Never advise setting it at project or local scope. +- A `--setting-sources` invocation that omits `user` drops user settings from that three-source read + list, so a headless `sync` launched that way silently loses `install_new` the same way. See + [sync.md](sync.md) Step 4 — the fallback is correct, the silence is not. ## `userConfig` has no `enum` field diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index a0769150c..e500fbcc8 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -8,16 +8,37 @@ by a prediction (see SKILL.md's "Action: audit"). ## Concurrency The `claude plugin` CLI is the serialization point — there is no separate lock this skill manages. -Re-read state (re-run `fleet-state.sh`) immediately before each mutating step rather than mutating -off a snapshot taken several steps ago; a background `autoUpdate` sweep or a concurrent session can -change installed/enabled state between steps. Note in the report when a mutation's outcome doesn't -match what the pre-mutation snapshot predicted — that's this race, not a bug. + +**The re-read boundary is the STEP, not the individual mutation.** Re-run `fleet-state.sh` +immediately before each mutating step rather than mutating off a snapshot taken several steps ago; a +background `autoUpdate` sweep or a concurrent session can change installed/enabled state between +steps. Inside a step, the loop body is deliberately snapshot-driven: Step 3 reads its id list once +and then issues one `claude plugin update` per line. That is the intended design, not a violation of +the rule above. Re-reading state before each of sixty-odd calls would buy nothing — the CLI is the +serialization point, so the worst outcome of losing the race on any single id is that the id was +already updated by whoever won it, and the call degrades to a no-op. + +Detecting that race is not something this step can do from CLI output, so do not pretend to. An +id reported "already at the latest version" is *equally* consistent with a benign no-op and with a +concurrent sweep having just updated it; the two are indistinguishable, and a report row that claims +to tell them apart would be inventing a signal. The one outcome that **is** distinguishable, and +worth a report row under "Action needed", is an id present in the pre-mutation snapshot that the CLI +then reports as **not installed** — that is a genuine concurrent uninstall, not this benign race. ## Version capture for the report SKILL.md's report requires `@: ` for every updated plugin, so both -values have to be collected while the sweep runs — neither can be reconstructed afterward. Three -sources, in precedence order, and **never** a synthesized value: +values have to be collected while the sweep runs — neither can be reconstructed afterward. + +**Retain the pre-sweep `fleet-state.sh` output for the whole run.** It is the sole source of every +``, and once Step 2/3 have run there is nothing left on the machine that still holds those +values — the pre-update versions are gone. Keep that snapshot (and each `claude plugin update` line +as it is emitted) available through Step 6 rather than assuming it can be recovered; a sweep of +several dozen mutations whose report depends on the `` pairs is otherwise one context +compaction away from being unable to emit its own report. This skill provides no durable log for +that today — see "Deferred" in the plugin's CHANGELOG for why the script does not write one. + +Three sources, in precedence order, and **never** a synthesized value: 1. **``** — that id's `installed[].version` from the pre-mutation `fleet-state.sh` re-read the section above already requires. It is the pre-update value by construction. @@ -40,6 +61,42 @@ source 3 agreed with source 2 on every id. That establishes the write landed bef that run — not that it is synchronous per call, and not that it holds on another version. Keep source 2 primary and keep the divergence handling above. +## Marketplace scoping — Steps 2–5 are the per-marketplace loop body + +**Every `fleet-state.sh` call in Steps 2–5 carries `--marketplace "$mp"`, and in `all` mode the whole +of Steps 2–5 is the loop body, run once per marketplace.** Without this, `all` mode refreshes every +marketplace in Step 1 and then performs install, update, enable, and divergence maintenance against +exactly **one** of them — the resolved default — while emitting a report that names no coverage +boundary. That is a silent partial sweep: the plugins of every other marketplace are neither updated +nor reported as skipped. + +Get the names from `fleet-state.sh --marketplaces`, never from a hand-written `jq` over +`known_marketplaces.json` — enumerating names has exactly the trailing-`\r` hazard that enumerating +ids does, and for the same reason: + +```bash +while IFS= read -r mp; do + [[ -n "$mp" ]] || continue + # Steps 2–5 for "$mp" +done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplaces) +``` + +The bare (no `--marketplace`) form is not a fleet-wide form; it resolves the default marketplace and +scopes to it. Nor can the sweep be widened by combining flags — the script refuses that composition +outright and names the fix in its own error text: + +```text +$ fleet-state.sh --all --ids installed-user +ERROR: --ids cannot be combined with --all + Run --ids once per marketplace with --marketplace . +``` + +(Verified on Claude Code 2.1.240.) `--all` exists for the JSON report, which nests one block per +marketplace; `--ids` projects a single block, so it takes one marketplace at a time. Loop it. + +The per-marketplace failure rule from Step 1 carries through: a marketplace whose iteration fails is +reported inline and never aborts the loop for the rest. + ## Step 1 — Marketplace refresh For each target marketplace (the resolved default, the named one, or every marketplace when the @@ -62,17 +119,34 @@ marketplace's failure is attributable and reported inline without aborting the s **On a non-zero exit — every mode, including single/default.** Not fatal, and never silently absorbed: report the marketplace and the CLI's own error text inline under "Action needed" and -continue to Step 2. Steps 2–3 operate on installed state, which a failed marketplace refresh -leaves untouched. Steps 4–5 do NOT: Step 4 derives installations from the catalog -(`missing_from_user_install`) and Step 5 consults catalog metadata (`defaultEnabled`), so running -them against a stale catalog can install a since-removed plugin or enable one the publisher has -since made opt-in-only. For a marketplace whose refresh failed, **skip Steps 4–5** and list what -they would have done under "Action needed" as deferred until a sync run where the refresh -succeeds. Say so in the report (`Marketplace: — refresh failed, catalog may be stale; -install/enable maintenance deferred`) rather than claiming it is current. Do not delete, rename, -or re-clone the marketplace directory to work around it — that is cache surgery this skill does -not do. To learn how stale the catalog actually is, compare `git -C rev-parse -HEAD` against `git ls-remote origin HEAD` run in that directory — `ls-remote` queries the remote +continue to Step 2. + +Which later steps a stale catalog compromises, and how each one degrades: + +- **Step 2 is unaffected.** It operates purely on installed state, which a failed refresh leaves + untouched, and it is deliberately not catalog-pre-filtered (see Step 2). +- **Step 3 still runs, but WITHOUT its pre-filter.** Its sweep is installed-state-driven, so the + updates themselves are safe — but the `catalog_versions` pre-filter reads the marketplace + *checkout*, and a checkout that failed to refresh may be behind the real catalog. An id whose + installed version matches the **stale** catalog version would then be withheld from the sweep as + "already current" when a newer version exists upstream — a silently skipped update, which is + exactly the class of failure this skill exists to prevent. So for a marketplace whose Step 1 + refresh failed, Step 3 falls back to `--ids installed-user` and sweeps every user-scope id + unconditionally. Correctness over speed: the pre-filter is an optimization, and an optimization + keyed on data known to be stale is not one. +- **Steps 4–5 are skipped entirely.** Step 4 derives installations from the catalog + (`missing_from_user_install`) and Step 5 consults catalog metadata (`defaultEnabled`), so running + them against a stale catalog can install a since-removed plugin or enable one the publisher has + since made opt-in-only. For a marketplace whose refresh failed, **skip Steps 4–5** and list what + they would have done under "Action needed" as deferred until a sync run where the refresh + succeeds. + +Say so in the report — `Marketplace: — refresh failed, catalog may be stale; update sweep +ran unfiltered; install/enable maintenance deferred` — rather than claiming it is current. Do not +delete, rename, or re-clone the marketplace directory to work around it — that is cache surgery +this skill does not do. To learn how stale the catalog actually is, compare +`git -C rev-parse HEAD` against `git ls-remote origin HEAD` run in that +directory — `ls-remote` queries the remote without writing `FETCH_HEAD`, remote-tracking refs, or objects, all three of which a plain `git fetch` writes (mutations of the marketplace's internal clone, outside this skill's boundary). @@ -85,7 +159,28 @@ excluded; see [gotchas.md](gotchas.md)), so a headless session where the env var correctly compute `currentProject`; gating on the raw env var directly would skip this step in exactly the case that fallback exists for. -Look at `installed[]` entries with `currentProject: true` and run an update for **every one of +**Before looping, branch on the report's top-level `project_root` — this step must never skip +silently.** It is the primary value path; a run where it did nothing has to say so, and until it +does, "no project context at all" and "a project with no in-repo installs" produce an identical +report. They are categorically different answers and the user cannot tell them apart: + +- **`project_root` is `null`** — no project root resolved (a run from `$HOME`, or from a non-git + directory with no `.claude` of its own). Nothing in-repo can be updated because there is no + "here". Emit the skipped `In-repo:` row from SKILL.md's Report section, naming the cwd, and go to + Step 3. Do **not** report this as "0 updated". +- **`project_root` is a path and no record carries `currentProject: true`** — a project resolved and + it simply has no project/local-scope installs. Emit the `In-repo:` row as `0` **for that root**, + which is an honest zero rather than an absent step. +- **`project_root` is a path and records carry `currentProject: true`** — the success path below. + +Reading `project_root` costs nothing extra: this step already calls `fleet-state.sh` above, and the +field is in the JSON it returned. Do not try to recover the distinction from `--ids current-project` +alone — that selector emits nothing in both of the first two cases, which is exactly why the step +used to no-op invisibly. And do not infer it from `currentProject` per record either: that flag is a +tri-state whose `null` covers user-scope records, records with no `projectPath`, *and* the +no-project-context case all at once. + +Then look at `installed[]` entries with `currentProject: true` and run an update for **every one of them**, unconditionally: ```bash @@ -101,7 +196,7 @@ Each line is `\t`, so the `-s` flag comes off the same line as the id while IFS=$'\t' read -r id scope; do [[ -n "$id" ]] || continue claude plugin update "$id" -s "$scope" -done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --ids current-project) +done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" --ids current-project) ``` The scope rides on the record for a reason: one plugin can hold **both** a `project`- and a @@ -114,17 +209,46 @@ Do **not** pre-filter on `divergences[]`. `divergences[]` only contains ids with scope record — a project/local install with no other scope pinning the same id (the common single- pin case) never appears there at all, and neither does a multi-scope install where every scope happens to already share the same stale version (`versionsMatch: true` — still behind the catalog, -just not internally disagreeing). Both are real staleness `fleet-state.sh` cannot detect from its own -output (it has no per-plugin catalog version to compare against), so the only correct signal is -"is this entry present" — mirror Step 3's own pattern and just call `update`, letting the CLI report -"already at the latest version" as a no-op when nothing changes. Verified safe: `plugin update +just not internally disagreeing). Both are real staleness `divergences[]` cannot express, so the only +correct signal here is "is this entry present" — just call `update`, letting the CLI report +"already at the latest version" as a no-op when nothing changes. + +Deliberately **not** pre-filtered on `catalog_versions` the way Step 3's sweep is, even though the +field is now available for these ids too. The in-repo population is small (a handful of records, +against Step 3's dozens), so the saving is negligible, while a project/local pin is far more likely +than a user-scope install to sit at a version the catalog does not carry — a deliberate pin, or a +local build. Paying one redundant no-op call per in-repo record buys the primary value path a +signal that does not depend on the catalog resolving at all. Verified safe: `plugin update -s project` does not write the committed `.claude/settings.json` (see [scope-semantics.md](scope-semantics.md)) — no settings-diff review needed for this step, unlike `converge`. ## Step 3 — User-scope update sweep -For every catalog plugin id currently installed at `user` scope, run: +Partially catalog-dependent: the sweep itself is installed-state-driven and always runs, but its +pre-filter reads the marketplace checkout. Two cases where that checkout cannot be trusted to prove +an id current, and what each does: + +- **Step 1's refresh failed for this marketplace** — use `--ids installed-user` instead of + `--ids update-candidates-user`, and sweep unconditionally. See Step 1. +- **`audit` mode** — `audit` issues zero mutating calls, so Step 1 never runs and the catalog is + simply however stale it already was, by an unbounded amount. The pre-filter still runs (predicting + the real algorithm is the point of a dry run), but its output is a **lower bound**: a real `sync` + refreshes first and may find more to update. Say so, and quantify the uncertainty with the + catalog's own age rather than leaving it implicit — `fleet-state.sh` reports + `marketplace.lastUpdated`: + + ```text + Would update: plugin(s) (lower bound — predicted against a catalog last refreshed + , which `audit` does not refresh; `sync` refreshes first and may find more) + ``` + + Never present an `audit` prediction of zero as "the fleet is current" — it means "nothing is + behind the catalog as it stands on disk", which is a different claim. + +Everything else in this step is unchanged. + +Update the catalog plugins installed at `user` scope: ```bash claude plugin update -s user @@ -134,15 +258,45 @@ One call per plugin — `claude plugin update` takes a single `` argumen "update everything" flag. Loop it; a single plugin's update failure is reported inline (under "Action needed") and does not abort the sweep for the rest. -Take the ids from `fleet-state.sh --ids`, never from a hand-written `jq` over its JSON: +Take the ids from `fleet-state.sh --ids`, never from a hand-written `jq` over its JSON, and use the +**`update-candidates-user`** selector rather than `installed-user`: ```bash while IFS= read -r id; do [[ -n "$id" ]] || continue claude plugin update "$id" -s user -done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --ids installed-user) +done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" --ids update-candidates-user) ``` +### Why the pre-filter, and why it can only ever be a candidate list + +Each plugin's version lives in its own manifest inside the marketplace checkout +(`//.claude-plugin/plugin.json`), even though the `marketplace.json` +entry itself carries no version. `fleet-state.sh` reads those manifests into `catalog_versions` with +no network call and no `claude plugin` invocation, and `update-candidates-user` withholds only the +ids it positively proved already sit at the catalog version. On an already-current fleet that turns +the whole sweep into zero `claude plugin update` calls instead of one per user-scope install. + +It is not free, just far cheaper than what it replaces: the read costs one local file parse per +catalog entry (plus one path resolution per entry that actually resolves), against `claude plugin +update` process launches it removes. Local file reads, no network, no CLI. + +**Correctness dominates the saving, so the selector fails open by construction.** An id whose +catalog version cannot be read — the entry's `source` is a remote spec rather than a repo-relative +path, the checkout never materialized that directory, the manifest carries no `version`, the JSON +does not parse — is emitted as a candidate, exactly as if no pre-filter existed. That is not a rare +branch: across the marketplaces registered on the authoring machine (Claude Code 2.1.240) the +version resolved for every entry of some and for a small minority of others', so a marketplace where +the pre-filter withholds nothing at all is an ordinary outcome, not a malfunction. Read a shrunken +sweep as a bonus, never as evidence that the ids it skipped were checked. + +`installed-user` remains available and unchanged for a caller that deliberately wants every +user-scope id. Do not reach for it here to "be safe" when the catalog is trustworthy — +`update-candidates-user` is already a superset of what needs updating. Reach for it in the one case +where the catalog itself is suspect: **a marketplace whose Step 1 refresh failed.** The pre-filter's +guarantee is "this id matches the version in the local checkout"; that is only a statement about +staleness when the checkout is current. + `--ids` emits the fully-qualified `@` form, one per line, CR-free — a bare name fails with "Plugin not found" even when unambiguous, and on Windows a hand-written `jq -r ... | while read` silently appends a `\r` to every id but the last, which fails with the @@ -153,8 +307,8 @@ fails with "Plugin not found" even when unambiguous, and on Windows a hand-writt Catalog-dependent: skipped (deferred) for a marketplace whose Step 1 refresh failed — see Step 1. -Take `fleet-state.sh`'s `missing_from_user_install` (`--ids missing-user-install` emits the id list -directly — see Step 3) — catalog ids not installed at `user` scope +Take `fleet-state.sh`'s `missing_from_user_install` (`--marketplace "$mp" --ids missing-user-install` +emits the id list directly — see Step 3) — catalog ids not installed at `user` scope (already excludes anything explicitly opted out with `enabledPlugins: false` in any scope — never re-offer a deliberate decline). This is deliberately user-scope, not the all-scope `missing_from_install`: a plugin installed only at `project`/`local` scope is absent from `missing_from_install` yet still not @@ -168,6 +322,13 @@ step's prose, is what to branch on: - **`all`** — `claude plugin install -s user` for every entry, no prompt - **`none`** — install nothing; list the entries under "Action needed" in the report only +**A headless run launched with `--setting-sources` that omits `user` silently reverts this policy to +`ask`.** `pluginConfigs` is read from user settings, `--settings`, and managed settings only (see +[scope-semantics.md](scope-semantics.md)), so dropping `user` from the source list drops the +configured `install_new` with it — and the render falls back to the unset placeholder, which this +step correctly reads as `ask`. That is the right fallback and the wrong silence: say so in the +report rather than letting a policy the user set appear to have been honored. + **Caveat (document, don't silently absorb):** with `install_new: all`, a catalog plugin that's installed at `user` scope and then *disabled* (not uninstalled — `enabledPlugins: false` still recorded, install record still present) is correctly excluded (it's not in `missing_from_user_install`, @@ -176,13 +337,23 @@ it's an installed, opted-out plugin). But a plugin that's *uninstalled entirely* `install_new: all` has no memory of "I removed this on purpose." If that's not the intent, uninstall AND disable (`enabledPlugins: false`), or switch the policy to `ask`/`none`. +**Say that in the report, at the moment it fires.** When the policy is `all` and this step installed +anything, the `Installed:` row carries the recurrence clause from SKILL.md's Report section. A +caveat documented only here is invisible to the person reading the report, who is exactly the person +about to be surprised by it on the next run. Do not leave it to inference. + +**Capture each install's own CLI output, don't discard it.** An install can report that the plugin +declares `userConfig` options left unset, along with its own suggested remedy. That line is +per-install information this step is the only one positioned to see, and it belongs in the report's +"Action needed" list rather than in the scrollback — see SKILL.md's Report section for the slot. + ## Step 5 — `enabledPlugins` completeness Catalog-dependent (`defaultEnabled` comes from catalog metadata): skipped (deferred) for a marketplace whose Step 1 refresh failed — see Step 1. -Take `fleet-state.sh`'s `missing_from_enabled` (`--ids missing-enabled` emits the id list directly — -see Step 3) — ids installed somewhere but never mentioned (true +Take `fleet-state.sh`'s `missing_from_enabled` (`--marketplace "$mp" --ids missing-enabled` emits the +id list directly — see Step 3) — ids installed somewhere but never mentioned (true or false) in any scope's `enabledPlugins`, already excluding ids the marketplace ships with `defaultEnabled: false`. That field is a publisher's deliberate opt-in-required default (it takes precedence over the plugin's own `plugin.json` field — see @@ -248,8 +419,40 @@ recorded either way. ## Step 6 — Report Emit the report per SKILL.md's "Report" section, filling each updated plugin's `` from -the sources the "Version capture for the report" section above fixes. End with reload guidance: bare `/reload-plugins` by -default; suggest `--force` only when an updated/installed component ships an MCP server whose tools -aren't deferred (see [scope-semantics.md](scope-semantics.md) — `--force` exists to opt into a real -token cost, not a blanket recommendation). Call out a session restart separately only when an updated -component ships a monitor (monitors aren't covered by `/reload-plugins`). +the sources the "Version capture for the report" section above fixes. + +**Split the Divergences count into pre-existing and run-caused.** A user-scope sweep that moves user +scope ahead of untouched project records *manufactures* actionable divergences — the run's own +correct consequence, not drift it discovered. Reporting the total as a single discovered number +routes the user to `converge` for skew this run just created. + +**Attribute it to the right step — that needs THREE snapshots, not two.** Steps 2 and 3 both mutate +versions, so a single pre-Step-2 / post-Step-3 bracket cannot tell which one created a new +divergence, and labelling the whole delta "the user-scope sweep" is wrong whenever Step 2 caused it. +Concretely: equal project and user records at `v1`, Step 2 updates the project record to `v2`, Step 3's +user update fails — the skew is Step 2's, and a two-snapshot diff blames Step 3. Take the +`divergences[]` read from each of the three `fleet-state.sh` calls the algorithm already makes — the +pre-Step-2 snapshot, the pre-Step-3 re-read the concurrency rule requires anyway, and the post-sweep +re-read — and attribute each new row to the interval it first appeared in. No extra call is needed; +this is bookkeeping over reads that already happen. + +Report as +` actionable ( newly created by this run — by the in-repo update, by the user-scope +sweep, pre-existing)`. When the two intervals genuinely cannot be separated (a snapshot was +missed), say ` newly created by this run` without splitting it, rather than assigning the whole +delta to one step. + +**Say when the sweep updated `claude-ops` itself.** Step 3 sweeps every user-scope id, which +necessarily includes the plugin providing this skill. When it does, the algorithm that ran is the +**pre-update** one: `${CLAUDE_PLUGIN_ROOT}` keeps resolving to the version loaded at session start, +so every later `fleet-state.sh` call and every remaining step executes the old copy, and the report +describes work done by a version the user no longer has installed. Current docs, `plugins-reference` +(fetched 2026-08-22): "When a plugin updates mid-session, hook commands, monitors, MCP servers, and +LSP servers keep using the previous version's path." This is not a crash risk — the previous version +directory is retained on a grace period, so the running script does not vanish mid-run — it is a +reporting obligation. Emit SKILL.md's self-update row. + +End with reload guidance per SKILL.md's Report section: recommend bare `/reload-plugins`, and state +the recovery step rather than pre-judging which case will trigger it. Call out a session restart +separately only when an updated component ships a monitor (monitors aren't covered by +`/reload-plugins`). diff --git a/plugins/claude-ops/skills/plugins/evals/evals.json b/plugins/claude-ops/skills/plugins/evals/evals.json index abb0d2a80..bd345a9a8 100644 --- a/plugins/claude-ops/skills/plugins/evals/evals.json +++ b/plugins/claude-ops/skills/plugins/evals/evals.json @@ -73,6 +73,72 @@ "The response does not delete or otherwise manipulate a plugin's cache version directory", "The response redirects to the appropriate `claude plugin` CLI command (or the converge action) instead of manual state surgery" ] + }, + { + "id": 7, + "name": "sync-reports-skipped-in-repo-step", + "prompt": "/claude-ops:plugins sync — run it from my home directory, which is not a project. fleet-state.sh reports project_root: null and all 111 installed[] records carry currentProject: null.", + "expected_output": "Step 2 is the self-described primary value path, so a run where it did nothing must say so. With project_root null, the report carries the In-repo row in its 'skipped — no project context resolved' form, naming the cwd. It is NOT reported as '0 updated', and the step is not omitted from the report.", + "files": [], + "expectations": [ + "The report contains an In-repo row stating the in-repo step was skipped because no project context resolved, rather than omitting the step entirely", + "The response distinguishes 'no project context resolved' from 'a project with zero project/local installs' — it does not report the skip as a count of 0 updated", + "The response branches on fleet-state.sh's top-level project_root field, not on `--ids current-project` being empty, which is empty in both cases", + "No `claude plugin update -s project` or `-s local` call is issued" + ] + }, + { + "id": 8, + "name": "sync-all-covers-every-marketplace", + "prompt": "/claude-ops:plugins sync all — I have 9 marketplaces registered and 72 plugins installed at user scope across all of them.", + "expected_output": "In `all` mode Steps 2-5 are the per-marketplace loop body, not a single pass against the default marketplace. Every fleet-state.sh call in those steps carries --marketplace , and the report makes per-marketplace coverage visible rather than emitting one Marketplace line while install/update/enable maintenance touched only one catalog.", + "files": [], + "expectations": [ + "Steps 2-5 run once per marketplace, each fleet-state.sh call carrying an explicit --marketplace argument", + "The response does not issue a bare `--ids` call (no --marketplace) and treat its result as covering every marketplace", + "The response does not attempt `--all` combined with `--ids`, which the script rejects", + "The report distinguishes per-marketplace coverage rather than implying a single sweep covered all 9" + ] + }, + { + "id": 9, + "name": "dead-project-path-reported-not-converged", + "prompt": "/claude-ops:plugins sync — fleet-state.sh reports 38 actionable divergences whose project-scope records all carry projectPath D:\\worktrees\\scratch-run with projectPathPresent: false, because that worktree was deleted.", + "expected_output": "Rows whose projectPath is not present on this machine are reported in their own section, kept out of the actionable Divergences count, and never routed to converge — converge's every project/local command must cd into that path, so those commands cannot execute. The absence is reported as an observation ('not present on this machine'), never as a verdict that the directory is permanently gone, and the skill neither reaps the records nor edits installed_plugins.json.", + "files": [], + "expectations": [ + "The stale/not-present project records are reported in a section separate from the Divergences count, not folded into the actionable count", + "The response does not tell the user to run `/claude-ops:plugins converge` to resolve rows whose projectPath is absent", + "The response does not edit or propose editing installed_plugins.json, and does not claim a `claude plugin` verb exists that reaps an install record by path", + "The response describes the path as not present on this machine rather than asserting the directory is dead, acknowledging an unmounted volume or offline share produces the same signal", + "The absent-path rows are still shown — they are annotated, never filtered out of the report" + ] + }, + { + "id": 10, + "name": "update-sweep-prefilter-fails-open", + "prompt": "/claude-ops:plugins sync — for this marketplace fleet-state.sh's catalog_versions is null for every id because the marketplace entries carry object-valued sources, and 40 plugins are installed at user scope.", + "expected_output": "The Step 3 pre-filter may only withhold an id it positively proved is already at the catalog version. With every catalog version null, nothing is proved, so the sweep is not narrowed at all — all 40 user-scope ids get a `claude plugin update` call, exactly as if no pre-filter existed. A shrunken sweep is never treated as evidence that skipped ids were checked.", + "files": [], + "expectations": [ + "All 40 user-scope ids are swept — an unreadable catalog version results in the id being updated, never skipped", + "The response does not treat a null catalog version as 'already current' or as 'no update needed'", + "The ids come from `fleet-state.sh --ids`, never from a hand-written jq extraction over the JSON", + "The response does not report the fleet as verified-current on the basis of the pre-filter withholding ids" + ] + }, + { + "id": 11, + "name": "version-capture-never-reports-old-to-old", + "prompt": "/claude-ops:plugins sync — during the sweep `claude plugin update alpha@market1 -s user` printed that it updated the plugin, but the post-sweep fleet-state.sh re-read still shows alpha@market1 at its pre-update version.", + "expected_output": "The CLI's own reported value is source 2 and outranks the post-sweep re-read (source 3), which can lag if the state write is deferred. So the row reports the CLI's value, or when the CLI named no version — never ``, and the id is never counted as not-updated. A row admitting it cannot tell is better than one asserting nothing changed for a plugin that did.", + "files": [], + "expectations": [ + "The report does not render the row as ``", + "The plugin is still counted as updated, not as unchanged", + "The response uses the CLI's own reported version, or explicitly ``, rather than synthesizing a version from the post-sweep re-read", + "The response does not treat the stale post-sweep read as evidence the update failed" + ] } ] } diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index 65cc82530..b5fbd886d 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -12,6 +12,7 @@ # Usage: # fleet-state.sh [--marketplace | --all] # fleet-state.sh [--marketplace ] --ids +# fleet-state.sh --marketplaces # # With neither flag, resolves the default marketplace dynamically: the one # this plugin (CLAUDE_PLUGIN_ROOT) was itself installed from. `--all` sweeps @@ -19,9 +20,9 @@ # reported inline and does not abort the sweep. # # Output (stdout): one JSON object. -# Single marketplace: {marketplace, catalog, installed, enabled, -# missing_from_install, missing_from_user_install, missing_from_enabled, -# divergences} +# Single marketplace: {marketplace, project_root, catalog, catalog_versions, +# installed, enabled, missing_from_install, missing_from_user_install, +# missing_from_enabled, user_scope_orphans, divergences} # — or {marketplace: {name, error}} on a resolvable per-marketplace failure. # missing_from_install is all-scope (catalog minus installed anywhere); # missing_from_user_install is user-scope only (catalog minus user-scope @@ -29,6 +30,39 @@ # any directory. Both exclude ids explicitly opted out (false) in any scope. # --all: {marketplaces: {"": , ...}} # +# project_root the normalized project root this run resolved, or null +# when none did. Consumers need the DISTINCTION, not just +# the per-record currentProject flag: currentProject is a +# tri-state (true / false / null) and null collapses +# "no project context resolved at all" together with +# "this is a user-scope record", so a run from $HOME and a +# run inside a repo with no in-repo installs are otherwise +# indistinguishable downstream. `sync` Step 2 branches on +# this to report a skipped in-repo step instead of +# silently no-opping. +# catalog_versions {"": ""|null} — each catalog entry's +# version read from its own manifest in the marketplace +# checkout. null means UNKNOWN, never "no update needed"; +# see the fail-open contract at the computation below. +# user_scope_orphans ids holding at least one project/local install record +# and NO user-scope record. Structurally absent from +# divergences[] (which only groups ids with more than one +# record) and from missing_from_user_install (they ARE +# installed, just not at user scope), so without this +# field nothing in the output names them at all. +# Excludes ids explicitly opted out (false) in any scope, +# like the two missing_* arrays — a deliberate decline is +# not a gap to report as action needed. +# +# Each project/local record in installed[] (and each divergences[].scopes[] +# entry) additionally carries projectPathPresent: true|false|null — whether +# that record's projectPath is a directory on this machine RIGHT NOW. It is +# advisory and must never filter installed[] or divergences[]: a false is +# equally consistent with a deleted worktree and with an unmounted volume, +# a disconnected network share, or removable media that is simply not +# attached. null means not applicable (a user-scope record, or no +# projectPath). +# # Output (stdout) with --ids : NOT JSON — one record per line, in the # order the block carries them, and nothing else. Fields are TAB-separated and # the first field is always the fully-qualified `@` id, so @@ -36,6 +70,14 @@ # the `sync` step that consumes it: # installed-user installed[] at user scope (Step 3 update) # fields: id +# update-candidates-user installed[] at user scope whose (Step 3 update) +# catalog version is UNKNOWN or differs from the +# installed one — a SUPERSET of what actually needs +# updating, never an authoritative "these are stale" +# list. Prefer it over installed-user for the Step 3 +# sweep; it degrades to exactly installed-user when +# no catalog version resolves. +# fields: id # current-project installed[] with currentProject (Step 2 update) # fields: id, scope — scope is carried because one # plugin can hold both a project- and a local-scope @@ -45,8 +87,19 @@ # fields: id # missing-enabled missing_from_enabled[] (Step 5 enable) # fields: id +# user-scope-orphans user_scope_orphans[] (report only) +# fields: id # Zero matches is success with empty output (exit 0), not an error. Reject: # `--ids` with `--all` (no single block to project), or an unknown selector. +# +# Output (stdout) with --marketplaces: NOT JSON — every marketplace name from +# known_marketplaces.json, one per line, nothing else, CR-free by the same +# wrapper as --ids. Empty output for an empty object is success (exit 0). +# Standalone: combining it with --marketplace, --all, or --ids is a usage +# error (exit 2). Exists so `sync`'s `all` mode can iterate +# `--marketplace --ids ` per name without hand-writing its +# own `jq -r 'keys[]' | while read`, which is the same Windows-CRLF +# corruption the --ids contract exists to prevent. # A per-marketplace failure block goes to STDERR in this mode (never stdout), # so stdout carries records or nothing — a `< <(… --ids …)` consumer cannot # see the process's exit status and would otherwise read the error JSON as an @@ -69,7 +122,18 @@ # FLEET_STATE_USER_SETTINGS — path to the user-scope settings.json # FLEET_STATE_CATALOG_DIR — dir of .json catalog # fixtures, read instead of each -# marketplace's installLocation clone +# marketplace's installLocation clone. +# Per-plugin manifests (for catalog_versions) +# are then resolved under a SUBDIRECTORY of +# the same dir named for the marketplace: +# $FLEET_STATE_CATALOG_DIR/.json +# $FLEET_STATE_CATALOG_DIR///.claude-plugin/plugin.json +# mirroring production's +# /.claude-plugin/marketplace.json +# //.claude-plugin/plugin.json +# A fixture that omits the subdirectory is +# not broken — it exercises the fail-open +# path, which is the common one in the wild. # # Real env vars this script honors (set by Claude Code, not test-only): # CLAUDE_PLUGIN_ROOT — this plugin's own install dir; used to self-resolve @@ -380,6 +444,36 @@ resolve_default_marketplace() { ' "$INSTALLED_JSON" | head -1 | sed 's/.*@//' } +# --- Physical containment test for a catalog plugin manifest ----------------- +# True only when $1 resolves — with symlinks followed — to a path strictly +# under the already-resolved checkout root $2. +# +# Why physical rather than lexical: a symlink inside the checkout that points +# outside it is reached by an ordinary-looking relative source, so no amount of +# string inspection on the source can detect it. Resolving both sides and +# comparing is what actually enforces "inside the checkout". +# +# Fails CLOSED for the containment question (returns non-zero), which makes the +# CALLER fail OPEN on the version: an unresolvable path yields no version, so +# the id stays an update candidate. That is the safe direction — the only +# unsafe outcome for this pre-filter is withholding an update it did not earn. +manifest_is_contained() { + local candidate="$1" root="$2" resolved + [[ -n "$root" ]] || return 1 + # On failure hook::physical_path ECHOES ITS INPUT and returns non-zero. That + # echoed input still begins with the checkout root, so a naive prefix compare + # would call it contained without anything having been resolved — the unsafe + # direction. Gate on the RETURN STATUS, which `resolved=$(...)` propagates. + # Its HOOK_PHYSICAL_PATH_UNRESOLVED global cannot be used here: command + # substitution runs the function in a subshell, so the flag it sets never + # reaches this scope and would always read as "resolved". + resolved=$(hook::physical_path "$candidate") || return 1 + resolved=$(hook::normalize_path "$resolved") + # Strict prefix: the root itself is not a plugin manifest, and the trailing + # slash stops "/checkout-evil/..." from matching root "/checkout". + [[ "$resolved" == "$root"/* ]] +} + # --- Emit one marketplace's state object ------------------------------------ # Args: marketplace name. Prints a JSON object on stdout; on a resolvable # per-marketplace failure prints {marketplace:{name,error}} and returns 1 @@ -438,6 +532,139 @@ emit_marketplace() { # below read the same catalog id list. catalog_ids_f="$(jq_slurp_tmpfile "$catalog_ids")" + # --- Per-plugin catalog versions, for the Step 3 update pre-filter --------- + # A marketplace.json ENTRY carries no version — only name/source/category/ + # tags (and optionally defaultEnabled/displayName/relevance). That absence is + # why `sync` Step 3 historically called `claude plugin update` for every + # user-scope install and let the CLI decide: there was "no per-plugin catalog + # version to compare against". There is one, just not in that file — each + # plugin's own manifest sits in the marketplace checkout the entry's `source` + # points at, and reading it costs no network call and no CLI invocation. + # + # FAIL OPEN is the contract here, not a defensive nicety. Measured across the + # nine marketplaces registered on the authoring machine (Claude Code 2.1.240), + # the version resolves for every entry of some marketplaces and for a small + # MINORITY of others': an entry whose `source` is an object (a remote git + # spec) has no repo-relative path at all, a checkout may not materialize every + # entry's directory, and a manifest may carry no `version` key. So an + # unresolvable version is the COMMON case, not the rare one. Every such id + # gets null, and every consumer must read null as "update candidate" — the + # pre-filter may only ever shrink the sweep for an id it positively proved is + # already at the catalog version. Anything else risks silently skipping a + # stale plugin, which is strictly worse than the redundant no-op call the + # pre-filter exists to avoid. + local manifest_base + if [[ -n "${FLEET_STATE_CATALOG_DIR:-}" ]]; then + manifest_base="$FLEET_STATE_CATALOG_DIR/$name" + else + # installLocation is recorded in native form on Windows (C:\Users\...); + # fold to forward slashes so the composed path is one this shell can stat. + manifest_base="${install_location//\\//}" + fi + + # Containment root for the check below, resolved ONCE per marketplace. + # hook::normalize_path folds separators, and on Windows also folds drive-letter + # case, so two normalized physical paths compare directly with `==` on either + # platform — no ad-hoc downcasing here. + local manifest_base_phys + manifest_base_phys=$(hook::normalize_path "$(hook::physical_path "$manifest_base")") + manifest_base_phys="${manifest_base_phys%/}" + + local catalog_versions_lines="" cv_name cv_src cv_rel cv_ver cv_manifest + while IFS=$'\t' read -r cv_name cv_src; do + [[ -n "$cv_name" ]] || continue + cv_ver="" + # `source` is THIRD-PARTY content — it comes out of a marketplace's own + # manifest. If it can name a manifest outside the checkout, that foreign + # file gets to positively "prove" an id is already at the catalog version + # and suppress its update. A wrongly-WITHHELD update is the only unsafe + # direction this pre-filter has (a wrongly-emitted id is merely a redundant + # no-op call), so reaching outside the checkout must be impossible. + # + # Two gates, because a lexical one alone is NOT sufficient: + # 1. Lexical, below — refuse a `../` path SEGMENT. Cheap, spawns nothing, + # and rejects the common spelling before anything is stat'd. Backslash + # is folded first so `..\x` is caught as the same traversal. + # 2. Physical containment, after the -f test — resolve the manifest with + # symlinks followed and require it to sit under the resolved checkout + # root. This is the gate that actually enforces the property: a SYMLINK + # inside the checkout pointing outside it is a perfectly ordinary + # `./name` source that no lexical check can see, and `git clone` + # materializes real symlinks wherever core.symlinks is on. + # The physical gate runs only for entries whose manifest exists, so its + # subprocess cost tracks resolvable plugins, not catalog size. + # Either gate failing yields null → fail open → the id stays a candidate. + cv_rel="${cv_src#./}" + cv_rel="${cv_rel//\\//}" + case "/$cv_rel/" in + */../*) + cv_rel="" + ;; + *) ;; + esac + cv_manifest="$manifest_base/$cv_rel/.claude-plugin/plugin.json" + if [[ -n "$cv_rel" && -f "$cv_manifest" ]] && manifest_is_contained "$cv_manifest" "$manifest_base_phys"; then + # Through the CR-stripping wrapper like every other call, and + # `// empty` so a manifest with no version yields "" (→ null below) + # rather than the string "null". + # `//` is used deliberately here, unlike at projectPathPresent below + # where it would destroy information: the only extra value it swallows + # is a boolean `false` version, and swallowing that yields null → the id + # stays an update candidate. Every value this operator collapses lands on + # the FAIL-OPEN side, so the collapse cannot cause a wrong withhold. A + # numeric `0` is preserved (jq treats only false/null as empty). + cv_ver=$(jq -r '.version // empty' "$cv_manifest" 2>/dev/null) + fi + catalog_versions_lines+="$cv_name"$'\t'"$cv_ver"$'\n' + done < <(jq -r '.plugins[]? | select((.source | type) == "string") | "\(.name)\t\(.source)"' "$catalog_json") + + # Entries whose source is not a string never enter the loop above, so they + # are absent from this map entirely — a lookup returns null, the same + # fail-open answer as an entry whose manifest could not be read. + local catalog_versions + catalog_versions=$(printf '%s' "$catalog_versions_lines" | jq -Rn --arg mp "$name" ' + [inputs + | split("\t") + | select(length >= 2 and (.[0] | length) > 0) + | {key: (.[0] + "@" + $mp), value: (if .[1] == "" then null else .[1] end)}] + | from_entries') + + # --- projectPath liveness (advisory only — NEVER a filter) ----------------- + # Answers "is this record's projectPath a directory on this machine right + # now", nothing more. A false is equally consistent with a worktree that was + # removed and with a volume that is merely not mounted, a network share that + # is offline, or removable media that is unplugged — so this field annotates + # a row, and must never suppress one. Suppressing on it would hide real drift + # for anyone whose repos live on an external or network volume. + # Computed per DISTINCT path (a machine can hold a hundred records naming a + # dozen directories), so the stat count tracks directories, not records. + local path_presence_lines="" pp pp_fs + while IFS= read -r pp; do + [[ -n "$pp" ]] || continue + pp_fs="${pp//\\//}" + if [[ -d "$pp_fs" ]]; then + path_presence_lines+="$pp"$'\t'"true"$'\n' + else + path_presence_lines+="$pp"$'\t'"false"$'\n' + fi + done < <(jq -r --arg suffix "@$name" ' + [.plugins + | to_entries[] + | select(.key | endswith($suffix)) + | .value[] + | select(.scope == "project" or .scope == "local") + | .projectPath // empty] + | unique | .[]' "$INSTALLED_JSON") + + local path_presence path_presence_f + path_presence=$(printf '%s' "$path_presence_lines" | jq -Rn ' + [inputs + | split("\t") + | select(length >= 2 and (.[0] | length) > 0) + | {key: .[0], value: (.[1] == "true")}] + | from_entries') + path_presence_f="$(jq_slurp_tmpfile "$path_presence")" + # Ids the marketplace entry ships with defaultEnabled:false — a publisher's # deliberate opt-in-required default (takes precedence over the plugin's own # plugin.json field; see plugins-reference.md's "Default enablement"). No @@ -450,7 +677,8 @@ emit_marketplace() { # Every install record for ids in this marketplace, flattened, with the # currentProject flag Windows-normalized on both sides. local installed - installed=$(jq -c --arg suffix "@$name" --arg cur "$current_project_norm" --argjson ci "$case_insensitive_os" ' + installed=$(jq -c --arg suffix "@$name" --arg cur "$current_project_norm" --argjson ci "$case_insensitive_os" \ + --slurpfile pres "$path_presence_f" ' .plugins | to_entries[] | select(.key | endswith($suffix)) @@ -466,6 +694,16 @@ emit_marketplace() { (.projectPath | gsub("\\\\";"/")) as $p | if $ci then ($p | ascii_downcase) == ($cur | ascii_downcase) else $p == $cur end else null end + ), + projectPathPresent: ( + if (.scope == "project" or .scope == "local") and (.projectPath // "" | length) > 0 then + # `has` rather than `$pres[0][$pp] // null`: jq treats FALSE as + # empty for `//`, so the alternative operator would silently + # rewrite a genuine "not present" into "not checked" — collapsing + # the exact distinction this field exists to carry. + .projectPath as $pp + | (if ($pres[0] | has($pp)) then $pres[0][$pp] else null end) + else null end ) } ' "$INSTALLED_JSON" | jq -cs '.') @@ -497,6 +735,29 @@ emit_marketplace() { --slurpfile falseIds "$explicit_false_ids_f" \ '($catalog[0] - $userInstalled[0]) - $falseIds[0]') + # Ids with a project/local record and NO user-scope record. Every other field + # here is structurally blind to them: divergences[] starts by discarding any + # id with fewer than two records, and missing_from_user_install lists ids that + # are not installed AT ALL at user scope but IS catalog-derived and excludes + # ids installed somewhere. So a single-scope project-only install appears in + # neither, and without this array nothing in the output names it. Deliberately + # NOT filtered to catalog membership: an installed id the catalog no longer + # carries is exactly the kind of record a reader wants named. + # Excludes ids explicitly opted out (false) in any scope, exactly as + # missing_from_install and missing_from_user_install do. Without that + # subtraction, a plugin that is deliberately disabled AND installed only at + # project/local scope lands in this array, and SKILL.md's Report section puts + # the array under "Action needed" — resurfacing a decision the user already + # made as drift to act on. An opt-out is an answer, not a gap. + local user_scope_orphans + user_scope_orphans=$(jq -cn \ + --slurpfile installed "$(jq_slurp_tmpfile "$installed")" \ + --slurpfile falseIds "$explicit_false_ids_f" ' + (([$installed[0][] | select(.scope == "project" or .scope == "local") | .id] | unique) + - ([$installed[0][] | select(.scope == "user") | .id] | unique)) + - $falseIds[0] + ') + local known_at_mp known_at_mp_f known_at_mp=$(jq -c --arg suffix "@$name" '[.[] | select(endswith($suffix))]' <<<"$known_ids") # Routed once: both the missing_from_enabled and enabled_at_mp programs read it. @@ -536,7 +797,7 @@ emit_marketplace() { | map(select(length > 1)) | map({ id: .[0].id, - scopes: map({scope, version, projectPath}), + scopes: map({scope, version, projectPath, projectPathPresent}), versionsMatch: ((map(.version) | unique | length) == 1) }) ' <<<"$installed") @@ -545,21 +806,27 @@ emit_marketplace() { --arg name "$name" \ --argjson autoUpdate "$auto_update_json" \ --arg lastUpdated "$last_updated" \ + --arg project_root "$current_project_norm" \ --slurpfile catalog "$(jq_slurp_tmpfile "$catalog")" \ + --slurpfile catalogVersions "$(jq_slurp_tmpfile "$catalog_versions")" \ --slurpfile installed "$(jq_slurp_tmpfile "$installed")" \ --slurpfile enabled "$(jq_slurp_tmpfile "$enabled_at_mp")" \ --slurpfile missingInstall "$(jq_slurp_tmpfile "$missing_from_install")" \ --slurpfile missingUserInstall "$(jq_slurp_tmpfile "$missing_from_user_install")" \ --slurpfile missingEnabled "$(jq_slurp_tmpfile "$missing_from_enabled")" \ + --slurpfile userScopeOrphans "$(jq_slurp_tmpfile "$user_scope_orphans")" \ --slurpfile divergences "$(jq_slurp_tmpfile "$divergences")" \ '{ marketplace: {name: $name, autoUpdate: $autoUpdate, lastUpdated: $lastUpdated}, + project_root: (if ($project_root | length) > 0 then $project_root else null end), catalog: $catalog[0], + catalog_versions: $catalogVersions[0], installed: $installed[0], enabled: $enabled[0], missing_from_install: $missingInstall[0], missing_from_user_install: $missingUserInstall[0], missing_from_enabled: $missingEnabled[0], + user_scope_orphans: $userScopeOrphans[0], divergences: $divergences[0] }' } @@ -576,16 +843,36 @@ emit_marketplace() { # local-scope record for the same repo (the multi-scope case divergences[] # exists to track), so an id-only projection would emit that id twice with # nothing to tell the two lines apart. +# +# `update-candidates-user` is the pre-filtered form of `installed-user`, and its +# name says CANDIDATE on purpose: it emits a SUPERSET of the ids that actually +# need updating, never an authoritative stale list. An id is emitted when its +# catalog version is unknown OR differs from the installed one, so the only ids +# it withholds are those positively proved to already sit at the catalog +# version. `== null` rather than `// null`: jq's alternative operator also +# swallows `false`, and reaching for it here is how a lookup miss and a real +# value get conflated. +# Plain string inequality, deliberately not a semver ORDERING compare: an +# installed version merely DIFFERENT from the catalog's (a local dev build +# ahead of it, say) stays a candidate, exactly as it is today when Step 3 calls +# update for every id unconditionally. An ordering compare would start +# withholding ids on a judgement this script has no business making. ids_selector_filter() { case "$1" in installed-user) printf '%s' '.installed[]? | select(.scope == "user") | .id' ;; + update-candidates-user) + # shellcheck disable=SC2016 # a jq program: $cv is a jq variable and must reach jq unexpanded + printf '%s' '.catalog_versions as $cv | .installed[]? | select(.scope == "user") | select(($cv[.id]) == null or ($cv[.id]) != .version) | .id' + ;; current-project) printf '%s' '.installed[]? | select(.currentProject == true) | "\(.id)\t\(.scope)"' ;; missing-user-install) printf '%s' '.missing_from_user_install[]?' ;; missing-enabled) printf '%s' '.missing_from_enabled[]?' ;; + user-scope-orphans) printf '%s' '.user_scope_orphans[]?' ;; *) echo "ERROR: unknown --ids selector: $1" >&2 - echo " expected one of: installed-user, current-project," >&2 - echo " missing-user-install, missing-enabled" >&2 + echo " expected one of: installed-user, update-candidates-user," >&2 + echo " current-project, missing-user-install," >&2 + echo " missing-enabled, user-scope-orphans" >&2 return 1 ;; esac @@ -652,8 +939,13 @@ emit_one() { MODE="default" TARGET="" IDS_SELECTOR="" +LIST_MARKETPLACES="" while [[ $# -gt 0 ]]; do case "$1" in + --marketplaces) + LIST_MARKETPLACES="yes" + shift + ;; --ids) IDS_SELECTOR="${2:-}" # Same guard-before-`shift 2` reasoning as --marketplace below: with no @@ -687,6 +979,31 @@ while [[ $# -gt 0 ]]; do esac done +# --marketplaces is a standalone ENUMERATION mode: it answers "which names can +# I hand to --marketplace" and nothing else. `sync`'s `all` mode runs Steps 2-5 +# once per marketplace, so it needs that list — and without this mode the only +# way to get it is a hand-written `jq -r 'keys[]' | while read` over +# known_marketplaces.json, which on Windows reintroduces the trailing CR that +# the whole --ids contract exists to prevent. Same hazard, same remedy. +# +# Any flag that shapes a per-marketplace report alongside it is a +# contradiction, so it is refused loudly rather than silently ignored. Checked +# after the arg loop rather than inside the branch so the rejection is +# order-independent: `--marketplaces --all` and `--all --marketplaces` fail +# identically. +if [[ -n "$LIST_MARKETPLACES" ]]; then + if [[ "$MODE" != "default" || -n "$IDS_SELECTOR" ]]; then + echo "ERROR: --marketplaces cannot be combined with --marketplace, --all, or --ids" >&2 + exit 2 + fi + # Through the CR-stripping jq wrapper like every other line-oriented output, + # so a `while read` consumer gets CR-free names by construction. An empty + # object yields empty output and exit 0: nothing to enumerate is an answer, + # not an error. + jq -r 'keys[]' "$MARKETPLACES_JSON" || exit 2 + exit 0 +fi + # --ids projects ONE marketplace block; --all's {marketplaces: {...}} envelope # has no single block to project. Refused rather than invented, so a caller # never gets a silently-empty list from a combination this does not implement. diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index 096f70b21..87b33fb29 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -1162,6 +1162,675 @@ rc=$? assert_exit "--ids with --all: exit 2" 2 "$rc" assert_contains "--ids with --all: refuses rather than inventing a shape" "$out" "cannot be combined" +# ============================================================================ +# catalog_versions — per-plugin versions read from the marketplace checkout, +# and the FAIL-OPEN contract that governs every consumer of them. +# ============================================================================ + +# Seed a catalog whose entries carry a string `source`, and materialize each +# named plugin's own manifest under the marketplace subdirectory — the fixture +# mirror of production's //.claude-plugin/. +# Args: case_dir, then `=` pairs. A pair with an EMPTY +# version writes a manifest with no `version` key; a plugin named in the +# catalog but omitted from the pairs gets no manifest directory at all. Both +# are fail-open inputs, which is why the helper can express them. +seed_catalog_versions_case() { + local case_dir="$1" pair pname pver entries="" + shift + for pair in "$@"; do + pname="${pair%%=*}" + pver="${pair#*=}" + mkdir -p "$case_dir/catalog/market1/$pname/.claude-plugin" + if [[ -n "$pver" ]]; then + write "$case_dir/catalog/market1/$pname/.claude-plugin/plugin.json" \ + "$(jq -cn --arg n "$pname" --arg v "$pver" '{name: $n, version: $v}')" + else + write "$case_dir/catalog/market1/$pname/.claude-plugin/plugin.json" \ + "$(jq -cn --arg n "$pname" '{name: $n}')" + fi + entries+="$pname"$'\n' + done + write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' + write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +} + +# A resolvable manifest yields its version; the catalog entry itself carries +# none, which is the whole reason this read exists. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.2.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":[{"scope":"user","installPath":"y","version":"0.1.0"}]}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "catalog_versions: reads the version from the plugin's own manifest" \ + "0.2.0" "$(jq -r '.catalog_versions["alpha@market1"]' <<<"$out" 2>/dev/null)" + +# `./` prefix on the source is stripped; a bare relative source works too. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.2.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "alpha"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "catalog_versions: a source with no ./ prefix resolves identically" \ + "0.2.0" "$(jq -r '.catalog_versions["alpha@market1"]' <<<"$out" 2>/dev/null)" + +# --- The three fail-open inputs, each asserted to yield null, never a guess. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.2.0 +# `beta` is in the catalog with a valid source but no manifest was materialized. +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}, {"name": "beta", "source": "./beta"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "catalog_versions fail-open: an unmaterialized plugin directory yields null" \ + "null" "$(jq -r '.catalog_versions["beta@market1"]' <<<"$out" 2>/dev/null)" +assert_eq "catalog_versions fail-open: a sibling that DOES resolve is unaffected" \ + "0.2.0" "$(jq -r '.catalog_versions["alpha@market1"]' <<<"$out" 2>/dev/null)" + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha= +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "catalog_versions fail-open: a manifest with no version key yields null" \ + "null" "$(jq -r '.catalog_versions["alpha@market1"]' <<<"$out" 2>/dev/null)" + +# An object-valued `source` (a remote spec) has no repo-relative path at all. +# It is skipped entirely, so the lookup misses — which must read as null, the +# same fail-open answer, not as an error and not as a version. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.2.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}, {"name": "remote", "source": {"source": "github", "repo": "example/remote"}}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "catalog_versions fail-open: an object-valued source yields null, not an error" \ + "null" "$(jq -r '.catalog_versions["remote@market1"]' <<<"$out" 2>/dev/null)" +assert_eq "catalog_versions fail-open: an object-valued source does not abort the block" \ + "0" "$(jq -r 'if .marketplace.error then 1 else 0 end' <<<"$out" 2>/dev/null)" + +# A malformed manifest must not take the run down with it. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.2.0 +write "$case_dir/catalog/market1/alpha/.claude-plugin/plugin.json" '{not valid json' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":[{"scope":"user","installPath":"y","version":"0.1.0"}]}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +rc=$? +assert_exit "catalog_versions fail-open: an unparsable manifest still exits 0" 0 "$rc" +assert_eq "catalog_versions fail-open: an unparsable manifest yields null" \ + "null" "$(jq -r '.catalog_versions["alpha@market1"]' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# --ids update-candidates-user — a CANDIDATE list. It may withhold an id only +# when it positively proved that id already sits at the catalog version. +# ============================================================================ + +# Withheld: installed version equals the catalog version. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.1.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":[{"scope":"user","installPath":"y","version":"0.1.0"}]}}' +ARGS=(--marketplace market1 --ids update-candidates-user) +out=$(run_ids "$case_dir") +rc=$? +assert_exit "update-candidates-user: exit 0" 0 "$rc" +assert_eq "update-candidates-user: withholds an id already at the catalog version" "" "$out" + +# Emitted: installed version differs from the catalog version. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.2.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":[{"scope":"user","installPath":"y","version":"0.1.0"}]}}' +ARGS=(--marketplace market1 --ids update-candidates-user) +out=$(run_ids "$case_dir") +assert_eq "update-candidates-user: emits an id behind the catalog version" \ + "alpha@market1" "$out" + +# Emitted: installed version is AHEAD of the catalog (a local build). Plain +# inequality, deliberately not an ordering compare — an ahead id stays a +# candidate exactly as it is when no pre-filter runs at all. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.1.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":[{"scope":"user","installPath":"y","version":"9.9.9"}]}}' +ARGS=(--marketplace market1 --ids update-candidates-user) +out=$(run_ids "$case_dir") +assert_eq "update-candidates-user: an id AHEAD of the catalog stays a candidate" \ + "alpha@market1" "$out" + +# Never narrows past user scope: a project-scope record is not this selector's +# business even when its version differs. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.1.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":[{"scope":"project","projectPath":"C:\\nope","installPath":"x","version":"0.0.1"},{"scope":"user","installPath":"y","version":"0.1.0"}]}}' +ARGS=(--marketplace market1 --ids update-candidates-user) +out=$(run_ids "$case_dir") +assert_eq "update-candidates-user: a differing project-scope record is not a user-scope candidate" \ + "" "$out" + +# --- THE FAIL-OPEN PROOF --------------------------------------------------- +# The contract that matters is not "one unreadable id is tolerated" — it is +# that when NO catalog version resolves, the pre-filter degrades to exactly the +# unfiltered sweep. Measured on real machines this is the COMMON case, not the +# rare one (a marketplace whose entries carry object sources, or whose checkout +# does not materialize every plugin directory), so it is asserted as an +# equality against installed-user rather than as a spot check. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.1.0 +# Four catalog entries; only `alpha` could ever resolve, and its manifest is +# then removed so that NONE of the four does. +rm -rf "$case_dir/catalog/market1/alpha" +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}, {"name": "beta", "source": "./beta"}, {"name": "gamma", "source": {"source": "github", "repo": "e/g"}}, {"name": "delta"}]}' +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [{"scope": "user", "installPath": "a", "version": "0.1.0"}], + "beta@market1": [{"scope": "user", "installPath": "b", "version": "0.2.0"}], + "gamma@market1": [{"scope": "user", "installPath": "c", "version": "0.3.0"}], + "delta@market1": [{"scope": "user", "installPath": "d", "version": "0.4.0"}] + } +}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "fail-open proof: no catalog version resolves for any of the four entries" \ + "0" "$(jq -r '[.catalog_versions | to_entries[] | select(.value != null)] | length' <<<"$out" 2>/dev/null)" +ARGS=(--marketplace market1 --ids installed-user) +out_unfiltered=$(run_ids "$case_dir") +ARGS=(--marketplace market1 --ids update-candidates-user) +out_candidates=$(run_ids "$case_dir") +assert_eq "fail-open proof: with no readable catalog version the sweep is not narrowed at all" \ + "$out_unfiltered" "$out_candidates" +assert_eq "fail-open proof: and that unnarrowed sweep is every user-scope id, not an empty list" \ + "alpha@market1 +beta@market1 +gamma@market1 +delta@market1" "$out_candidates" + +# Partial resolution narrows only the ids it proved, and leaves every +# unresolvable sibling in the sweep. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_catalog_versions_case "$case_dir" alpha=0.1.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}, {"name": "beta", "source": "./beta"}]}' +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [{"scope": "user", "installPath": "a", "version": "0.1.0"}], + "beta@market1": [{"scope": "user", "installPath": "b", "version": "0.2.0"}] + } +}' +ARGS=(--marketplace market1 --ids update-candidates-user) +out=$(run_ids "$case_dir") +assert_eq "fail-open proof: a partially-readable catalog withholds only the proved id" \ + "beta@market1" "$out" + +# ============================================================================ +# projectPathPresent — advisory annotation. Never a filter, never a verdict. +# ============================================================================ + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +live_dir="$case_dir/live-repo" +mkdir -p "$live_dir" +native_live="${live_dir//\//\\}" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg p "$native_live" --arg q 'C:\definitely\not\here\at\all' '{ + version: 1, + plugins: { + "alpha@market1": [{scope: "project", projectPath: $p, installPath: "x", version: "0.1.0"}], + "beta@market1": [{scope: "project", projectPath: $q, installPath: "y", version: "0.1.0"}], + "gamma@market1": [{scope: "user", installPath: "z", version: "0.1.0"}] + } + }' +)" +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}, {"name": "beta"}, {"name": "gamma"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "projectPathPresent: true for a projectPath that exists" \ + "true" "$(jq -r '.installed[] | select(.id == "alpha@market1") | .projectPathPresent' <<<"$out" 2>/dev/null)" +# The load-bearing one: jq's `//` treats FALSE as empty, so a lookup written +# with the alternative operator would silently rewrite this false into null and +# collapse "not present" into "not checked" — the exact distinction the field +# carries. +assert_eq "projectPathPresent: false for an absent projectPath, NOT rewritten to null" \ + "false" "$(jq -r '.installed[] | select(.id == "beta@market1") | .projectPathPresent' <<<"$out" 2>/dev/null)" +assert_eq "projectPathPresent: null for a user-scope record (not applicable)" \ + "null" "$(jq -r '.installed[] | select(.id == "gamma@market1") | .projectPathPresent' <<<"$out" 2>/dev/null)" +# Advisory means advisory: an absent path must not remove the record from the +# report. Suppressing on a directory test would hide real drift from anyone +# whose repos sit on an unmounted volume or an offline share. +assert_eq "projectPathPresent: an absent path never filters the record out of installed[]" \ + "3" "$(jq -r '.installed | length' <<<"$out" 2>/dev/null)" + +# The same annotation rides each divergences[].scopes[] entry, and an absent +# path never removes the divergence row either. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg q 'C:\definitely\not\here\at\all' '{ + version: 1, + plugins: { + "alpha@market1": [ + {scope: "project", projectPath: $q, installPath: "x", version: "0.1.0"}, + {scope: "user", installPath: "y", version: "0.2.0"} + ] + } + }' +)" +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "projectPathPresent: carried on divergences[].scopes[] too" \ + "false" "$(jq -r '.divergences[0].scopes[] | select(.scope == "project") | .projectPathPresent' <<<"$out" 2>/dev/null)" +assert_eq "projectPathPresent: an absent path never suppresses the divergence row" \ + "1" "$(jq -r '.divergences | length' <<<"$out" 2>/dev/null)" +assert_eq "projectPathPresent: and the row is still graded on version skew alone" \ + "false" "$(jq -r '.divergences[0].versionsMatch' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# user_scope_orphans — the class every other field is structurally blind to. +# ============================================================================ + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "orphan@market1": [{"scope": "project", "projectPath": "C:\\repo-one", "installPath": "x", "version": "0.1.0"}], + "localorphan@market1": [{"scope": "local", "projectPath": "C:\\elsewhere", "installPath": "x", "version": "0.1.0"}], + "paired@market1": [ + {"scope": "project", "projectPath": "C:\\repo-one", "installPath": "x", "version": "0.1.0"}, + {"scope": "user", "installPath": "y", "version": "0.1.0"} + ], + "useronly@market1": [{"scope": "user", "installPath": "y", "version": "0.1.0"}] + } +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "orphan"}, {"name": "localorphan"}, {"name": "paired"}, {"name": "useronly"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +# `join(",")` rather than `.[]`: the assertion-side `jq` here is the REAL jq, +# not fleet-state.sh's CR-stripping wrapper, and on Windows it terminates every +# line with CRLF. `$(…)` strips only the FINAL terminator as a unit, so a +# multi-line extraction leaves a trailing CR on every line but the last and the +# comparison fails against a byte-identical-looking literal. Collapsing to one +# line keeps the capture in the safe single-line case — the same reason no +# other case in this suite extracts multiple lines through a test-side jq. +assert_eq "user_scope_orphans: names project-only and local-only ids" \ + "localorphan@market1,orphan@market1" "$(jq -r '.user_scope_orphans | join(",")' <<<"$out" 2>/dev/null)" +# The two exclusions that make the field mean something. +assert_eq "user_scope_orphans: excludes an id that also holds a user-scope record" \ + "0" "$(jq -r '[.user_scope_orphans[] | select(. == "paired@market1")] | length' <<<"$out" 2>/dev/null)" +assert_eq "user_scope_orphans: excludes a user-only id" \ + "0" "$(jq -r '[.user_scope_orphans[] | select(. == "useronly@market1")] | length' <<<"$out" 2>/dev/null)" + +# An id installed ONLY at project/local scope and explicitly disabled somewhere +# is a decision already made, not a gap. SKILL.md's Report puts this array under +# "Action needed", so leaving an opt-out in it resurfaces the user's own decline +# as drift to act on — the same reason missing_from_install and +# missing_from_user_install both subtract the explicit-false set. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "declined@market1": [{"scope": "project", "projectPath": "C:\\repo-one", "installPath": "x", "version": "0.1.0"}], + "wanted@market1": [{"scope": "project", "projectPath": "C:\\repo-one", "installPath": "x", "version": "0.1.0"}] + } +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "declined"}, {"name": "wanted"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{"declined@market1": false}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "user_scope_orphans: an explicitly-disabled project-only id is NOT an orphan" \ + "wanted@market1" "$(jq -r '.user_scope_orphans | join(",")' <<<"$out" 2>/dev/null)" +# Guard the guard: the opt-out must be excluded because it is opted out, not +# because the fixture failed to produce an orphan population at all. +assert_eq "user_scope_orphans: the sibling id in the same fixture IS still reported" \ + "1" "$(jq -r '.user_scope_orphans | length' <<<"$out" 2>/dev/null)" +# Why the field is needed at all: a single-scope orphan is absent from +# divergences[] BY CONSTRUCTION (that array discards any id with fewer than two +# records), so nothing else in the output could carry it. +assert_eq "user_scope_orphans: the orphans are structurally absent from divergences[]" \ + "0" "$(jq -r '[.divergences[] | select(.id == "orphan@market1" or .id == "localorphan@market1")] | length' <<<"$out" 2>/dev/null)" + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "orphan@market1": [{"scope": "project", "projectPath": "C:\\repo-one", "installPath": "x", "version": "0.1.0"}], + "useronly@market1": [{"scope": "user", "installPath": "y", "version": "0.1.0"}] + } +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "orphan"}, {"name": "useronly"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +ARGS=(--marketplace market1 --ids user-scope-orphans) +out=$(run_ids "$case_dir") +rc=$? +assert_exit "--ids user-scope-orphans: exit 0" 0 "$rc" +assert_eq "--ids user-scope-orphans: emits the fully-qualified id, one per line" \ + "orphan@market1" "$out" + +# ============================================================================ +# project_root — the top-level field that makes Step 2's skip reportable. +# Without it, "no project context resolved" and "a project with nothing +# installed in it" are one indistinguishable signal downstream. +# ============================================================================ + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +nonrepo_dir="$case_dir/not-a-git-repo" +mkdir -p "$nonrepo_dir" +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":[{"scope":"user","installPath":"y","version":"0.1.0"}]}}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +out=$(cd "$nonrepo_dir" && run_state_no_project_dir "$case_dir") +assert_eq "project_root: null when no project context resolves at all" \ + "null" "$(jq -r '.project_root' <<<"$out" 2>/dev/null)" +# The distinction is only useful if it survives alongside a currentProject that +# is ALSO null — which is precisely the collapse project_root exists to break. +assert_eq "project_root: null even though every record's currentProject is also null" \ + "null" "$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null)" + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +proj_root="$case_dir/sample-repo" +mkdir -p "$proj_root" +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"alpha@market1":[{"scope":"user","installPath":"y","version":"0.1.0"}]}}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$proj_root") +# A project DID resolve and it simply has no project/local installs — the +# honest zero, reportable only because project_root is non-null here while every +# currentProject is still null. +assert_contains "project_root: names the resolved root when a project context exists" \ + "$(jq -r '.project_root' <<<"$out" 2>/dev/null)" "sample-repo" +assert_eq "project_root: a resolved root with no in-repo installs still has no currentProject:true" \ + "0" "$(jq -r '[.installed[] | select(.currentProject == true)] | length' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# catalog_versions in PRODUCTION mode — manifest_base derived from the +# marketplace's own installLocation rather than from FLEET_STATE_CATALOG_DIR. +# Every other case above sets the catalog-dir override, so without this one the +# branch that actually runs on a real machine is never exercised. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +mkdir -p "$case_dir/checkout/.claude-plugin" +mkdir -p "$case_dir/checkout/alpha/.claude-plugin" +mkdir -p "$case_dir/outside/.claude-plugin" +write "$case_dir/checkout/.claude-plugin/marketplace.json" '{"plugins": [{"name": "alpha", "source": "./alpha"}, {"name": "escape", "source": "../outside"}]}' +write "$case_dir/checkout/alpha/.claude-plugin/plugin.json" '{"name": "alpha", "version": "1.2.3"}' +# A manifest that DOES exist outside the checkout, reachable only by traversal. +write "$case_dir/outside/.claude-plugin/plugin.json" '{"name": "escape", "version": "0.0.1"}' +write "$case_dir/known_marketplaces.json" "$( + jq -cn --arg loc "$case_dir/checkout" '{market1: {source: {source: "github", repo: "example/market1"}, installLocation: $loc, lastUpdated: "2026-01-01T00:00:00Z"}}' +)" +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [{"scope": "user", "installPath": "a", "version": "1.2.3"}], + "escape@market1": [{"scope": "user", "installPath": "b", "version": "0.0.1"}] + } +}' +# Deliberately NOT via run_state: that helper always sets +# FLEET_STATE_CATALOG_DIR, which is the branch this case exists to bypass. +out=$(env FLEET_STATE_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/user_settings.json" \ + bash "$SCRIPT" --marketplace market1 2>&1) +assert_eq "production manifest_base: resolves a version from installLocation, no catalog-dir override" \ + "1.2.3" "$(jq -r '.catalog_versions["alpha@market1"]' <<<"$out" 2>/dev/null)" +# `source` is third-party content. A traversing source must NOT resolve — it +# would read a manifest outside the checkout and could positively but falsely +# "prove" an id is current, which is the ONLY way this pre-filter could wrongly +# WITHHOLD an update. +assert_eq "production manifest_base: a ../-traversing source refuses to resolve (yields null)" \ + "null" "$(jq -r '.catalog_versions["escape@market1"]' <<<"$out" 2>/dev/null)" +out_ids=$(env FLEET_STATE_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/user_settings.json" \ + bash "$SCRIPT" --marketplace market1 --ids update-candidates-user 2>/dev/null) +assert_eq "production manifest_base: the traversing id stays an update candidate, the resolved one is withheld" \ + "escape@market1" "$out_ids" + +# Windows spelling of the same traversal must be refused identically. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +mkdir -p "$case_dir/checkout/.claude-plugin" +mkdir -p "$case_dir/outside/.claude-plugin" +write "$case_dir/checkout/.claude-plugin/marketplace.json" '{"plugins": [{"name": "escape", "source": "..\\outside"}]}' +write "$case_dir/outside/.claude-plugin/plugin.json" '{"name": "escape", "version": "0.0.1"}' +write "$case_dir/known_marketplaces.json" "$( + jq -cn --arg loc "$case_dir/checkout" '{market1: {source: {source: "github", repo: "example/market1"}, installLocation: $loc, lastUpdated: "2026-01-01T00:00:00Z"}}' +)" +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"escape@market1":[{"scope":"user","installPath":"b","version":"0.0.1"}]}}' +out=$(env FLEET_STATE_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/user_settings.json" \ + bash "$SCRIPT" --marketplace market1 2>&1) +assert_eq "production manifest_base: a backslash-spelled traversal is refused too" \ + "null" "$(jq -r '.catalog_versions["escape@market1"]' <<<"$out" 2>/dev/null)" + +# A SYMLINK inside the checkout pointing outside it reaches a foreign manifest +# through an entirely ordinary `./name` source — no lexical check on the source +# can see it. If that foreign manifest's version were read, it could positively +# but falsely "prove" an installed id current and WITHHOLD its update, which is +# this pre-filter's only unsafe direction. The physical containment check is +# what closes it; this case is the proof. +# Skipped where the platform cannot create a symlink (Windows without developer +# mode / core.symlinks off) — a skip is honest there, since the vector needs a +# real symlink to exist in the first place. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +mkdir -p "$case_dir/checkout/.claude-plugin" +mkdir -p "$case_dir/outside/.claude-plugin" +# The outside manifest deliberately carries the SAME version as the installed +# record, so reading it would withhold the id. Anything else would pass this +# test for the wrong reason. +write "$case_dir/outside/.claude-plugin/plugin.json" '{"name": "evil", "version": "9.9.9"}' +write "$case_dir/checkout/.claude-plugin/marketplace.json" '{"plugins": [{"name": "evil", "source": "./linkdir"}]}' +write "$case_dir/known_marketplaces.json" "$( + jq -cn --arg loc "$case_dir/checkout" '{market1: {source: {source: "github", repo: "example/market1"}, installLocation: $loc, lastUpdated: "2026-01-01T00:00:00Z"}}' +)" +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"evil@market1":[{"scope":"user","installPath":"a","version":"9.9.9"}]}}' +# `MSYS=winsymlinks:nativestrict` is what makes Git Bash emit a REAL symlink +# instead of silently deep-COPYING the target — the copy would put the manifest +# genuinely inside the checkout and the case would then assert the opposite of +# what it means to. The variable is inert on POSIX, where ln -s already links. +# `[[ -L ]]` is the gate, not `ln`'s exit status: the copying form also succeeds. +MSYS=winsymlinks:nativestrict ln -s "$case_dir/outside" "$case_dir/checkout/linkdir" 2>/dev/null +if [[ -L "$case_dir/checkout/linkdir" ]] && + [[ -f "$case_dir/checkout/linkdir/.claude-plugin/plugin.json" ]]; then + out=$(env FLEET_STATE_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/user_settings.json" \ + bash "$SCRIPT" --marketplace market1 2>&1) + assert_eq "symlink escape: a manifest reached through a symlink out of the checkout yields null" \ + "null" "$(jq -r '.catalog_versions["evil@market1"]' <<<"$out" 2>/dev/null)" + out_ids=$(env FLEET_STATE_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/user_settings.json" \ + bash "$SCRIPT" --marketplace market1 --ids update-candidates-user 2>/dev/null) + assert_eq "symlink escape: the id is NOT withheld — it stays an update candidate" \ + "evil@market1" "$out_ids" +else + printf 'SKIP: symlink escape (this platform did not produce a real symlink)\n' +fi +rm -rf "$case_dir/checkout/linkdir" + +# Control for the case above: the identical shape with a REAL directory instead +# of a symlink still resolves, so the guard is containment and not a blanket +# refusal of everything that case constructs. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +mkdir -p "$case_dir/checkout/.claude-plugin" +mkdir -p "$case_dir/checkout/realdir/.claude-plugin" +write "$case_dir/checkout/realdir/.claude-plugin/plugin.json" '{"name": "good", "version": "9.9.9"}' +write "$case_dir/checkout/.claude-plugin/marketplace.json" '{"plugins": [{"name": "good", "source": "./realdir"}]}' +write "$case_dir/known_marketplaces.json" "$( + jq -cn --arg loc "$case_dir/checkout" '{market1: {source: {source: "github", repo: "example/market1"}, installLocation: $loc, lastUpdated: "2026-01-01T00:00:00Z"}}' +)" +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{"good@market1":[{"scope":"user","installPath":"a","version":"9.9.9"}]}}' +out=$(env FLEET_STATE_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/user_settings.json" \ + bash "$SCRIPT" --marketplace market1 2>&1) +assert_eq "containment control: an in-checkout real directory still resolves its version" \ + "9.9.9" "$(jq -r '.catalog_versions["good@market1"]' <<<"$out" 2>/dev/null)" + +# ============================================================================ +# --marketplaces — the name enumeration `sync`'s `all` mode loops. Without it +# the only way to get the list is a hand-written `jq -r 'keys[]' | while read` +# over known_marketplaces.json, which carries the identical trailing-CR hazard +# that --ids exists to prevent. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '{ + "market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}, + "market2": {"source": {"source": "github", "repo": "example/market2"}, "installLocation": "z2", "lastUpdated": "2026-01-01T00:00:00Z"} +}' +ARGS=(--marketplaces) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--marketplaces: exit 0" 0 "$rc" +assert_eq "--marketplaces: every marketplace name, one per line" \ + "market1 +market2" "$out" + +# Nothing to enumerate is an answer, not an error. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '{}' +ARGS=(--marketplaces) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--marketplaces: empty object exits 0" 0 "$rc" +assert_eq "--marketplaces: empty object emits nothing" "" "$out" + +# Standalone mode: every shaping flag is refused, and the refusal is +# order-independent so `--marketplaces --all` and `--all --marketplaces` agree. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_ids_case "$case_dir" +ARGS=(--marketplaces --all) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--marketplaces with --all: exit 2" 2 "$rc" +assert_contains "--marketplaces with --all: refuses the combination" "$out" "cannot be combined" +ARGS=(--all --marketplaces) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--marketplaces after --all: same rejection, order-independent" 2 "$rc" +ARGS=(--marketplaces --ids installed-user) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--marketplaces with --ids: exit 2" 2 "$rc" + +# CR regression, same stub mechanism as the --ids case above: the documented +# consumer is a `while read` loop feeding --marketplace, so a surviving CR +# would corrupt every name but the last into an unresolvable marketplace. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '{ + "market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}, + "market2": {"source": {"source": "github", "repo": "example/market2"}, "installLocation": "z2", "lastUpdated": "2026-01-01T00:00:00Z"} +}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +mkdir -p "$case_dir/crlf-bin" +REAL_JQ="$(command -v jq)" +{ + printf '#!/usr/bin/env bash\n' + printf 'set -o pipefail\n' + printf '"%s" "$@" | sed '"'"'s/\\r*$/\\r/'"'"'\n' "$REAL_JQ" +} >"$case_dir/crlf-bin/jq" +chmod +x "$case_dir/crlf-bin/jq" +# Guard the guard: a stub that silently stopped CRLF-terminating would make +# this case pass while testing nothing. +stub_probe=$(printf '{"a":"one"}' | PATH="$case_dir/crlf-bin:$PATH" jq -r '.a' | od -An -c | tr -s ' ') +assert_contains "--marketplaces CR regression: stub jq really emits CRLF" "$stub_probe" 'o n e \r \n' +ARGS=(--marketplaces) +out=$(PATH="$case_dir/crlf-bin:$PATH" run_state "$case_dir") +rc=$? +assert_exit "--marketplaces CR regression: exit 0 under a CRLF-emitting jq" 0 "$rc" +case "$out" in +*$'\r'*) fail "--marketplaces CR regression: no CR survives into the emitted names" \ + "found a CR in: $(printf '%s' "$out" | od -An -c | tr -s ' ')" ;; +*) pass "--marketplaces CR regression: no CR survives into the emitted names" ;; +esac +assert_eq "--marketplaces CR regression: names are byte-exact under a CRLF-emitting jq" \ + "market1 +market2" "$out" + +# project_root is marketplace-invariant but must ride EVERY block: an --all +# consumer reads blocks independently, so the field that explains each block's +# currentProject flags has to travel with it. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +proj_root="$case_dir/sample-repo" +mkdir -p "$proj_root" +write "$case_dir/known_marketplaces.json" '{ + "market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}, + "market2": {"source": {"source": "github", "repo": "example/market2"}, "installLocation": "z2", "lastUpdated": "2026-01-01T00:00:00Z"} +}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/catalog/market2.json" '{"plugins": [{"name": "beta"}]}' +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +ARGS=(--all) +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$proj_root") +assert_eq "--all: every marketplace block carries project_root" \ + "2" "$(jq -r '[.marketplaces[] | select(.project_root != null)] | length' <<<"$out" 2>/dev/null)" +assert_eq "--all: and every block reports the SAME resolved root" \ + "1" "$(jq -r '[.marketplaces[].project_root] | unique | length' <<<"$out" 2>/dev/null)" + +# The unknown-selector help text must name every selector the case arms accept, +# or the two lists drift apart silently. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +seed_ids_case "$case_dir" +ARGS=(--marketplace market1 --ids no-such-selector) +out=$(run_state "$case_dir") +assert_contains "--ids help text names installed-user" "$out" "installed-user" +assert_contains "--ids help text names update-candidates-user" "$out" "update-candidates-user" +assert_contains "--ids help text names current-project" "$out" "current-project" +assert_contains "--ids help text names missing-user-install" "$out" "missing-user-install" +assert_contains "--ids help text names missing-enabled" "$out" "missing-enabled" +assert_contains "--ids help text names user-scope-orphans" "$out" "user-scope-orphans" + # --- Summary ------------------------------------------------------------- printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" [[ "$FAILED" -eq 0 ]] && exit 0 diff --git a/plugins/claude-ops/skills/setup/SKILL.md b/plugins/claude-ops/skills/setup/SKILL.md index 885a0ba76..359b9d320 100644 --- a/plugins/claude-ops/skills/setup/SKILL.md +++ b/plugins/claude-ops/skills/setup/SKILL.md @@ -64,25 +64,34 @@ verify-and-route: (default `${CLAUDE_PLUGIN_DATA}`); if repository-resident, recommend a portable contained path, inspecting the consumer's declared artifact conventions. Same for `skill_usage_dir` (default `.claude/observability`). State the tradeoff and let the reader pick — do not prompt. -- **Reconfiguring a personal option:** `/plugin configure claude-ops@` (interactive, any time). - Headless: `--config` only applies on a fresh install (ignored once installed), so reconfigure via - `claude plugin uninstall claude-ops -s ` then - `claude plugin install claude-ops@ -s --config registry_dir=`; this - skill never writes user settings or `pluginConfigs`. Both commands default to `-s user` — pass - the scope `claude plugin list` reports for this plugin, and run from that project's directory - for a `project`/`local` scope. Defaulting instead uninstalls a separate user-scope record while - the effective install stays in place, so the reinstall lands at a scope that does not load. - Uninstalling also drops the stored `pluginConfigs` entry, so the reinstall must re-supply - **every** key whose value should stay non-default — this plugin declares fourteen, and a - reinstall that passes only `registry_dir` silently resets the other thirteen (the seven - `*_audit_enabled` toggles, `instructions_loaded_audit_log_session_start`, `install_new`, - `skill_usage_dir`, `skill_usage_git_exclude`, `skill_usage_scope`, `stdin_read_timeout`) to - their manifest defaults. Record the current values before uninstalling; afterwards there is - nothing left to read them from. +- **Reconfiguring a personal option:** `/plugin configure claude-ops@` (interactive, any + time). Headless: rerun the install with the new value — + `claude plugin install claude-ops@ -s --config KEY=VALUE …` (repeatable per + key). Against an already-installed plugin it prints `already installed` **and still writes the + value** — verified on Claude Code 2.1.240 (a non-sensitive option at `user` scope: a non-default + value written to an installed plugin, then restored). The short-circuit is about the install, not + the config write. Re-verify before relying on it outside those conditions — a `sensitive` option, + or `project`/`local` scope, were not covered. Do **not** uninstall to reconfigure: uninstalling + drops this plugin's entire stored `pluginConfigs` entry, resetting every option in the README's + Options reference table to its manifest default — all fifteen, so the eight `*_audit_enabled` + toggles come back on and `registry_dir`, `skill_usage_dir`, `skill_usage_scope`, + `skill_usage_git_exclude`, `install_new`, `instructions_loaded_audit_log_session_start`, and + `stdin_read_timeout` all revert. `-s` defaults to `user`, so pass the scope `claude plugin list` + reports for this plugin, and run from that project's directory for a `project`/`local` scope, or + the write lands at a scope that does not load. This skill never writes user settings or + `pluginConfigs`. + Afterwards, keep the two claims apart. The write is issued and the stored value is what you + passed; the RUNNING session's behavior is not. The rendered `${user_config.*}` is injected at + skill load and each hook receives its `CLAUDE_PLUGIN_OPTION_*` from an environment fixed at + session start, so a same-session `check` still reports the OLD value — reporting that as a + failed write would be wrong. Verify the effective value by rerunning `check` in a **fresh + session**, and never claim an unobserved change. -After any reconfiguration, rerun `check` and report both observed effective destinations — never claim -an unobserved change. Re-running `apply` when both destinations are contained (or defaulted) changes -nothing and reports "already configured". +After any reconfiguration, rerun `check` in a **fresh session** and report both observed effective +destinations — never claim an unobserved change, and never read a same-session `check` still showing +the old value as a failed write (see the reconfiguration note above for why it does). Re-running +`apply` when both destinations are contained (or defaulted) changes nothing and reports +"already configured". ## What this skill does NOT do