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..4eca6925b 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,64 @@ 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] + +### Added + +- **`plugins`: four sync-enabling `fleet-state.sh` capabilities (#3112).** `project_root` as a + top-level field on every successful marketplace block (string when project context resolved, + null otherwise, including each `--all` block); `projectPathExists` on every `installed[]` record + and every `divergences[].scopes[]` entry (true/false from a directory test; null for user-scope + records with no `projectPath`); a standalone `--marketplaces` flag emitting + `known_marketplaces.json` names one per line, CR-free (a usage error combined with + `--marketplace`/`--all`/`--ids`); and an `--ids stale-user` selector emitting the user-scope ids + not confirmed current with the local marketplace checkout's per-plugin manifest version — + fail-open (an unresolvable catalog version keeps the id in the list), validated on the audited + CC 2.1.240 run at exactly 48/61 predicted updates with 0 false positives/negatives. +- **`plugins`: the sync report's always-present `In-repo:` row (#3112).** Step 2 (the + self-described primary value path) branches on `project_root` for the zero-record case, so a run + where the step never applied ("no project context") is visibly distinct from one that ran and + found no in-repo installs — previously the two were indistinguishable silence. +- **`plugins`: a conditional self-update `Note:` row (#3112).** When the sweep updates + `claude-ops@` itself, the report states the run executed the pre-update algorithm — + the rendered skill content is the pre-update version, and the report no longer implies otherwise. +- **`plugins`: a durable version-capture ledger (#3112).** `sync` now mandates a `mktemp` ledger + with one `\t\t` record appended per mutating call as it runs; Step 6 composes the + report from the ledger, never from conversational memory (on the audited 67-call run the old→new + data had survived only in transcript). Session scratch, never a committed artifact. + +### Changed + +- **`plugins`: `sync all` now iterates Steps 2–5 per marketplace (#3112).** Names come from + `fleet-state.sh --marketplaces` (never a hand-written `jq -r 'keys[]' | while read`, per the + CRLF gotcha), each step passing `--marketplace --ids ` since `--ids` refuses + `--all`. Previously only Step 1 looped, so an `all` sweep silently covered one marketplace's + installs (observed: 66 ids swept against 72 machine-wide across 9 marketplaces, CC 2.1.240). +- **`plugins`: Step 3 sweeps `--ids stale-user` instead of `--ids installed-user` (#3112).** The + local-manifest comparison pre-filters already-current plugins, so the audited sweep issues 48 + update calls instead of 61 and a routine already-current sync issues 0; the stale "fleet-state.sh + has no per-plugin catalog version" rationale is corrected where the docs repeated it. Step 2's + just-call-update posture is unchanged (its records are project/local-scope; `stale-user` is + user-scope only). +- **`plugins`: the `versionsMatch` filter rule now has a single normative home (#3112).** + scope-semantics.md's "Divergence is not automatically actionable" section is the one normative + statement; SKILL.md, converge.md, and gotchas.md now point at it instead of hand-maintaining + restatements across four files. + +### Fixed + +- **`plugins`: no more converge/enable commands into directories that no longer exist (#3112).** + Every doc site constructing `(cd "" && claude plugin …)` now gates on the record's + `projectPathExists`; a `false` record routes to a named "Action needed" category — **orphaned + install record** — as report-only, since no CLI verb reaps such a record (observed on CC 2.1.240; + `prune -s project` has the same no-path-flag limitation). A divergence whose only lagging scope + rows are orphaned is reported as non-convergeable instead of previewing commands that can only + fail. +- **`plugins`: SKILL.md's `pluginConfigs` scope claim corrected (#3112).** The value is substituted + only from a `pluginConfigs` map Claude Code actually reads — since CC v2.1.207 that is user scope + (`~/.claude.json` / the `--settings` file) and managed settings; project- and local-scope + `pluginConfigs` are ignored, unlike `enabledPlugins`, which still honors project/local scope. + ## [0.35.4] ### Fixed diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md index bf220e98a..e79cfc34b 100644 --- a/plugins/claude-ops/skills/plugins/SKILL.md +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -85,9 +85,8 @@ 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. +particular its "Divergence is not automatically actionable" section, the normative statement of the +`versionsMatch` filter rule that every divergence count this skill reports must apply. ## Action: audit @@ -107,15 +106,31 @@ action. ```text Marketplace: (autoUpdate: ) +In-repo: updated | none — project context , no in-repo installs | none — no project + context (Step 2 did not apply) Updated: plugin(s) — @: (only when N > 0) Installed: new catalog plugin(s) — @ (only when N > 0; per install_new policy) +Note: claude-ops updated mid-run (); this run executed the algorithm + (only when the sweep updated claude-ops@ itself — see context/sync.md) Divergences: project-scope install(s) behind user scope → run `/claude-ops:plugins converge` - (N = actionable only — versionsMatch:false; same-version multi-scope installs are not counted - or listed here) + (N = the actionable subset per scope-semantics.md's versionsMatch filter rule) Action needed: (omit section entirely when empty) + enable gaps, orphaned install records, CLI failures, unknown plugins> (omit section entirely + when empty) ``` +The `In-repo:` row is **always present**, in exactly one of its three states — the deliberate +exception to "only rows needing action." Step 2 of [context/sync.md](context/sync.md) is the +primary value path, and its silent no-op was invisible precisely because the row's absence looked +identical to "ran and found nothing": `fleet-state.sh`'s `project_root` disambiguates the two +zero-record states (null → the step did not apply; non-null → it ran against `` and found no +in-repo installs), and sync.md Step 2 fixes which state maps to which wording. + +The `Note:` row appears only when the sweep updated `claude-ops@` itself. The skill +content rendered for this session — the algorithm that actually ran — is the pre-update version, +so the report must never imply the new version's algorithm produced it (see +[context/sync.md](context/sync.md) "Self-update"). + A project-scope enable gap is a row `sync` deliberately does not fix — Step 5 enables automatically only where the write is not team-shared state. Give each one its runnable command rather than a count, so acting on it is a copy, not a reconstruction: @@ -125,18 +140,28 @@ count, so acting on it is a copy, not a reconstruction: — writes that repo's committed .claude/settings.json; review the diff before committing ``` -Only ids that Step 5 did not enable at `user`/`local` scope in this run appear here — for the rest -the command would fail rather than run, and Step 5 explains why. +Emit that command only when the record's `projectPathExists` is `true`. A `false` record's +directory is gone, so the command can only fail — the row moves to the orphaned-install-record +category below instead. Only ids that Step 5 did not enable at `user`/`local` scope in this run +appear here — for the rest the command would fail rather than run, and Step 5 explains why. + +An **orphaned install record** — an install record whose recorded `projectPath` no longer exists on +disk (`projectPathExists: false`) — is its own named "Action needed" category, and always +report-only: any `(cd "" && …)` command constructed against it can only fail, and no +CLI verb reaps such a record (observed on CC 2.1.240; `prune -s project` has the same no-path-flag +limitation as every `-s project` verb), so the record stays until upstream provides a reap path. +Name the id, scope, and dead path per row rather than a bare count. When running inside a project (`CLAUDE_PROJECT_DIR` set and `fleet-state.sh`'s `installed[]` entries carry `currentProject: true`), lead the Divergences line with *this* project's actionable count and fold the rest of the machine into one trailing clause — e.g. `2 behind here → converge; 27 more elsewhere on this machine`. Per-row detail (naming exact `` versions per repo) is -reserved for genuine conflicts: an unknown/orphaned plugin id, or a CLI call that failed — never for -the routine bulk case. (Enable-state mismatches — a plugin `true` in one scope's `enabledPlugins` -and `false` in another — are a known blind spot, not a reportable category: `fleet-state.sh` only -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".) +reserved for genuine conflicts: an unknown plugin id (present in `enabledPlugins` with no matching +catalog entry), or a CLI call that failed — never for the routine bulk case. (Enable-state +mismatches — a plugin `true` in one scope's `enabledPlugins` and `false` in another — are a known +blind spot, not a reportable category: `fleet-state.sh` only 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 @@ -159,9 +184,12 @@ 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 -an unset key (verified 2026-07-23 against CC 2.1.218: an unset key leaves the placeholder token +explicitly set** in a `pluginConfigs` map Claude Code actually reads: since Claude Code v2.1.207 +that is user scope (`~/.claude.json`, or the `--settings` file) and managed settings only — +project- and local-scope `pluginConfigs` are ignored, unlike `enabledPlugins`, which this same +skill reads and which still honors project/local 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 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 **Configured value** line above still shows that literal placeholder token, not `ask`. diff --git a/plugins/claude-ops/skills/plugins/context/converge.md b/plugins/claude-ops/skills/plugins/context/converge.md index bd1b7e221..c0996fcab 100644 --- a/plugins/claude-ops/skills/plugins/context/converge.md +++ b/plugins/claude-ops/skills/plugins/context/converge.md @@ -17,7 +17,9 @@ 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 +the actionable subset, per the filter rule stated normatively in +[scope-semantics.md](scope-semantics.md) ("Divergence is not automatically actionable"). +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 @@ -28,7 +30,12 @@ per-scope maps, not something this skill's prompt layer can paper over. ## Step 1 — Detect Call `fleet-state.sh` (default marketplace, named one, or the current invocation's target) and take -`divergences[]` filtered to `versionsMatch: false`. +`divergences[]` filtered to `versionsMatch: false` (the [scope-semantics.md](scope-semantics.md) +rule above). Then check each row's `scopes[].projectPathExists` before treating it as convergeable: +a divergence whose only *lagging* scope rows carry `projectPathExists: false` cannot be converged — +every command Step 2 could propose for it would `cd` into a directory that no longer exists. Report +such a row as an orphaned install record (SKILL.md's "Action needed" category) and propose no +commands for it. ## Step 2 — Preview per-plugin intent @@ -61,6 +68,15 @@ rows a bulk report collapses) — never construct the proposed command as a bare running the bare form for a row whose `projectPath` isn't the current directory would silently mutate — or fail against — the wrong repo's settings. +**Before constructing any such command, read that scope row's `projectPathExists`** — carried on +every `divergences[].scopes[]` entry for exactly this decision. `false` → never emit the command: +the recorded directory is gone, so the `cd` can only fail. Treat the record as an **orphaned +install record** instead — no CLI verb reaps an install record whose `projectPath` no longer +exists (observed on CC 2.1.240; `prune -s project` shares the no-path-flag limitation), so it is +report-only under "Action needed" until upstream provides a reap path. When every lagging scope +row of a divergence is orphaned this way, the divergence is not convergeable at all (Step 1): +report it and propose nothing. + Two `git worktree` checkouts of one repository pin independently — verified on Claude Code 2.1.228 by uninstalling one id in a repo's main checkout and observing the worktree's record for the same id survive untouched. They share one `.git` and one tracked `.claude/settings.json` yet hold separate diff --git a/plugins/claude-ops/skills/plugins/context/gotchas.md b/plugins/claude-ops/skills/plugins/context/gotchas.md index 94548314a..0caa12f2d 100644 --- a/plugins/claude-ops/skills/plugins/context/gotchas.md +++ b/plugins/claude-ops/skills/plugins/context/gotchas.md @@ -84,10 +84,11 @@ 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. What separates that benign case +from a real, actionable version skew is `fleet-state.sh`'s `versionsMatch` field, and the filter +rule for it is stated normatively in [scope-semantics.md](scope-semantics.md) ("Divergence is not +automatically actionable") — apply it as written there before counting or listing anything; a raw +`divergences[].length` count is exactly the overstatement that rule exists to prevent. ## Internal-schema drift — fail loud, never guess @@ -142,7 +143,7 @@ feeds `claude plugin` needs no `jq` of its own at all: while IFS= read -r id; do [[ -n "$id" ]] || continue claude plugin update "$id" -s user -done < <(…/scripts/fleet-state.sh --ids installed-user) +done < <(…/scripts/fleet-state.sh --ids stale-user) ``` For anything `--ids` does not cover: route every `jq` call through the diff --git a/plugins/claude-ops/skills/plugins/context/scope-semantics.md b/plugins/claude-ops/skills/plugins/context/scope-semantics.md index 99d0ba40d..05579e516 100644 --- a/plugins/claude-ops/skills/plugins/context/scope-semantics.md +++ b/plugins/claude-ops/skills/plugins/context/scope-semantics.md @@ -37,6 +37,15 @@ each with `versionsMatch`: A raw count of `divergences[].length` conflates the two and overstates drift — always filter on `versionsMatch == false` before presenting a count to the user. +This section is the **single normative statement** of that filter rule. SKILL.md's Report section, +[converge.md](converge.md), and [gotchas.md](gotchas.md) point here rather than restating it, so a +change to the rule is a change to this section only. + +Each `divergences[].scopes[]` entry also carries `projectPathExists` (true/false from a directory +test on the recorded `projectPath`; null for user-scope entries, which have none), so a consumer +proposing a `(cd "" && …)` command can withhold it per-scope when the recorded +directory no longer exists — see [converge.md](converge.md) Step 2. + ## `plugin list` / `plugin details` version output is misleading **Verified misleading**: `claude plugin list` and `claude plugin details ` show the *highest diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index a0769150c..a5a66cfe8 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -40,6 +40,54 @@ 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. +**Capture into a durable ledger, not conversational memory.** Capture-as-you-go needs a medium that +survives the sweep: on the audited CC 2.1.240 run, a 67-mutating-call sweep's old→new data existed +only in the session transcript by the time the report was composed — nothing Step 6 can reliably +read back. So: at sweep start, `mktemp` a ledger file; for every mutating call, append one +tab-separated record — `\t\t` — **as the call runs**, filling ``/`` from +the three sources above in their stated precedence (the ledger changes where captured values live, +never where they come from). Step 6 composes the report's per-plugin lines from the ledger, never +from conversational memory or the transcript. The ledger is session scratch — a temp file discarded +with the session, never a committed artifact. + +## Self-update: the algorithm that ran is the pre-update one + +`claude-ops` is itself a catalog plugin, so Step 3 updates `claude-ops@` like any other +id — but the skill content rendered for this session, this algorithm included, is the **pre-update** +version. A mid-run update changes what the *next* session loads, not what this one is executing. +Before the sweep, capture claude-ops's own version from the pre-sweep `fleet-state.sh` snapshot +(its `installed[]` record); when the sweep then updates `claude-ops@` itself, the +report must carry `Note: claude-ops updated mid-run (); this run executed the +algorithm` (SKILL.md's Report section carries the conditional row), so the report never implies the +new version's algorithm produced it. + +## `all` mode — every step iterates per marketplace + +`all` is not a different algorithm — it is the same Steps 1–6 run once per marketplace. Enumerate +the names with `fleet-state.sh --marketplaces` (one name per line, CR-free by construction), never +a hand-written `jq -r 'keys[]' | while read` over `known_marketplaces.json` — that reintroduces the +Windows-CRLF corruption [gotchas.md](gotchas.md) documents: + +```bash +while IFS= read -r mp; do + [[ -n "$mp" ]] || continue + # Steps 1–5 for this marketplace, each fleet-state.sh call passing --marketplace "$mp": + # claude plugin marketplace update "$mp" + # … fleet-state.sh --marketplace "$mp" --ids current-project + # … fleet-state.sh --marketplace "$mp" --ids stale-user + # … +done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplaces) +``` + +Steps 2–5 must run **inside** that loop, each `fleet-state.sh` call carrying `--marketplace ` +— `--ids` refuses `--all` (there is no single block to project), so a sweep that runs the id +selectors only once has silently covered exactly one marketplace. That is the observed failure +shape: on the audited CC 2.1.240 run, a single-marketplace `all` sweep issued updates for 66 ids +while the machine held 72 installs across 9 marketplaces — the rest untouched, with no report row +saying so. A per-marketplace failure (a Step 1 refresh failure, or a `fleet-state.sh` error block — +which `--ids` mode routes to stderr, never stdout) is reported inline and never aborts the sweep +for the remaining marketplaces, consistent with Step 1. + ## Step 1 — Marketplace refresh For each target marketplace (the resolved default, the named one, or every marketplace when the @@ -110,21 +158,38 @@ The scope rides on the record for a reason: one plugin can hold **both** a `proj lines — `sort -u`, or pairing against a separately-extracted scope list, would silently drop one of the two updates. Do not re-derive scope from the id afterwards. +**When the loop processed zero records, the report must still say which zero it was.** The selector +alone collapses a tri-state: "no project context resolved" and "project context resolved, zero +in-repo installs" both project to an empty id list. The JSON block's top-level `project_root` (from +the pre-sweep snapshot, or a JSON-form re-read) disambiguates them — branch on it: + +- `project_root: null` → no project context resolved at all; this step did not apply. Report + `In-repo: none — no project context (Step 2 did not apply)`. +- `project_root` non-null → the step ran against that root and found no in-repo installs. Report + `In-repo: none — project context , no in-repo installs`. + +SKILL.md's Report section carries the always-present `In-repo:` row for exactly this reason: this +step is the self-described primary value path, and a run where it silently never executed must be +visibly distinct from one that ran and found nothing. + Do **not** pre-filter on `divergences[]`. `divergences[]` only contains ids with *more than one* 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 surface, so the +only correct signal is "is this entry present" — just call `update`, letting the CLI report +"already at the latest version" as a no-op when nothing changes. Step 3's `stale-user` pre-filter +does not transfer here: that selector compares **user-scope** records against the local catalog +manifests, and these records are project/local-scope, for which no selector does that comparison. +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: +For every `user`-scope plugin id **not confirmed current** with the local marketplace checkout's +catalog version, run: ```bash claude plugin update -s user @@ -134,15 +199,28 @@ 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 stale-user`, never from a hand-written `jq` over its JSON: ```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 --ids stale-user) ``` +`stale-user` pre-filters the user-scope sweep against a comparison `fleet-state.sh` can make +entirely locally: the marketplace's catalog *file* carries no versions, but each plugin's own +manifest in the local marketplace checkout does +(`//.claude-plugin/plugin.json`), so an installed-vs-catalog version +compare needs zero network. The selector **fails open**: an id whose catalog version cannot be +resolved — missing installLocation, missing/unreadable/invalid manifest, unresolvable source path — +stays in the list and still gets its `update` call; only an id *confirmed* current is omitted. The +asymmetry is deliberate (a wrongly-emitted id costs one no-op CLI call; a wrongly-omitted one skips +a real update — see the script's `emit_stale_user_ids` contract). Validated on the audited +CC 2.1.240 run: the comparison predicted exactly the 48 of 61 user-scope plugins that updated — +0 false positives, 0 false negatives, all catalog manifests resolving locally — so the sweep issued +48 calls instead of 61, and a routine already-current sync issues 0 instead of 61. + `--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 @@ -228,6 +306,13 @@ maintenance action with no such abort, so there may be no human to answer. [converge.md](converge.md) Step 2 gives — `-s project` has no path flag and always acts on the current directory — and the id stays fully qualified per [gotchas.md](gotchas.md). + **Gate the row on the record's `projectPathExists` first.** `false` means the recorded + `projectPath` no longer exists on disk, so the `cd` can only fail — never emit the command. + Route the record to "Action needed" as an **orphaned install record** instead (SKILL.md's Report + section names the category): no CLI verb reaps an install record whose `projectPath` is gone + (observed on CC 2.1.240; `prune -s project` has the same no-path-flag limitation as every + `-s project` verb), so the record is report-only until upstream provides a reap path. + **Order matters — suppress this row for any id the `user`/`local` branch just enabled.** An id with no `enabledPlugins` entry anywhere but install records at *both* `user` and `project` scope produces two rows in one run. The `user` row enables first, and `enable -s project` gates on the @@ -248,7 +333,9 @@ 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 +the ledger "Version capture for the report" mandates (never from conversational recall of the +sweep), the always-present `In-repo:` row from Step 2's tri-state, and the conditional self-update +`Note:` row per the section above. 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 diff --git a/plugins/claude-ops/skills/plugins/evals/evals.json b/plugins/claude-ops/skills/plugins/evals/evals.json index abb0d2a80..6240c0780 100644 --- a/plugins/claude-ops/skills/plugins/evals/evals.json +++ b/plugins/claude-ops/skills/plugins/evals/evals.json @@ -52,13 +52,13 @@ { "id": 5, "name": "divergence-report-collapses-bulk-names-conflicts", - "prompt": "Audit my plugins — I have 29 plugins with an actionable version divergence between project and user scope, plus 2 more that are multi-scope but pinned to the identical version, and one plugin id that fleet-state.sh reports under unknown/orphaned (present in enabledPlugins but no matching catalog entry).", - "expected_output": "The report's Divergences line collapses the 29 actionable (version-behind) entries into one bulk count and names `/claude-ops:plugins converge` as the fix — it does NOT list all 29 individually, and it does NOT count the 2 same-version multi-scope installs as actionable (versionsMatch: true is benign). The one unknown/orphaned plugin id gets its own per-row detail line under Action needed as a genuine conflict, distinct from the bulk divergence count.", + "prompt": "Audit my plugins — I have 29 plugins with an actionable version divergence between project and user scope, plus 2 more that are multi-scope but pinned to the identical version, and one plugin id that fleet-state.sh reports as unknown (present in enabledPlugins but no matching catalog entry).", + "expected_output": "The report's Divergences line collapses the 29 actionable (version-behind) entries into one bulk count and names `/claude-ops:plugins converge` as the fix — it does NOT list all 29 individually, and it does NOT count the 2 same-version multi-scope installs as actionable (versionsMatch: true is benign). The one unknown plugin id (present in enabledPlugins with no matching catalog entry) gets its own per-row detail line under Action needed as a genuine conflict, distinct from the bulk divergence count.", "files": [], "expectations": [ "The 29 actionable divergences are collapsed into a single bulk-count line, not enumerated one by one", "The 2 same-version (versionsMatch: true) multi-scope installs are excluded from the actionable divergence count", - "The unknown/orphaned plugin id gets an individual per-row detail line under Action needed, separate from the bulk divergence line", + "The unknown plugin id gets an individual per-row detail line under Action needed, separate from the bulk divergence line", "The response never claims to detect or report an enable-state mismatch (true in one scope, false in another) — fleet-state.sh only exposes the merged effective value, not each scope's raw enabledPlugins map" ] }, diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index 65cc82530..d7ec6cba2 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,10 +20,21 @@ # 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, installed, +# enabled, missing_from_install, missing_from_user_install, +# missing_from_enabled, divergences} # — or {marketplace: {name, error}} on a resolvable per-marketplace failure. +# project_root is the resolved PROJECT_ROOT as a string, or null when no +# project context resolved at all. It disambiguates the tri-state that +# `--ids current-project` alone collapses: that selector matches +# currentProject == true, so "no project context" (every flag null) and +# "project context but zero in-repo installs" (flags false) both project to +# zero ids — a consumer needs project_root to tell "the primary value path +# did not run" apart from "it ran and found nothing". +# Each installed[] record carries {id, scope, version, projectPath, +# currentProject, projectPathExists}. projectPathExists is true/false from a +# directory-existence test on the recorded projectPath, or null when +# projectPath is null (user-scope records). # missing_from_install is all-scope (catalog minus installed anywhere); # missing_from_user_install is user-scope only (catalog minus user-scope # installed), the signal `sync` Step 4 uses to keep every plugin usable from @@ -34,7 +46,9 @@ # the first field is always the fully-qualified `@` id, so # `while IFS=$'\t' read -r id …` reads every selector. Selectors, each naming # the `sync` step that consumes it: -# installed-user installed[] at user scope (Step 3 update) +# installed-user installed[] at user scope (full user-scope +# set; Step 3 consumes stale-user, its +# not-confirmed-current subset) # fields: id # current-project installed[] with currentProject (Step 2 update) # fields: id, scope — scope is carried because one @@ -45,6 +59,11 @@ # fields: id # missing-enabled missing_from_enabled[] (Step 5 enable) # fields: id +# stale-user installed[] at user scope NOT (Step 3 update) +# confirmed current with the local marketplace +# checkout's catalog version +# fields: id — see emit_stale_user_ids for the +# fail-open resolution rules # 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. # A per-marketplace failure block goes to STDERR in this mode (never stdout), @@ -54,6 +73,15 @@ # — on Windows that reintroduces a CR and corrupts every id but the last. See # the emit_ids comment and context/gotchas.md. # +# Output (stdout) with --marketplaces: NOT JSON — every marketplace name from +# known_marketplaces.json, one per line, nothing else, CR-free by the same +# mechanism as --ids. Empty output for an empty object is success (exit 0). +# A standalone mode: combining it with --marketplace, --all, or --ids is a +# usage error (exit 2). Exists so `sync`'s all-marketplace mode can iterate +# `--marketplace --ids ` per name without hand-writing its +# own `jq -r 'keys[]' | while read` over the JSON — the exact Windows-CRLF +# corruption the --ids contract above exists to prevent. +# # Exit codes: # 0 ran to completion (individual marketplace failures are reported in the # JSON body, not the exit code, so an --all sweep with partial failures @@ -69,7 +97,16 @@ # 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. +# Replaces the marketplace.json CATALOG +# file only: the per-plugin manifests +# stale-user reads still resolve under the +# real installLocation recorded in +# known_marketplaces.json, so a fixture +# that exercises stale-user must point +# installLocation at a real directory +# carrying /.claude-plugin/ +# plugin.json files # # 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 @@ -470,6 +507,28 @@ emit_marketplace() { } ' "$INSTALLED_JSON" | jq -cs '.') + # projectPathExists: true/false from a directory test on the recorded + # projectPath, null for the user-scope records that carry none. Exists so + # converge/sync never emit `(cd "" && claude plugin …)` against + # a directory that no longer exists — those commands can only fail, and no + # CLI verb reaps such records (observed on the audited CC 2.1.240 run), so + # they are report-only. jq cannot stat, so existence is computed here in + # bash over the UNIQUE recorded paths (one probe per directory, not per + # record) and fed back in as a path→bool map. The probe takes each path + # verbatim: CC wrote it for this same machine, so whatever spelling the + # record carries is the spelling `-d` must judge. + local path_exists_map='{}' rec_path rec_exists + while IFS= read -r rec_path; do + [[ -n "$rec_path" ]] || continue + rec_exists=false + [[ -d "$rec_path" ]] && rec_exists=true + path_exists_map=$(jq -c --arg p "$rec_path" --arg e "$rec_exists" \ + '. + {($p): ($e == "true")}' <<<"$path_exists_map") + done <<<"$(jq -r '[.[].projectPath // empty] | unique | .[]' <<<"$installed")" + installed=$(jq -c --slurpfile pe "$(jq_slurp_tmpfile "$path_exists_map")" \ + 'map(. + {projectPathExists: (if .projectPath == null then null else $pe[0][.projectPath] end)})' \ + <<<"$installed") + local installed_ids installed_ids=$(jq -c '[.[].id] | unique' <<<"$installed") @@ -529,22 +588,30 @@ emit_marketplace() { # `versionsMatch` separates a benign multi-scope install (project and user # scope both pinned to the same version — normal, not actionable) from a # real version skew (some scope is behind another — the "run converge" - # signal). A record count alone conflates the two. + # signal). A record count alone conflates the two. Each scope entry carries + # projectPathExists so a converge command targeting a dead projectPath can + # be withheld per-scope without a second lookup into installed[]. local divergences divergences=$(jq -c ' group_by(.id) | map(select(length > 1)) | map({ id: .[0].id, - scopes: map({scope, version, projectPath}), + scopes: map({scope, version, projectPath, projectPathExists}), versionsMatch: ((map(.version) | unique | length) == 1) }) ' <<<"$installed") + # project_root is marketplace-invariant but emitted per block: an --all + # consumer reads blocks independently, and the tri-state it disambiguates + # (see the header contract) must travel with every block that carries the + # currentProject flags it explains. Empty-string PROJECT_ROOT means no + # project context resolved — surfaced as null, never "". jq -cn \ --arg name "$name" \ --argjson autoUpdate "$auto_update_json" \ --arg lastUpdated "$last_updated" \ + --arg projectRoot "$PROJECT_ROOT" \ --slurpfile catalog "$(jq_slurp_tmpfile "$catalog")" \ --slurpfile installed "$(jq_slurp_tmpfile "$installed")" \ --slurpfile enabled "$(jq_slurp_tmpfile "$enabled_at_mp")" \ @@ -554,6 +621,7 @@ emit_marketplace() { --slurpfile divergences "$(jq_slurp_tmpfile "$divergences")" \ '{ marketplace: {name: $name, autoUpdate: $autoUpdate, lastUpdated: $lastUpdated}, + project_root: (if $projectRoot == "" then null else $projectRoot end), catalog: $catalog[0], installed: $installed[0], enabled: $enabled[0], @@ -582,15 +650,99 @@ ids_selector_filter() { 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[]?' ;; + # stale-user's base projection carries the installed version alongside the + # id because the projection alone cannot answer the selector: the catalog + # side of the comparison lives in per-plugin manifest files on disk, which + # jq cannot read from inside a filter. emit_ids routes these pairs through + # emit_stale_user_ids, which does the manifest reads and emits ids only. + # A null version interpolates as the literal string "null", which can never + # equal a real catalog version — so a version-less record fails open into + # the emitted set, per the selector's bias (see emit_stale_user_ids). + stale-user) printf '%s' '.installed[]? | select(.scope == "user") | "\(.id)\t\(.version)"' ;; *) echo "ERROR: unknown --ids selector: $1" >&2 echo " expected one of: installed-user, current-project," >&2 - echo " missing-user-install, missing-enabled" >&2 + echo " missing-user-install, missing-enabled, stale-user" >&2 return 1 ;; esac } +# Project the stale-user selector: the subset of user-scope installed ids (the +# installed-user population) NOT confirmed current with the local marketplace +# checkout's catalog version. Exists so `sync` Step 3 can skip `claude plugin +# update` for already-current plugins instead of sweeping every installed id +# with no-op calls. The catalog version for @ is read from the +# marketplace clone at //.claude-plugin/ +# plugin.json — from the catalog entry's `source` field when +# that is a relative-path string (the "./plugins/" spelling), else the +# plugins/ layout default. Observed on the audited CC 2.1.240 run: this +# installed-vs-catalog comparison predicted exactly the 48 of 61 plugins that +# updated (0 false positives, 0 false negatives), with all 70 catalog +# manifests resolving locally, zero network. +# +# FAIL OPEN, every edge: an id is emitted whenever its catalog version cannot +# be resolved — missing installLocation, missing/unreadable/invalid manifest, +# non-string version, an object `source` whose local path cannot be derived. +# Only an id whose catalog version was read successfully AND equals the +# installed version is omitted. The asymmetry is deliberate: a wrongly-emitted +# id costs one no-op CLI call (safe); a wrongly-omitted id skips a real +# update (unsafe). +# +# Note FLEET_STATE_CATALOG_DIR replaces the catalog FILE only (see the env +# header): the per-plugin manifests here always resolve under the real +# installLocation from known_marketplaces.json. +emit_stale_user_ids() { + local block="$1" filter="$2" + local mp_name install_location catalog_json source_map='{}' + mp_name=$(jq -r '.marketplace.name' <<<"$block") + install_location=$(jq -r --arg n "$mp_name" '.[$n].installLocation // ""' "$MARKETPLACES_JSON") + # Same catalog-file resolution as emit_marketplace, minus its error blocks: + # here an unresolvable catalog is not a failure, it is "every id fails open". + if [[ -n "${FLEET_STATE_CATALOG_DIR:-}" ]]; then + catalog_json="$FLEET_STATE_CATALOG_DIR/$mp_name.json" + else + catalog_json="$install_location/.claude-plugin/marketplace.json" + fi + # name→source-path map from the catalog. Only string sources map; an object + # source (git/github forms) maps to null and falls through to the layout + # default below — if that path carries no manifest either, the id fails open. + if [[ -f "$catalog_json" ]] && jq empty "$catalog_json" 2>/dev/null; then + source_map=$(jq -c ' + [.plugins[]? + | select((.name | type) == "string") + | {key: .name, value: (if (.source | type) == "string" then .source else null end)}] + | from_entries' "$catalog_json") + fi + local pairs id installed_version pname rel manifest catalog_version + pairs=$(jq -r "$filter" <<<"$block") + [[ -n "$pairs" ]] || return 0 + while IFS=$'\t' read -r id installed_version; do + [[ -n "$id" ]] || continue + if [[ -z "$install_location" ]]; then + printf '%s\n' "$id" + continue + fi + pname="${id%@*}" + rel=$(jq -r --arg n "$pname" '.[$n] // empty' <<<"$source_map") + [[ -n "$rel" ]] || rel="plugins/$pname" + rel="${rel#./}" + manifest="$install_location/$rel/.claude-plugin/plugin.json" + # Empty string is the single "unresolved" sentinel: an invalid-JSON + # manifest fails the jq read (capturing nothing), a non-string .version is + # mapped to "" by the filter itself, and a manifest whose version IS the + # empty string can never equal a real installed version — all three land + # in the emitted set, as fail-open requires. + catalog_version="" + [[ -f "$manifest" ]] && + catalog_version=$(jq -r 'if (.version | type) == "string" then .version else "" end' "$manifest" 2>/dev/null) + if [[ -z "$catalog_version" || "$catalog_version" != "$installed_version" ]]; then + printf '%s\n' "$id" + fi + done <<<"$pairs" + return 0 +} + # Project one marketplace block down to the plain id list a `sync` step loops. # Exists so no CALLER has to write its own `jq -r ... | while read`: on Windows # a hand-written jq reintroduces the CR this script's own wrapper strips (the @@ -608,6 +760,12 @@ ids_selector_filter() { emit_ids() { local block="$1" selector="$2" filter filter=$(ids_selector_filter "$selector") || return 2 + # stale-user is the one selector that is not a pure block projection: its + # base id/version pairs are post-filtered against on-disk catalog manifests. + if [[ "$selector" == "stale-user" ]]; then + emit_stale_user_ids "$block" "$filter" + return $? + fi # `[]?` rather than `[]`: a block legitimately missing a key (an empty # `installed`, say) yields no ids instead of erroring. jq -r "$filter" <<<"$block" @@ -652,8 +810,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 +850,25 @@ while [[ $# -gt 0 ]]; do esac done +# --marketplaces is a standalone enumeration mode: it answers "which names can +# I hand to --marketplace" and nothing else, so any flag that shapes a +# per-marketplace report alongside it is a contradiction, refused loudly like +# the --ids/--all combination below rather than silently ignored. Checked +# after the arg loop (not in the branch) so 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 (the reason + # this mode exists — see the header contract). 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..ec9ce1b41 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,276 @@ 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" +# ============================================================================ +# Case: project_root disambiguates the current-project tri-state. `--ids +# current-project` matches currentProject == true, so "no project context at +# all" (flags null) and "project context with zero in-repo installs" (flags +# false) both project to zero ids; project_root is the field a consumer reads +# to tell those apart. String when context resolved, null when none — never "". +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +project_dir="$case_dir/pr-project" +mkdir -p "$project_dir" +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"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir") +project_root=$(jq -r '.project_root' <<<"$out" 2>/dev/null) +assert_eq "project_root: resolved context surfaces as the PROJECT_ROOT string" "$project_dir" "$project_root" + +# No context: non-git cwd, no .claude marker, CLAUDE_PROJECT_DIR unset — the +# same anchor-less setup the currentProject null case exercises. +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":{}}' +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") +project_root=$(jq -r '.project_root' <<<"$out" 2>/dev/null) +assert_eq "project_root: no project context is null, not empty string" "null" "$project_root" + +# Every per-marketplace block under --all carries it too: --all consumers read +# blocks independently, so the tri-state must travel with each one. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +project_dir="$case_dir/all-project" +mkdir -p "$project_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/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/catalog/market2.json" '{"plugins": [{"name": "beta"}]}' +ARGS=(--all) +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir") +roots=$(jq -r '[.marketplaces.market1.project_root, .marketplaces.market2.project_root] | unique | .[]' <<<"$out" 2>/dev/null) +assert_eq "project_root: every --all block carries the same resolved root" "$project_dir" "$roots" + +# ============================================================================ +# Case: projectPathExists tri-state. converge/sync must not emit +# `(cd "" && claude plugin …)` against a directory that no longer +# exists — no CLI verb reaps such records (observed on the audited CC 2.1.240 +# run), so they are report-only, and this field is how a consumer knows. +# true: projectPath recorded and the directory exists. false: recorded but +# gone. null: user-scope record, no projectPath at all. The dead-path plugin +# is dual-scope so divergences[].scopes[] is pinned to carry the field too. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +live_dir="$case_dir/live-repo" +mkdir -p "$live_dir" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg live "$live_dir" --arg dead "$case_dir/deleted-repo" '{ + version: 1, + plugins: { + "alpha@market1": [{scope: "project", projectPath: $live, installPath: "x", version: "0.1.0"}], + "beta@market1": [ + {scope: "project", projectPath: $dead, installPath: "y", version: "0.1.0"}, + {scope: "user", installPath: "z", 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"}, {"name": "beta"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +tri=$(jq -c '[.installed[] | {id, scope, projectPathExists}]' <<<"$out" 2>/dev/null) +assert_eq "projectPathExists: live dir true, deleted dir false, user-scope null" \ + '[{"id":"alpha@market1","scope":"project","projectPathExists":true},{"id":"beta@market1","scope":"project","projectPathExists":false},{"id":"beta@market1","scope":"user","projectPathExists":null}]' \ + "$tri" +div_tri=$(jq -c '[.divergences[0].scopes[] | .projectPathExists]' <<<"$out" 2>/dev/null) +assert_eq "projectPathExists: divergences[].scopes[] carries it per scope entry" "[false,null]" "$div_tri" + +# ============================================================================ +# Case: --marketplaces enumerates known_marketplaces.json names one per line, +# stdout-only, so sync's all-marketplace mode iterates `--marketplace +# --ids ` per name without hand-writing `jq -r 'keys[]' | while +# read` (the Windows-CRLF corruption --ids itself 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" + +# Empty object: 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" + +# CR regression, same stub mechanism as the --ids case above: the emitted +# names must be CR-free even under a CRLF-emitting native-Windows jq, because +# the documented consumer is a `while read` loop feeding --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"} +}' +# run_ids does not seed the prerequisite state files the way run_state does. +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" +ARGS=(--marketplaces) +out=$(PATH="$case_dir/crlf-bin:$PATH" run_ids "$case_dir") +rc=$? +assert_exit "--marketplaces CR regression: exit 0 under a CRLF-emitting jq" 0 "$rc" +case "$out" in +"") fail "--marketplaces CR regression: no CR survives into the names" \ + "output was EMPTY — a CR assertion over no output proves nothing" ;; +*$'\r'*) fail "--marketplaces CR regression: no CR survives into the names" \ + "output carried a CR: $(printf '%s' "$out" | od -An -c | tr -s ' ')" ;; +*) pass "--marketplaces CR regression: no CR survives into the names" ;; +esac + +# Standalone mode: refused loudly with every report-shaping flag, in either +# argument order — never silently ignored. +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"}}' +write "$case_dir/catalog/market1.json" '{"plugins": []}' +for combo in "--marketplaces --all" "--all --marketplaces" "--marketplaces --marketplace market1" "--marketplaces --ids installed-user"; do + # combo is a fixed space-separated flag list; word splitting is the point. + # shellcheck disable=SC2206 + ARGS=($combo) + out=$(run_state "$case_dir") + rc=$? + assert_exit "--marketplaces combination '$combo': exit 2" 2 "$rc" + assert_contains "--marketplaces combination '$combo': refused loudly" "$out" "cannot be combined" +done + +# ============================================================================ +# --ids stale-user: the subset of user-scope installed ids NOT confirmed +# current with the local marketplace checkout's catalog version — sync Step 3 +# skips `claude plugin update` for the confirmed-current rest. FAIL OPEN: any +# id whose catalog version cannot be resolved is emitted (a wrong emission +# costs one no-op CLI call; a wrong omission skips a real update). Fixture +# note: FLEET_STATE_CATALOG_DIR replaces the CATALOG file only — the +# per-plugin manifests resolve under the real installLocation, so this case +# builds a real clone directory with pinned plugin.json versions. +# +# One fixture, every resolution edge: +# alpha string source "./plugins/alpha", manifest current → omitted +# beta no source, default plugins/beta, manifest ahead → emitted +# curr no source, default plugins/curr, manifest current → omitted +# custom string source "./nested/custom", manifest current → omitted +# (proves the source path is honored: the plugins/custom default +# carries no manifest, so ignoring source would fail it open) +# ghost manifest missing → emitted +# broken manifest invalid JSON → emitted +# notstr manifest .version is a number → emitted +# objsrc object source, no local manifest anywhere → emitted +# projonly project-scope only — outside the user-scope population, never +# emitted no matter how stale +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +clone="$case_dir/mp-clone" +mkdir -p "$clone/plugins/alpha/.claude-plugin" "$clone/plugins/beta/.claude-plugin" \ + "$clone/plugins/curr/.claude-plugin" "$clone/nested/custom/.claude-plugin" \ + "$clone/plugins/broken/.claude-plugin" "$clone/plugins/notstr/.claude-plugin" +write "$clone/plugins/alpha/.claude-plugin/plugin.json" '{"name": "alpha", "version": "1.0.0"}' +write "$clone/plugins/beta/.claude-plugin/plugin.json" '{"name": "beta", "version": "2.1.0"}' +write "$clone/plugins/curr/.claude-plugin/plugin.json" '{"name": "curr", "version": "3.3.3"}' +write "$clone/nested/custom/.claude-plugin/plugin.json" '{"name": "custom", "version": "4.0.0"}' +write "$clone/plugins/broken/.claude-plugin/plugin.json" '{not valid json' +write "$clone/plugins/notstr/.claude-plugin/plugin.json" '{"name": "notstr", "version": 7}' +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [{"scope": "user", "installPath": "a", "version": "1.0.0"}], + "beta@market1": [{"scope": "user", "installPath": "b", "version": "2.0.0"}], + "curr@market1": [{"scope": "user", "installPath": "c", "version": "3.3.3"}], + "custom@market1": [{"scope": "user", "installPath": "d", "version": "4.0.0"}], + "ghost@market1": [{"scope": "user", "installPath": "e", "version": "0.1.0"}], + "broken@market1": [{"scope": "user", "installPath": "f", "version": "0.1.0"}], + "notstr@market1": [{"scope": "user", "installPath": "g", "version": "7"}], + "objsrc@market1": [{"scope": "user", "installPath": "h", "version": "0.1.0"}], + "projonly@market1": [{"scope": "project", "projectPath": "/x", "installPath": "i", "version": "0.0.1"}] + } +}' +write "$case_dir/known_marketplaces.json" "$( + jq -cn --arg loc "$clone" \ + '{market1: {source: {source: "github", repo: "example/market1"}, installLocation: $loc, lastUpdated: "2026-01-01T00:00:00Z"}}' +)" +write "$case_dir/catalog/market1.json" '{"plugins": [ + {"name": "alpha", "source": "./plugins/alpha"}, + {"name": "beta"}, + {"name": "curr"}, + {"name": "custom", "source": "./nested/custom"}, + {"name": "ghost"}, + {"name": "broken"}, + {"name": "notstr"}, + {"name": "objsrc", "source": {"source": "github", "repo": "example/objsrc"}}, + {"name": "projonly"} +]}' +ARGS=(--marketplace market1 --ids stale-user) +out=$(run_ids "$case_dir") +rc=$? +assert_exit "--ids stale-user: exit 0" 0 "$rc" +assert_eq "--ids stale-user: stale + every unresolvable edge emitted, confirmed-current omitted, project-scope out of population" \ + "beta@market1 +ghost@market1 +broken@market1 +notstr@market1 +objsrc@market1" "$out" + +# Missing installLocation: no clone to read a catalog version from, so every +# user-scope id fails open into the emitted set. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": {"alpha@market1": [{"scope": "user", "installPath": "a", "version": "1.0.0"}]} +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "./plugins/alpha"}]}' +ARGS=(--marketplace market1 --ids stale-user) +out=$(run_ids "$case_dir") +rc=$? +assert_exit "--ids stale-user: missing installLocation exits 0" 0 "$rc" +assert_eq "--ids stale-user: missing installLocation fails open, id emitted" "alpha@market1" "$out" + +# Zero user-scope installs: empty output is success, matching every other +# selector's zero-match contract. +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"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +# run_ids does not seed the prerequisite state files the way run_state does. +write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +ARGS=(--marketplace market1 --ids stale-user) +out=$(run_ids "$case_dir") +rc=$? +assert_exit "--ids stale-user: zero user-scope installs is success" 0 "$rc" +assert_eq "--ids stale-user: zero user-scope installs emits nothing" "" "$out" + # --- Summary ------------------------------------------------------------- printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" [[ "$FAILED" -eq 0 ]] && exit 0