diff --git a/README.md b/README.md index c6ddb6834..0e84f3c48 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Browse and manage with `/plugin`. To refresh after updates: `/plugin marketplace - [`playbooks`](plugins/playbooks) — Doctrine and knowledge playbooks as on-demand skills, plus a maintainer-facing update skill. boris — Boris Cherny's Claude Code workflow tips (howborisusesclaudecode.com); thariq — Anthropic's internal skill-authoring playbook; fable-5 — Claude Fable 5's operating doctrine (self-authored, no upstream). The boris and thariq packs vendor a verbatim upstream baseline; /playbooks:update drift-checks and syncs those baselines centrally (maintainers). - [`claude-config`](plugins/claude-config) — Three audit skills for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), automation-gaps (evidence-gated verdicts on automation gaps), and permission-hygiene (allow-rule / allowed-tools grants for auto-mode durability and portability). - [`claude-memory`](plugins/claude-memory) — Audits the Claude Code instruction/memory layer — CLAUDE.md, CLAUDE.local.md, .claude/rules/, and auto-memory — against a checklist derived from official Claude Code documentation. A deterministic script-backed spine (MEMORY.md index integrity, orphan always-loaded rules) yields identical findings on identical repo state; judgment-tier checks apply fixed criteria with model reading. Actions: audit (default), fix (per-item approval), update (refresh criteria from current docs), report. -- [`claude-ops`](plugins/claude-ops) — Claude Code operations toolkit. Four skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), troubleshoot (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), and a re-runnable setup action that settles where the troubleshooting registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. +- [`claude-ops`](plugins/claude-ops) — Claude Code operations toolkit. Five skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), troubleshoot (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 — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), and a re-runnable setup action that settles where the troubleshooting registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. - [`skill-quality`](plugins/skill-quality) — Skill-authoring QA tooling: a static contract checker that runs seventeen deterministic checks over a Claude Code skill (frontmatter, listing-budget cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence) and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout. ### Security diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md new file mode 100644 index 000000000..7edeed8a2 --- /dev/null +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -0,0 +1,197 @@ +# plugin-fleet-sync-skill + +## Brief + +### TLDR + +Add `/claude-ops:plugins` — an action-routed skill that deterministically brings a machine's plugin fleet current on demand: marketplace refresh, update of the *effective* (actually-loaded) scopes, policy-driven install of new catalog plugins, scope-divergence detection, and explicit scope convergence — with a terse actionable report. + +### Goal + +One command that guarantees, on any machine and from any directory, that the plugins which actually load are the latest published versions of everything the marketplace offers, and that surfaces (never silently fixes) any state where something older or unintended is what really runs. + +### Decisions (locked) + +1. **Marketplace scope**: defaults to the marketplace this plugin was installed from, resolved dynamically (never hardcoded). Optional `` or `all` argument extends the same loop. +2. **Home**: `claude-ops` plugin (Claude Code operational tooling). No marketplace.json edit needed; bump plugin.json version + CHANGELOG + README + description. +3. **Surface**: skill `plugins`, action router — `sync` (default, mutating), `audit` (read-only dry-run of everything sync/converge would do), `converge` (explicit scope consolidation). Mirrors the `changelog` skill's router shape. +4. **Sync semantics — "effective fleet current where you stand"**: + - Always: `claude plugin marketplace update ` (self-heals corrupt clones: pull failure → automatic re-clone), then per-plugin `claude plugin update` sweep at user scope, then install-new per policy, then enabledPlugins completeness check. + - Inside a project with project/local-scope installs: additionally update those installs in place (`plugin update -s project|local`) — they are what loads there. + - Ends with reload guidance (see deferred: `--force`). +5. **Install policy**: `install_new` userConfig scalar — `ask` (default; new catalog plugins offered in one batched multi-select prompt) | `all` | `none`. +6. **Converge** (only committed-settings-touching action): detects any plugin id with >1 scope entry (divergent version or enable state), previews per-plugin intent, confirms (ask-first for ALL pins in V1), executes via CLI (`plugin uninstall --scope`, `plugin update -s`), then surfaces the resulting committed `.claude/settings.json` diff for review. Never runs implicitly from sync; sync only reports and names the converge command. +7. **Report**: terse fixed sections — marketplace state, updated, installed, divergences (live-vs-inactive named per repo), action needed. Detail only where action is required. +8. **autoUpdate posture**: report the marketplace's autoUpdate status and suggest enabling (complementary: background update-installed-to-latest once per session start, random ≤10-min delay). Never mutates the setting. The skill exists for what autoUpdate verifiably does not do: install new catalog plugins, enabledPlugins completeness, divergence detection/convergence, deterministic on-demand execution. + +### Constraints + +- **All mutations via the `claude plugin` / `claude plugin marketplace` CLI.** `installed_plugins.json`, `known_marketplaces.json`, `.last_inuse_sweep`, and cache version dirs are internal state: read-only, never written or deleted. No cache surgery — CC garbage-collects orphaned version dirs 7 days after update/uninstall. +- **Renames are CC-native** (≥ v2.1.193: settings rewritten old→new at session start; `null` = removal). The skill's only rename residue: anything in catalog but not installed gets installed (covers renamed plugins / `plugin-cache-miss`). +- **Never silently edit a repo's committed `.claude/settings.json`** — team-shared, trust-gated. Converge surfaces diffs; v2.1.203+ local-override path available for personal-only intent. +- **Never trust `plugin list`/`details` version output for what is LOADED** (verified misleading: shows highest installed, not the cwd-effective install). Effective-version claims derive from scope-by-cwd resolution (local > project > user, verified) or a functional probe. +- Non-interactive contexts: any `uninstall`/`prune` needs `-y`; autonomous sessions (`CLAUDE_CODE_REMOTE`, `/loop`, `/schedule`) must abort converge (repo destructive-tier convention). +- Repo conventions (migration playbook / philosophy): repo- and machine-agnostic (no hardcoded paths, names, or marketplace ids; `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}`); bash runtime (Git Bash on Windows) with OS detection; shared helpers only via `lib/hook-utils.sh` sync; `context/` progressive disclosure; `evals/evals.json` (~5–6 cases: routing, happy path, ≥1 refusal/guardrail, ≥1 anti-pattern) per skill-quality schema. +- Minimum CC version note in docs: renames map ≥2.1.193; `prune` ≥2.1.121. + +### Acceptance criteria + +- From a clean machine with the marketplace added: one `sync` run leaves every catalog plugin installed at user scope at latest version, enabledPlugins complete, and prints the terse report — idempotent (second run reports all-current, changes nothing). +- With a stale/corrupt marketplace clone: `sync` recovers via `marketplace update` (no manual re-clone) and proceeds. +- After an upstream rename wave: post-migration `sync` installs all new-named plugins; no old-name entries remain in user-scope state; nothing hand-edits internal JSON. +- Run inside a repo with divergent project-scope installs: report names, per plugin, which version is LIVE there vs inactive; sync updates the project-scope installs in place (subject to the deferred verification below); committed settings untouched. +- `audit` mutates nothing — issues zero mutating CLI calls; internal state files unchanged absent external writers (concurrent sessions / autoUpdate background sweep) — while predicting sync/converge actions accurately. *(Refined 2026-07-17 during plan stress-test: byte-identical-unconditionally is unprovable under CC's own background autoUpdate.)* +- `converge` on a drift repo: previews, confirms, consolidates to user scope via CLI, and the repo's settings diff (if any) is surfaced, not committed. +- New catalog plugin with `install_new: ask`: offered, not auto-installed; with `all`: installed; with `none`: reported only. +- Skill ships evals passing skill-quality schema validation; claude-ops version bumped with CHANGELOG + README entries. + +### Captured assumptions + +- Consumers run CC ≥2.1.193 (renames-map support); older CC degrades to `plugin-not-found` for renamed plugins — report guides upgrade. +- The marketplace's own `renames` map remains the single source of rename truth; skill hard-codes no rename knowledge. +- `install_new` policy applies per marketplace uniformly (no per-plugin allowlist in V1). + +### Out-of-scope (deliberate) + +- Cache-dir cleanup/GC (CC's 7-day orphan sweep owns it; manual deletion unsanctioned). +- Auto-uninstalling catalog-removed plugins (CC-native `renames: null` handles removal; skill reports only). +- Mutating marketplace `autoUpdate` or any CC setting. +- Managed-scope handling (enterprise) beyond reporting. +- Cross-machine fleet orchestration (per-machine command only). +- Per-plugin desired-set manifest (revisit if `install_new` policy proves too coarse). + +### Deferred questions + +- **Does `plugin update -s project` avoid writing the repo's committed `.claude/settings.json`?** — RESOLVED, see Open questions VERIFIED line 1: hash-verified no write. Decision 4 stands, no re-approval needed. +- **Is `/reload-plugins --force` required after sync, or is plain `/reload-plugins` sufficient?** — RESOLVED, see Open questions VERIFIED line 2: no `--force` flag exists; report recommends bare `/reload-plugins`. +- **`all`-marketplaces argument interaction with third-party marketplaces lacking renames maps / non-git sources** — RESOLVED by /architect: graceful degradation — `marketplace update` handles every source type; a marketplace without a `renames` map simply has no rename residue; failures per-marketplace are reported and do not abort the sweep. + +## Plan + +### Phase 1: Verify runtime facts (throwaway spike) [DONE] + +Three unknowns gate later phases; resolve empirically on this machine before authoring semantics. + +1. **`plugin update -s project` committed-file test** (USER-RESERVED gate — **go/no-go for the skill's primary value**: the in-repo update path is the main event, since dual-scope repos load the stale project pin): from `/medley` (a sibling checkout) — note its `.claude/settings.json` is ALREADY dirty, so porcelain status alone cannot detect the write. Method: SHA256 both `.claude/settings.json` and `.claude/settings.local.json` AND copy both to a backup dir; run `claude plugin update markdown-formatter@melodic-software -s project` (also fixes one real drift item: 0.1.3 → latest); re-hash and diff. If either file changed: restore from backup, record diff verbatim, **STOP — Brief Decision 4 needs user re-approval**. +2. **`/reload-plugins` `--force` necessity**: check current CC docs/help for `/reload-plugins` flags; empirically compare `/reload-plugins` vs `--force` after a plugin update. Record which the report should recommend. +3. **userConfig enum support**: fetch `json.schemastore.org/claude-code-plugin-manifest.json`; check whether `userConfig` entries support `enum`. Fallback: string field + prose validation in SKILL.md. +4. **Internal-schema parse contract snapshot**: copy the live shapes of `installed_plugins.json` (per-id array of `{scope, projectPath?, installPath, version, ...}`) and `known_marketplaces.json` into Phase 2 test fixtures, with a CC version-floor note (schema observed on 2.1.211; undocumented internal contract — parser must fail loud on shape drift, never guess). + +**Sanity Check:** PLAN.md "Open questions" contains four lines matching `^- VERIFIED:` (one per unknown), each with observed evidence; item 1's line records both file hashes before/after verbatim; fixture files exist under `plugins/claude-ops/skills/plugins/scripts/fixtures/`. + +### Phase 2: State-inspection script (TDD) [DONE] + +| File | Action | What changes | +|------|--------|-------------| +| `plugins/claude-ops/skills/plugins/scripts/fleet-state.sh` | Create | Read-only state JSON per design-resolution type sketch; `--marketplace ` / `--all`; jq-based; fail-open notice on missing jq | +| `plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh` | Create | Fixture-driven tests written FIRST (Red-Green-Refactor) | + +Fixtures: dual-scope divergence, plugin missing from installs, plugin missing from enabledPlugins, explicit `enabledPlugins: false` opt-out, marketplace absent, malformed/drifted JSON shape (must fail loud), native-Windows `projectPath` vs Git Bash cwd. + +**Path normalization (CRITICAL, from plan review):** `installed_plugins.json` stores `projectPath` in native Windows form (`D:\repos\...`); Git Bash `$PWD` is `/d/repos/...`. In-repo detection MUST normalize both sides or it silently no-ops on Windows. Reuse `hook::normalize_path` from the plugin's synced `hook-utils.sh` copy (`plugins/claude-ops/hooks/hook-utils.sh` — already carried by this plugin; never hand-edit, sync via `scripts/sync-hook-utils.sh` if lib changes are needed). + +**Sanity Check:** `bash fleet-state.test.sh` exit 0 including a Windows path-match assertion (`projectPath: D:\\...` fixture matched from cwd `/d/...`); repo bash-lint hook passes on both files; live run on this machine reports all 46 catalog plugins installed+enabled with zero `missing_*` entries. + +**Evidence:** `fleet-state.test.sh` — 11 cases / 24 assertions, 0 failed (dual-scope divergence, missing-from-install, missing-from-enabled, explicit-`false`-opt-out, marketplace-absent, malformed `installed_plugins.json` and `known_marketplaces.json` shape drift both fail loud at exit 2, native-Windows `projectPath` vs Git Bash cwd `currentProject` match, default-marketplace resolution via `CLAUDE_PLUGIN_ROOT` join including its fail-loud path, jq-missing notice). shellcheck clean on both files; bash-lint's PostToolUse formatter ran on every write, no outstanding diff. Live read-only run against this machine's real state (`--marketplace melodic-software`): 47 catalog plugins (not 46 — the plan's estimate predates `repo-fleet-hygiene` landing), 0 `missing_from_install`, 0 `missing_from_enabled`, 31 `divergences` (expected — dual-scope project/user pins are normal, not a defect). Two real bugs found and fixed during this phase: this host's `jq` build CRLF-terminates even compact single-line output, corrupting anything re-parsed via `--argjson` (fixed with a `jq() { command jq "$@" | tr -d '\r'; }` wrapper); the `enabled` reduce was missing `jq -n`, silently reading the script's own stdin instead of producing output. + +### Phase 3: SKILL.md + context files [DONE] + +| File | Action | What changes | +|------|--------|-------------| +| `plugins/claude-ops/skills/plugins/SKILL.md` | Create | Frontmatter (`user-invocable: true`, `disable-model-invocation: true`, argument-hint, description with "Use when"), Action Router table (sync default / audit / converge, ``/`all` args), terse-report section spec — bulk-divergence collapsed to one line ("N project-scope installs behind user scope → run converge"; per-row detail reserved for genuine conflicts: enable-state mismatch, unknown plugin, failures), `${CLAUDE_PLUGIN_ROOT}` script invocation | +| `plugins/claude-ops/skills/plugins/context/sync.md` | Create | Sync algorithm: marketplace update → in-repo project/local update when cwd is inside a project with installs (PRIMARY value path, per Phase 1 verdict) → user-scope update sweep → install-new per `install_new` policy → enabledPlugins completeness → report. "Missing" = in catalog AND never installed AND not explicitly `false` in any enabledPlugins scope; explicit `false` = deliberate opt-out, reported never flipped; `install_new: all` reinstall-on-every-sync behavior for uninstalled-but-not-disabled plugins documented as a caveat. Concurrency: CLI is the serialization point; re-read state immediately before each mutation (never mutate off a stale snapshot); autoUpdate background sweep may race — report notes it | +| `plugins/claude-ops/skills/plugins/context/converge.md` | Create | Divergence intent preview, per-plugin confirm, CLI execution, committed-diff surfacing; autonomous-session abort per the existing repo convention (`repo-hygiene` clean: `CLAUDE_CODE_REMOTE`, `/loop`, `/schedule` → abort destructive tier; fail-closed when context is uncertain) | +| `plugins/claude-ops/skills/plugins/context/scope-semantics.md` | Create | Verified facts: scope-by-cwd loading, enable-boolean override chain, `list`/`details` version display caveat, committed-vs-machine-local state map, renames-map CC-native behavior (floor ≥2.1.193; prune ≥2.1.121), autoUpdate complement, divergence-is-normal expectation | +| `plugins/claude-ops/skills/plugins/context/gotchas.md` | Create | list/details version lie, native-Windows projectPath, concurrency/TOCTOU, dual-scope-divergence-as-normal, internal-schema drift fail-loud | + +**Sanity Check:** `grep -c '|`sync' SKILL.md` ≥ 1 and router table lists exactly sync/audit/converge; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` returns 0 normative occurrences (marketplace name always resolved dynamically); `grep -c 'CLAUDE_PLUGIN_ROOT' SKILL.md` ≥ 1; frontmatter contains `disable-model-invocation: true`. + +**Evidence:** all four checks pass as written (sync row count 1, router table exactly 3 rows sync/audit/converge, 0 `melodic-software` occurrences, `CLAUDE_PLUGIN_ROOT` count 2, `disable-model-invocation: true` present). `markdownlint-cli2` clean on all 5 files. Frontmatter/router semantics fetched fresh from `code.claude.com/docs/en/skills` this session (2026-07-17) rather than assumed. `claude plugin marketplace update [name]` and per-plugin `claude plugin update -s ` / `install` / `enable` CLI syntax verified empirically against the installed CC 2.1.212 binary's own `--help` output, not just docs prose. One real gap found and fixed while writing `context/sync.md`: `fleet-state.sh`'s `missing_from_install` did not yet honor the Brief's "not explicitly `false` in any enabledPlugins scope" exclusion (only `missing_from_enabled` had it) — fixed, covered by a new test case, live-verified still 0/0 missing on this machine. A second gap found while writing `context/converge.md`: Brief Decision 6 names both version AND enable-state divergence as convergeable, but `fleet-state.sh` only exposes the merged effective `enabled` map, not raw per-scope values needed to detect a true/false split. Scoped V1 `converge` to version divergence only (satisfies the acceptance criteria's "drift repo" test, which is version-based) and documented the enable-state-mismatch gap explicitly in `context/converge.md` and `context/gotchas.md` as a named limitation rather than silently dropping or silently expanding `fleet-state.sh`'s output contract further. + +### Phase 4: Evals [DONE] + +| File | Action | What changes | +|------|--------|-------------| +| `plugins/claude-ops/skills/plugins/evals/evals.json` | Create | 6 cases: routing (bare invocation → sync), audit-is-read-only guardrail, install_new=ask prompting, converge-requires-confirm refusal (autonomous context), divergence report names live-vs-inactive, anti-pattern (never edits installed_plugins.json / cache dirs) | + +**Sanity Check:** `bash plugins/skill-quality/scripts/check-skill.sh plugins/claude-ops/skills/plugins` reports zero WARN across ALL checks (gotchas surface satisfies Check 11); `jq '.evals | length' evals.json` ≥ 5. + +**Evidence:** `check-skill.sh` doesn't actually accept a full path as its argument (its skills-root resolution expects `/`, defaulting to `${CLAUDE_PROJECT_DIR}/.claude/skills`) — the plan's literal invocation string doesn't run as written. Ran the equivalent correctly via `CHECK_SKILL_SKILLS_ROOT="plugins/claude-ops/skills" bash plugins/skill-quality/scripts/check-skill.sh plugins`: `CHECK-SKILL plugins: PASS — 0 errors, 0 warning(s)` (description length 573/1536, all 5 trigger phrases preserved, SKILL.md 130/500 lines, markdownlint clean, `scripts/fleet-state.test.sh` passed as part of the run). `jq '.evals | length'` = 6. + +### Phase 5: Plugin metadata [DONE] + +| File | Action | What changes | +|------|--------|-------------| +| `plugins/claude-ops/.claude-plugin/plugin.json` | Modify | version 0.8.0 → 0.9.0; description includes new skill; `userConfig.install_new` per Phase 1 verdict | +| `plugins/claude-ops/CHANGELOG.md` | Modify | `## [0.9.0]` `### Added` entry | +| `plugins/claude-ops/README.md` | Modify | Skills section row + configuration note | + +**Sanity Check:** `node scripts/validate-plugin-contracts.mjs` (or `bash scripts/validate-plugins.sh`) exit 0; `jq -r .version plugins/claude-ops/.claude-plugin/plugin.json` = `0.9.0`; `grep -c '\[0.9.0\]' CHANGELOG.md` = 1. + +**Evidence:** `node scripts/validate-plugin-contracts.mjs` — "17 setup skills and 1375 plugin files checked", exit 0. Version and CHANGELOG checks both match exactly. One out-of-table gap found by `bash scripts/validate-plugins.sh` (which additionally validates the repo-root catalog block, `validate-plugin-contracts.mjs` alone does not): the repo-root `README.md`'s generated plugin catalog was now stale against `claude-ops`'s updated description. Regenerated via `node scripts/generate-catalog.mjs` (repo-root `README.md` +1/-1 line) — a mechanical, generator-owned fix within this phase's actual scope (plugin-metadata consistency), not a scope expansion. `validate-plugins.sh` now exits 0 end-to-end (every plugin manifest + the marketplace catalog). `markdownlint-cli2` clean on `plugins/claude-ops/README.md`, `CHANGELOG.md`, and repo-root `README.md`. + +### Phase 6: End-to-end verification [DONE] + +1. Install the branch build locally (or `/reload-plugins` against the dev copy per repo dev-loop convention), run `/claude-ops:plugins audit`: verify it issues ZERO mutating CLI calls (transcript inspection) and, absent concurrent sessions/autoUpdate sweeps, both internal JSON files hash identical before/after; report contains all five sections. +2. Run `sync` on this machine (steady state expected: all current) — idempotence: second run reports no changes. +3. Run `audit` from inside medley — divergence section present, bulk case collapsed to the one-line summary with live-vs-inactive versions named for at least the conflict rows. + +**Sanity Check:** audit transcript contains no `plugin install|update|uninstall|marketplace update` invocations; before/after SHA256 of both internal JSON files identical (run with no other CC session live); sync run 2 output contains zero update/install lines; medley audit output contains the collapsed divergence summary line. + +**Evidence:** Used this repo's documented dev-loop convention (`docs/MIGRATION-PLAYBOOK.md` "Local development loop") — `claude --plugin-dir ./plugins/claude-ops --permission-mode dontAsk --allowedTools "Bash" -p "/claude-ops:plugins audit"`, a real headless invocation of the actual skill, not a simulation. + +**Verification boundary (read before treating this phase as "fully end-to-end"):** `audit` is live-verified — every mutating-call-free path, the divergence report shape, and the `currentProject` fix all ran for real against this machine and against medley. `sync` was live-verified for Step 1 (marketplace refresh), Step 3 (user-scope sweep — 2 runs, both 0-update steady-state), and idempotence. **Step 2 (in-repo project/local update — the plan's own "primary value path") never fired**, because both live `sync` runs happened from repos with no project-scope divergence to update. `converge` was **never invoked** in any form beyond `audit`'s prediction text. Exercising those two paths for real requires mutating a real repo with genuine divergence (medley, 29 actionable rows per the Step 3 audit run below) — that's a consequential, only-partly-reversible action against a repo outside this one, so it needs an explicit user decision, not a unilateral one this session. Recorded as an open verification gap, not silently absorbed into "done." + +- **Step 1**: first live run surfaced a real bug — SKILL.md pointed `fleet-state.sh` at `${CLAUDE_PLUGIN_ROOT}/scripts/fleet-state.sh`; the true path is `skills/plugins/scripts/fleet-state.sh`. Fixed, re-ran: all five report sections present (Marketplace/Updated/Installed/Divergences/Action needed), zero `plugin install|update|uninstall|marketplace update` invocations, `installed_plugins.json` and `known_marketplaces.json` SHA256 byte-identical before/after. +- **Step 3** (audit from inside medley) surfaced a second, more significant bug: `currentProject` was `null` on every install record despite standing inside medley — `CLAUDE_PROJECT_DIR` is not reliably exported to a headless `-p` session (verified: `fleet-state.sh` run manually with `CLAUDE_PROJECT_DIR` explicitly set worked correctly, isolating the gap to environment propagation, not the script's normalization logic). Fixed by adding a `git rev-parse --show-toplevel` fallback (CLAUDE_PROJECT_DIR authoritative when set, git toplevel when not, `$PWD` last resort) — the priority order matters: an initial git-first attempt broke test isolation and would have silently preferred the wrong root whenever `CLAUDE_PROJECT_DIR` and cwd's git repo differ. Added a dedicated TDD case for the fallback path (real red→green: failed against the un-fixed script, passed after) — 14 cases / 29 assertions now, all passing. Re-ran the medley audit: `Divergences: 29 actionable here` with named live-vs-inactive versions (`planning` 0.2.0 vs 0.11.1, `claude-ops` 0.1.0 vs 0.8.0, `work-items` 0.1.0 vs 0.7.0, ...) — the bulk-collapse-with-named-conflicts shape the sanity check requires. +- **Step 2** (`sync`, run twice on this machine): first run — 0 updated (all 37 melodic-software user-scope plugins already at latest), 0 installed (catalog fully covered). Surfaced a third finding, not a skill bug but a real CLI quirk: the first sweep attempt failed "Plugin not found" for every plugin; isolated and verified empirically (`claude plugin update bash-lint -s user` fails, `claude plugin update bash-lint@melodic-software -s user` succeeds, back to back, same scope) — a bare plugin name is rejected even when unambiguous; `sync.md`/`gotchas.md` already specified the fully-qualified id form, corrected an initial wrong attribution (blamed cwd/stdin) to the verified real cause. Second `sync` run: `Updated: 0`, `Installed: 0`, "all current" — idempotence confirmed. +- Hash-drift caveat encountered directly, not just theorized: between two audit brackets, `known_marketplaces.json`'s overall hash changed even though `melodic-software`'s own entry (`lastUpdated`) stayed frozen — traced to `anthropic-agent-skills` and `claude-plugins-official` (both `autoUpdate`-relevant) picking up a fresh `lastUpdated` in the same window, i.e. Claude Code's own background sweep touching *other* marketplaces in the shared file, not this skill's audit action. Confirms the Phase 2 evidence caveat ("byte-identical-unconditionally is unprovable under CC's own background autoUpdate") empirically rather than by inference. +- `check-skill.sh` re-run after all Phase 6 fixes: still `PASS — 0 errors, 0 warning(s)` (script tests run as part of that check). +- `bash scripts/validate-plugins.sh` caught a real repo-agnostic-design violation the earlier per-file greps missed: `gotchas.md`'s CLI-quirk example hardcoded `bash-lint@melodic-software` as a worked example — the repo's own `validate-plugin-contracts.mjs` rejects `@melodic-software` literals anywhere under `skills/*.md` ("reusable skill content must not require publisher-specific runtime identifiers"). Genericized to `@` placeholders; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` back to 0 matches; `validate-plugins.sh` now exits 0 end-to-end. +- **Post-Phase-6 fix** (found by `/verification:confirm`'s independent fresh-context verifier, not by this session's own review): `converge.md`/`SKILL.md`'s "Action needed" report spec instructed reporting an enable-state mismatch (`true` in one scope, `false` in another) as a "known limitation — resolve manually," which implies the skill can *detect* the mismatch and only defers the *fix*. In reality `fleet-state.sh` never exposes each scope's raw `enabledPlugins` map (only the merged effective value in `enabled`) — the mismatch cannot be detected from its output at all, so it can never actually be reported. `evals.json`'s eval 5 masked this: it handed the mismatch to the model directly in prompt text, so it validated the model echoing a told fact, not a real capability. Fixed: `converge.md`'s V1-scope note now says "cannot even detect," `SKILL.md`'s report spec drops "enable-state mismatches" from the reportable "Action needed" categories (with a pointer to the blind-spot note), and eval 5's scenario was swapped to an unknown/orphaned-plugin-id conflict (a case `fleet-state.sh`'s raw `catalog`/`installed`/`enabled` arrays genuinely support deriving) plus an explicit expectation that the response never claims to detect an enable-state mismatch. Re-verified: `jq empty evals.json`, `validate-plugins.sh`, and `check-skill.sh` all clean after the edit. + +## Blast radius + +LOW — additive skill in one plugin; no existing skill/hook/lib modified (plugin.json/CHANGELOG/README additive edits). Runtime mutations are user-invoked, CLI-mediated, reversible; the one committed-settings hazard is fenced behind `converge` confirm + Phase 1 USER-RESERVED gate. + +## Stress-test summary + +Plan-reviewer sub-agent (fresh context): 1 CRITICAL + 6 IMPORTANT + 3 SUGGESTION. All applied except: #4 partially (medley test kept — it exercises a real stale pin a fresh throwaway cannot manufacture; method hardened to hash-compare + backup/restore) and #6 downgraded (reviewer missed the existing `CLAUDE_CODE_REMOTE` autonomous-abort convention at `repo-hygiene/skills/clean/context/action-router.md:58` — converge aligns with it, fail-closed, no new research phase). CRITICAL #1 (native-Windows `projectPath` vs Git Bash cwd — in-repo detection would silently no-op) fixed in Phase 2 via `hook::normalize_path` reuse + test assertion. /devils-advocate: skipped — blast radius LOW, no triggers matched. + +## Execution shape + +Fully sequential: 1 → 2 → 3 → 4 → 5 → 6 — Phase 1 verdicts gate Phase 3 semantics; Phase 3 cites Phase 2's script interface; 4/5 document 3; 6 verifies all. Parallelism opportunity immaterial (<100 LOC independent). + +| Phase | Surface | Basis | +|---|---|---| +| 1 | main-session | mutating empirical test + USER-RESERVED stop gate needs the user present | +| 2 | main-session | small TDD unit; dispatch overhead exceeds saving | +| 3–5 | main-session | judgment-heavy authoring against conventions | +| 6 | main-session | drives live machine state + user-visible reload | + +## Open questions + +- VERIFIED: `plugin update -s project` does NOT write committed `.claude/settings.json` or `.claude/settings.local.json`. Empirical test in a real dual-scope repo (`markdown-formatter@melodic-software`: project scope 0.1.3, user scope 0.1.9). Before: `settings.json` sha256 `367ad75f642282ebc0d467fe932b530a74755a55fdad5ee9fef6f030c775169e`, `settings.local.json` sha256 `7f026819d5a24c05d61df0a5a77b186a08fd25ee90fb20f384ddda12dbf6eb9b`. Ran `claude plugin update markdown-formatter@melodic-software -s project` (CC 2.1.212) → project scope updated 0.1.3 → 0.1.9. After: both hashes byte-identical to before. Only `installed_plugins.json`'s project-scope record changed (version/installPath/lastUpdated/gitCommitSha). **Brief Decision 4 confirmed safe — no re-approval needed.** +- VERIFIED: no `--force` flag exists for `/reload-plugins`. `code.claude.com/docs/en/plugins-reference` (fetched 2026-07-16) documents bare `/reload-plugins` as the command that refreshes skills, agents, hooks, and MCP/LSP servers in-process, and separately notes **monitors require a full session restart** (not covered by `/reload-plugins` at all). No `--force` variant appears anywhere in the plugins/skills/commands reference pages; targeted web search corroborates no such flag exists. Report guidance: recommend bare `/reload-plugins`; call out session-restart separately only when a monitor is in play. +- VERIFIED: `userConfig` schema has no `enum` field. `www.schemastore.org/claude-code-plugin-manifest.json` (redirected from `json.schemastore.org`, fetched 2026-07-16): allowed `type` values are `string | number | boolean | directory | file`; entry properties are `type, title, description, required, default, multiple, sensitive, min, max` with `additionalProperties: false` — an `enum` key would fail schema validation. Fallback confirmed: `install_new` ships as `type: string`, `default: "ask"`, valid values (`ask`/`all`/`none`) documented in `description` + prose-validated in SKILL.md. +- VERIFIED: internal-schema shapes snapshotted live on CC 2.1.212 (2026-07-17). `installed_plugins.json`: `{version, plugins: {"@": [{scope: "user"|"project"|"local", projectPath?, installPath, version, installedAt, lastUpdated, gitCommitSha}]}}` — array length > 1 per plugin id is the divergence signal. `known_marketplaces.json`: `{"": {source: {source: "github", repo}, installLocation, lastUpdated, autoUpdate?}}` — `autoUpdate` observed absent-by-default on one real marketplace and `true` on another, confirming Brief Decision 8's "report + suggest enabling" framing (never assume a default). Scrubbed fixtures capturing these shapes: `plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json`, `known_marketplaces.sample.json`. + +## Handoff to implementation + +### User-approval gates + +- Phase 1 item 1: if `plugin update -s project` writes the committed `.claude/settings.json` → STOP, re-approve Brief Decision 4. +- Any scope expansion beyond the Files Affected tables. + +### Execution shape ([EXEC-SHAPE] tagged) + +- [EXEC-SHAPE] Hybrid script/prose split: one read-only `fleet-state.sh` (repeatable-inspection convention) + model-driven CLI mutations (judgment + confirms involved). +- [EXEC-SHAPE] `disable-model-invocation: true` for the whole skill V1 (setup-skill precedent; mutating fleet ops are user-intent). +- [EXEC-SHAPE] `all`-marketplaces degradation: per-marketplace failures reported, sweep continues. +- [FALLBACK — confirm or override] userConfig `install_new` as enum; falls back to string + prose validation if the manifest schema lacks enum support (Phase 1 item 3 decides). + +### Mechanical work + +- Branch: create `feat/claude-ops-plugins-skill` from latest `origin/main` (current checkout sits on an unrelated feature branch — use a worktree per repo convention if that branch stays active). +- Commit boundaries: one commit per phase; PLAN.md tag updates ride the same commit. +- Sequential fallback: n/a (no parallel shape). diff --git a/docs/topics/plugin-fleet-sync-skill/design/design-resolution.md b/docs/topics/plugin-fleet-sync-skill/design/design-resolution.md new file mode 100644 index 000000000..9dd25aade --- /dev/null +++ b/docs/topics/plugin-fleet-sync-skill/design/design-resolution.md @@ -0,0 +1,39 @@ +# Design resolution — plugin-fleet-sync-skill + +outcome: early-exit (Tier B) + +Reason: the contract surface was fully resolved by the /interview Brief (PLAN.md `## Brief`, +Decisions 1–8) backed by three verified research passes (official CC docs, repo conventions, +scope-precedence empirics). No new code types or module topology — the deliverable is a +markdown skill orchestrating the `claude plugin` CLI plus one read-only state-inspection +script. Type surface is small enough to sketch here; full /design would re-derive the Brief. + +## Type sketch + +**Action surface** (router in SKILL.md): + +- `sync [marketplace|all]` — default; mutating via CLI only +- `audit [marketplace|all]` — read-only dry-run of sync + converge predictions +- `converge` — explicit scope consolidation; only committed-settings-touching action + +**userConfig** (plugin.json, personal policy scalar): + +- `install_new`: `ask` (default) | `all` | `none` — enum support in the manifest schema is a + Phase-1 verification; fallback is a string field validated in skill prose. + +**State-inspection output** (read-only script → JSON consumed by the skill): + +```json +{ + "marketplace": {"name": "...", "autoUpdate": false, "lastUpdated": "..."}, + "catalog": ["plugin", "..."], + "installed": [{"id": "plugin@mp", "scope": "user|project|local", "version": "...", "projectPath": "..."}], + "enabled": {"plugin@mp": true}, + "missing_from_install": ["..."], + "missing_from_enabled": ["..."], + "divergences": [{"id": "...", "scopes": [{"scope": "...", "version": "...", "projectPath": "..."}]}] +} +``` + +Inputs are internal CC state files read-only (`installed_plugins.json`, `known_marketplaces.json`, +marketplace `marketplace.json`, settings `enabledPlugins`); every mutation goes through the CLI. diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index e5594e56d..43ed3e025 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,14 +1,14 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.8.0", - "description": "Claude Code operations toolkit. Four skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), troubleshoot (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), and a re-runnable setup action that settles where the troubleshooting registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", + "version": "0.9.0", + "description": "Claude Code operations toolkit. Five skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), troubleshoot (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 — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), and a re-runnable setup action that settles where the troubleshooting registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) 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", "email": "info@melodicsoftware.com" }, "license": "MIT", - "keywords": ["claude-code", "operations", "observability", "otel", "troubleshooting", "changelog", "monitoring", "hooks", "telemetry", "audit"], + "keywords": ["claude-code", "operations", "observability", "otel", "troubleshooting", "changelog", "monitoring", "hooks", "telemetry", "audit", "plugins", "marketplace"], "userConfig": { "registry_dir": { "type": "string", @@ -19,6 +19,11 @@ "type": "string", "title": "Skill-usage log directory (project-relative)", "description": "Optional contained project-relative directory where the skill-usage-audit hook writes skill-usage.jsonl. Absolute, drive, UNC, traversal, and escaping-symlink paths are invalid. Leave unset to use .claude/observability." + }, + "install_new": { + "type": "string", + "title": "New-plugin install policy for the plugins skill's sync action", + "description": "Controls what `sync` does with catalog plugins that aren't installed yet. Valid values: \"ask\" (default — offer them in one batched multi-select prompt), \"all\" (install every one automatically), \"none\" (report only, never install). The manifest schema has no enum type, so this validates in prose, not JSON Schema; any other value is treated as \"ask\"." } } } diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 019277c98..fadbcb5bc 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,21 @@ 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.9.0] + +### Added + +- New `plugins` skill (`/claude-ops:plugins`): brings a machine's plugin fleet current on demand. + `sync` (default) refreshes marketplaces, updates in-repo project/local-scope installs plus the + user-scope sweep, installs new catalog plugins per the `install_new` policy, and fills any + `enabledPlugins` completeness gap — all CLI-mediated, never hand-editing Claude Code's internal + state files. `audit` runs the same algorithm read-only. `converge` is the one action that can + touch a committed `.claude/settings.json`: it detects actionable (version-behind) scope + divergence, previews and confirms per plugin, then surfaces the resulting diff for review — never + auto-committed, and it aborts outright in an autonomous session. Adds a read-only + `scripts/fleet-state.sh` state-inspection script and the `install_new` userConfig scalar + (`ask` default / `all` / `none`). + ## [0.8.0] ### Changed diff --git a/plugins/claude-ops/README.md b/plugins/claude-ops/README.md index dee01aa18..4e47f233f 100644 --- a/plugins/claude-ops/README.md +++ b/plugins/claude-ops/README.md @@ -1,12 +1,13 @@ # claude-ops A Claude Code plugin for running Claude Code well over time — one cohesive -capability across four skills and a family of telemetry-emitter hooks. +capability across five skills and a family of telemetry-emitter hooks. Observability reads what your sessions actually did, troubleshooting tracks what upstream has broken, changelog integration keeps your repo current with what -upstream has shipped, a re-runnable `setup` action settles where the -troubleshooting registry lives, and the `*-audit` hooks feed observability with -per-hook execution telemetry Claude Code's native OTEL cannot see. +upstream has shipped, the plugins skill keeps your own plugin fleet current, a +re-runnable `setup` action settles where the troubleshooting registry lives, +and the `*-audit` hooks feed observability with per-hook execution telemetry +Claude Code's native OTEL cannot see. ## Skills @@ -15,6 +16,7 @@ per-hook execution telemetry Claude Code's native OTEL cannot see. | `/claude-ops:observability` | Reads locally captured Claude Code telemetry — OTEL DuckDB store, machine-owned collector, optional Aspire dashboard, hook-event JSONL, ccusage — and renders cross-session trend reports (`session`/`day`/`week`/`month`/`since:`/`all` scopes). Read-only except the explicit `clean` action, which prunes the JSONL log and OTEL store by age. | | `/claude-ops:troubleshoot` | Searches known Claude product GitHub bugs before you build on a feature, checks service health and model quality, and maintains a persistent registry of tracked issues (what they block, workarounds, follow-ups when fixed). Actions: `status` (default), `search`, `check-all`, `scan`, `list`, `quality`, `create`. | | `/claude-ops:changelog` | Ingests Claude Code changelog entries and integrates them into the current repo: `fetch` (read-only display), `diff` (impact triage, no edits), `status` (applied versions from git history), and `apply` (full explore → research → interview → implement pipeline, explicit user intent only). | +| `/claude-ops:plugins` | Brings a machine's plugin fleet current on demand: marketplace refresh, updates for the plugins that actually load (including in-repo project/local-scope installs), new-catalog-plugin install per policy, and scope-divergence detection. Actions: `sync` (default, CLI-mediated mutations only), `audit` (read-only dry run), `converge` (the one action that can touch a committed `.claude/settings.json` — previews and confirms per plugin first). | | `/claude-ops:setup` | Validates the troubleshooting-registry and skill-usage-log destinations, including path containment, and routes personal option changes through Claude Code's plugin configuration prompt. | ## The audit hooks @@ -120,8 +122,13 @@ instead of failing. ## Configuration -Two `userConfig` options: +Three `userConfig` options: +- **`install_new`** (string, optional) — new-catalog-plugin install policy for the `plugins` + skill's `sync` action. `ask` (default) offers not-yet-installed catalog plugins in one batched + multi-select prompt; `all` installs every one automatically; `none` reports them without + installing. The manifest schema has no `enum` type, so this validates in prose, not JSON Schema; + any other value is treated as `ask`. - **`registry_dir`** (string, optional) — project-relative directory for the troubleshoot issue registry (`registry.json`). Set it to keep the registry inside your repo (git-tracked, team-shared) instead of the diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md new file mode 100644 index 000000000..898c828bc --- /dev/null +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -0,0 +1,141 @@ +--- +name: plugins +description: "Bring a machine's plugin fleet current on demand: marketplace refresh, update the plugins that actually load (including in-repo project/local-scope installs), install new catalog plugins per policy, detect scope divergence, and surface (never silently fix) drift — with a terse actionable report. Actions: sync (default, mutating), audit (read-only dry run), converge (explicit scope consolidation). Use when: 'sync plugins', 'update my plugins', 'are my plugins current', 'check plugin drift', 'converge plugin scopes', or before relying on a plugin that might be stale." +argument-hint: "[action] [|all] — actions: sync (default), audit, converge" +user-invocable: true +disable-model-invocation: true +--- + +## Variables + +Arguments: `$ARGUMENTS` + +## Scope + +Guarantees that the plugins which actually load — for the machine's user scope, and for any repo +you're standing in with its own project/local-scope installs — are the latest published versions of +everything the marketplace offers, and surfaces any state where something older or unintended is +what really runs. + +Distinct from what Claude Code's own background `autoUpdate` does (see +[context/scope-semantics.md](context/scope-semantics.md)): `autoUpdate` silently refreshes marketplace +data and bumps already-installed plugins post-startup. It never installs a new catalog plugin, never +checks `enabledPlugins` completeness, never detects or reports scope divergence, and only runs once +per session start on its own schedule — not on demand. This skill covers exactly that gap. + +Distinct from `claude-config`'s `audit` skill's plugin-drift check: that check compares a project's +committed `enabledPlugins` against a marketplace's *upstream* `marketplace.json` (orphan/new/rename +plugin names). This skill compares the *local, already-installed* state (`installed_plugins.json`, +per-scope `enabledPlugins`) against the *local* marketplace catalog — a different axis (install/scope +completeness, not settings-vs-upstream drift). + +**Never silently fixes drift it finds.** `sync` mutates only via the documented CLI actions below; +`converge` is the one action that can touch a committed `.claude/settings.json`, and only after an +explicit per-plugin confirm. + +## Action Router + +Parse `$ARGUMENTS` for the action (first token) and an optional marketplace target (second token: +a marketplace name, or `all`). + +| Action | Mutates | Description | Detail | +|---|---|---|---| +| `sync` (default) | Yes — CLI only | Marketplace refresh → in-repo update → user-scope update sweep → install new per policy → enabledPlugins completeness → report | [context/sync.md](context/sync.md) | +| `audit` | No | Same algorithm as `sync`, every mutating step replaced with a prediction; issues zero mutating CLI calls | "Action: audit" below | +| `converge` | Yes — the one action that can touch committed settings | Detects any plugin id with an actionable (non-benign) scope divergence, previews per-plugin intent, confirms, executes, surfaces the resulting diff | [context/converge.md](context/converge.md) | + +Bare invocation (no arguments) → `sync` against the default marketplace. `help` or an unrecognized +action → show this table. + +## Marketplace resolution + +No hardcoded marketplace name anywhere in this skill. Every action resolves its target the same way: + +- No marketplace argument → the default: the marketplace this plugin (`claude-ops`) was itself + installed from, resolved dynamically by `fleet-state.sh` (joins `${CLAUDE_PLUGIN_ROOT}` against + `installed_plugins.json`'s install records — never a hardcoded name). +- `` argument → that marketplace only. +- `all` argument → every marketplace in `known_marketplaces.json`; per-marketplace failures are + reported inline and never abort the sweep (see [context/sync.md](context/sync.md)). + +## State inspection + +Every action starts by calling the bundled read-only script — never hand-parse the internal JSON +files directly, and never write them: + +```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh [--marketplace | --all] +``` + +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. + +## Action: audit + +Read-only dry run of what `sync` (and, where relevant, `converge`) would do. Run the full algorithm +in [context/sync.md](context/sync.md) with every mutating CLI call replaced by "would run: ``" +in the report — call `fleet-state.sh`, compute the same install/enable/divergence deltas, but issue +**zero** `plugin install|update|uninstall|marketplace update` invocations. Predict `converge`'s +per-plugin intent (context/converge.md's preview step) the same way, without executing it. State-file +contents (`installed_plugins.json`, `known_marketplaces.json`, committed settings) are unchanged by +an `audit` run, modulo any concurrent session or background `autoUpdate` sweep — note that caveat in +the report rather than asserting byte-identical files. + +## Report + +Terse, fixed sections. Detail only where action is required — do not enumerate rows that need no +action. + +```text +Marketplace: (autoUpdate: ) +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` + (N = actionable only — versionsMatch:false; same-version multi-scope installs are not counted + or listed here) +Action needed: (omit section entirely when empty) +``` + +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".) + +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 +[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. + +## userConfig: `install_new` + +Controls new-catalog-plugin install policy during `sync`. Ships as a plain `string` (the manifest +schema has no `enum` type — verified against the published schema), default `"ask"`: + +- `ask` (default) — offer every not-yet-installed catalog plugin in one batched multi-select prompt +- `all` — install every not-yet-installed catalog plugin automatically +- `none` — report them in "Action needed" only, never install + +Any other value is invalid; treat it as `ask` and note the invalid value in the report. + +**Configured value: `${user_config.install_new}`** — a `userConfig` value only reaches this skill's +own content through this literal `${user_config.KEY}` substitution (Claude Code text-substitutes it +before the model sees the rendered skill); declaring the option in `plugin.json` alone does not make +its value readable here. Sync's Step 4 branches on this line's rendered value, not on the option's +name or description above. + +## Cross-references + +- [context/sync.md](context/sync.md) — full `sync` algorithm +- [context/converge.md](context/converge.md) — scope-consolidation flow, confirm gate, autonomous-session abort +- [context/scope-semantics.md](context/scope-semantics.md) — verified CC scope/version/reload facts this skill depends on +- [context/gotchas.md](context/gotchas.md) — known failure modes and how this skill avoids them diff --git a/plugins/claude-ops/skills/plugins/context/converge.md b/plugins/claude-ops/skills/plugins/context/converge.md new file mode 100644 index 000000000..ab3e72961 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/converge.md @@ -0,0 +1,92 @@ +# Converge — explicit scope consolidation + +`converge` is the **only** action that can touch a committed `.claude/settings.json`. It never runs +implicitly from `sync` — `sync`'s report only names the `converge` command; the user runs it +explicitly. + +## Autonomous-session abort (run this check FIRST, before any preview work) + +`converge` is destructive-tier (it can uninstall a scoped plugin install and rewrite committed +settings). Per this repo's existing convention (`repo-hygiene`'s `clean` skill, preflight §1.5): +**abort immediately** when the session is autonomous — `CLAUDE_CODE_REMOTE` set, or the invocation +arrived via `/loop` or `/schedule` — since no human is present to receive an `AskUserQuestion` +confirm. Fail closed when the context is genuinely ambiguous (uncertain whether a human is present): +treat it as autonomous and abort. Report why, and that `converge` can be re-run interactively. + +## 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 +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 +fix: never claim the report surfaces an enable-state mismatch, and never hand-parse the settings +files directly to work around the gap — the fix is extending `fleet-state.sh` to expose the raw +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`. + +## Step 2 — Preview per-plugin intent + +For each actionable divergence, first find which of its `scopes[]` holds the **highest version** +(compare `scopes[].version` — semver dotted-numeric compare, not string/lexicographic). Never choose +a strategy from scope identity alone ("does a user entry exist") without this comparison first — +`fleet-state.sh` only proves the scopes *disagree*, never that `user` scope is the newer one. A repo +pinning `project: 0.9.0` against a stale `user: 0.8.0` has the project pin as the newest version +present; uninstalling it to "fall through to user scope" would regress the effective loaded version, +the opposite of bringing the fleet current. + +Then decide the consolidation strategy: + +- **`user` scope holds the highest version** → the default strategy is to make the *project/local* + scope fall through to it: `claude plugin uninstall -s project` (or `-s local`) removes the + redundant lower-precedence pin, and scope precedence (local > project > user) means the project + now loads whatever `user` scope has — always current from here on without a standing project pin. +- **A `project`/`local` scope holds the highest version** (including when there's no `user`-scope + entry at all — only multiple `project`/`local`-scope pins across different repos) → the default + strategy is to bring every lagging scope, `user` scope included, up to that version: + `claude plugin update -s ` for each scope below the highest. + +**Every `project`/`local`-scope command targets its row's own `scopes[].projectPath`, never the +current working directory.** `-s project`/`-s local` have no path/target flag — the CLI always +operates on the *current directory's* `.claude/settings*.json`. A divergence row can legitimately +belong to a different repo than the one this session is standing in (the "elsewhere on this machine" +rows a bulk report collapses) — never construct the proposed command as a bare +`claude plugin uninstall|update -s project`, only as +`(cd "" && claude plugin uninstall|update -s project)`. Presenting or +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. + +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. + +## Step 3 — Confirm + +Use `AskUserQuestion` per plugin (or a clearly-enumerated batch the user can approve/override/skip +per row — never a single blanket "yes to all"). Options per plugin: apply the proposed strategy, +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. + +## Step 5 — Surface the resulting diff + +After all confirmed mutations run, `git diff` (or the equivalent status check) any project's +committed `.claude/settings.json` that `-s project` mutations may have touched. Per +[scope-semantics.md](scope-semantics.md), a plain `claude plugin update -s project` does **not** +write committed settings — but `claude plugin uninstall -s project` (this action's actual mechanism) +can remove an `enabledPlugins` entry from it. Show the diff; never commit it. The user reviews and +commits (or discards) it through their own normal git workflow. + +## Non-interactive execution + +Any `uninstall` needs `-y` when stdin/stdout isn't a TTY (required by the CLI itself). This only +applies once Step 3's confirm has already been obtained through the interactive flow above — never +add `-y` to bypass Step 3's per-plugin confirm. diff --git a/plugins/claude-ops/skills/plugins/context/gotchas.md b/plugins/claude-ops/skills/plugins/context/gotchas.md new file mode 100644 index 000000000..74d345fac --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/gotchas.md @@ -0,0 +1,71 @@ +# Gotchas + +Failure modes this skill is specifically built to avoid, and what breaks if the safeguard is +bypassed. Underlying facts are in [scope-semantics.md](scope-semantics.md) — this file is the +"here's what goes wrong" companion, not a restatement. + +## `claude plugin update ` (bare) fails "Plugin not found" — always pass the full id + +**Verified empirically** (`claude plugin update -s user` → `Plugin "" not found`; +`claude plugin update @ -s user` → succeeds, same scope, same machine, back to +back). A bare plugin name is not enough for `update` even when it's unambiguous on this machine — +always pass the fully-qualified `@` id, exactly as `fleet-state.sh`'s `installed[]` +and `catalog`-joined ids already are. `sync.md`'s Step 3 and `converge.md`'s CLI examples already use +the fully-qualified form for this reason — never shorten an id to the bare name when constructing an +actual `claude plugin update|install|uninstall|enable` command, even for readability in a report. + +## Trusting `plugin list` / `plugin details` for "what's loaded here" + +Both show the highest installed version across every scope, not the cwd-effective one. Reporting a +plugin as "current" based on their output can be wrong for any project with its own project/local +scope pin. Always derive effective-version claims from `fleet-state.sh`'s `currentProject` flag and +scope precedence, never from `list`/`details` text. + +## Native-Windows `projectPath` vs Git Bash `$PWD` + +`installed_plugins.json` stores `projectPath` in native Windows form (`D:\repos\...`); a Bash-tool +`$PWD` reads POSIX form (`/d/repos/...`). A naive string-equality check between the two silently +never matches on Windows — the in-repo detection this skill's primary value depends on (Step 2 of +`sync.md`) would quietly no-op, and nobody would notice because the *rest* of sync (marketplace +refresh, user-scope sweep) still runs and still produces *a* report. `fleet-state.sh` avoids this by +routing both sides through `hook::normalize_path` (from the plugin's own `hooks/hook-utils.sh` +copy) before comparing — empirically verified to fold both representations to the identical +canonical string. Never hand-roll a separate path comparison anywhere else in this skill; always go +through the `currentProject` field `fleet-state.sh` already computed. + +## Concurrency / TOCTOU + +`fleet-state.sh`'s output is a snapshot. A background `autoUpdate` sweep (random delay up to ten +minutes after session start) or a concurrent Claude Code session can mutate installed/enabled state +between when you read it and when you act on it. Re-read state immediately before each mutating +step (`sync.md`'s "Concurrency" section, `converge.md` Step 4) rather than driving a whole multi-step +sync off one snapshot taken at the start. When a mutation's actual result doesn't match what the +snapshot predicted, that's this race — note it in the report, don't treat it as a bug to chase. + +## 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. + +## Internal-schema drift — fail loud, never guess + +`installed_plugins.json` and `known_marketplaces.json` are Claude Code's *internal* state — not a +published, versioned contract. `fleet-state.sh` validates their top-level shape +(`{plugins: {...}}` / a JSON object) before trusting them, and exits 2 with a clear message on a +mismatch rather than silently emitting an empty or wrong report. If a future Claude Code version +changes this shape, that exit-2 failure is the signal to re-verify against a live install (not +training-data recall) and update the parser — never widen the shape check to "whatever doesn't +crash the script." + +## This host's `jq` build CRLF-terminates its output + +Discovered empirically while implementing `fleet-state.sh` (Windows/MSYS `jq`): even single-line +compact JSON output ends `\r\n`, not just `\n`. `$(...)` command substitution strips only the +trailing `\n`, so a stray `\r` survives at the end of a captured value and corrupts it once +re-embedded in another `--argjson` argument (`jq: invalid JSON text passed to --argjson`). Any new +script in this skill that shells out to `jq` and captures its output should route every call through +the same `jq() { command jq "$@" | tr -d '\r'; }`-style wrapper `fleet-state.sh` already uses — +don't rediscover this the hard way in a second script. diff --git a/plugins/claude-ops/skills/plugins/context/scope-semantics.md b/plugins/claude-ops/skills/plugins/context/scope-semantics.md new file mode 100644 index 000000000..7fbd49a86 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/scope-semantics.md @@ -0,0 +1,93 @@ +# 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 during this skill's implementation, not assumed from training data. Re-verify against +`code.claude.com/docs/en/plugins-reference` and `discover-plugins` if Claude Code's plugin CLI +changes shape. + +## Scope-by-cwd loading + +A plugin loads for a given working directory using scope precedence **local > project > user**: + +| Scope | Settings file | Written by | +|---|---|---| +| `user` | `~/.claude/settings.json` | `claude plugin install\|update -s user` (default scope) | +| `project` | `/.claude/settings.json` | `claude plugin install\|update -s project` — committed, team-shared | +| `local` | `/.claude/settings.local.json` | `claude plugin install\|update -s local` — gitignored, personal | +| `managed` | Managed settings (enterprise) | Administrators only — this skill reports, never mutates | + +`installed_plugins.json` (`~/.claude/plugins/installed_plugins.json`) can hold multiple install +records for the same `@` id — one per scope, each with its own `version`. This +is normal, not a defect: a project can legitimately pin a different version than your personal user +scope. The record that actually loads for a given directory is the one at the *highest-precedence +scope present*, never simply "the newest version installed." + +## Divergence is not automatically actionable + +`fleet-state.sh`'s `divergences[]` lists every plugin id with more than one scope record, but tags +each with `versionsMatch`: + +- `versionsMatch: true` — every scope pins the identical version. Benign, informational only. Do + not count these toward a report's "N behind" line or list them under "Action needed." +- `versionsMatch: false` — scopes disagree on version. This is the actionable signal `sync`'s report + surfaces and `converge` resolves. + +A raw count of `divergences[].length` conflates the two and overstates drift — always filter on +`versionsMatch == false` before presenting a count to the user. + +## `plugin list` / `plugin details` version output is misleading + +**Verified misleading**: `claude plugin list` and `claude plugin details ` show the *highest +installed version across all scopes*, not the version actually loaded for the current working +directory. Never treat their output as "what's running here." Derive effective-version claims from +`fleet-state.sh`'s scope-by-cwd resolution (via `currentProject` + scope precedence) or a functional +probe — never from `list`/`details` text. + +## `plugin update -s project` does NOT write committed settings + +**Empirically verified** (hash-compared a real repo's `.claude/settings.json` and +`.claude/settings.local.json` before and after): `claude plugin update -s project` updates only +the machine-local `installed_plugins.json` record. `enabledPlugins` carries no version — the +committed settings files are untouched by an update. `sync`'s in-repo update step is safe to run +without a settings-diff review; `converge`'s scope-*consolidation* is the one action that can add or +remove an `enabledPlugins` entry, and only that action surfaces a settings diff. + +## `/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, +agents, hooks, MCP, and LSP servers in-process. It does **not** cover monitors — a monitor requires a +full session restart. Recommend bare `/reload-plugins` by default; call out the restart requirement +only when an updated plugin ships a monitor. + +`--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. + +## `userConfig` has no `enum` field + +**Verified against the published plugin-manifest JSON Schema**: allowed `type` values are `string`, +`number`, `boolean`, `directory`, `file` — no `enum`. `install_new` ships as `type: string` with its +valid values (`ask`/`all`/`none`) documented in `description` and validated in prose by this skill, +not by the manifest schema. + +## Renames are CC-native (≥ v2.1.193) + +Claude Code rewrites a marketplace's `renames` map into installed/enabled state automatically at +session start (old id → new id; `null` means removal). This skill hard-codes no rename knowledge — +its only rename-adjacent behavior is that anything present in the current catalog but absent from +`installed_plugins.json` shows up as `missing_from_install`, which naturally covers a renamed +plugin's new id. `claude plugin prune` requires ≥ v2.1.121; renames mapping requires ≥ v2.1.193. + +## `autoUpdate` is a background complement, not a substitute + +Official-Anthropic marketplaces default `autoUpdate: true`; third-party and local-dev marketplaces +default it off (absent from `known_marketplaces.json`, not `false`). When on, Claude Code refreshes +marketplace data and bumps already-installed plugins once per session start, after a random delay of +up to ten minutes. This skill never mutates the setting — it only reports the marketplace's current +`autoUpdate` state and suggests enabling it when off, since it never overlaps with what this skill +covers (new-plugin install, `enabledPlugins` completeness, divergence detection/convergence, +deterministic on-demand execution). diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md new file mode 100644 index 000000000..e85212304 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -0,0 +1,129 @@ +# Sync algorithm + +`sync` is the default action: bring the effective fleet current where you stand. Every step below +is CLI-mediated — never edit `installed_plugins.json`, `known_marketplaces.json`, or any +`.claude/settings*.json` directly. `audit` runs this same sequence with every mutating call replaced +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. + +## Step 1 — Marketplace refresh + +For each target marketplace (the resolved default, the named one, or every marketplace when the +argument is `all`): + +```bash +claude plugin marketplace update +``` + +Self-heals a stale or corrupt local clone by re-fetching from the marketplace's registered source +(per Brief Decision 4 — no manual re-clone or cache surgery). In `all` mode, loop this per +marketplace name (rather than the bulk no-argument form) so a single marketplace's failure is +attributable and reported inline without aborting the sweep for the rest. + +## Step 2 — In-repo update (the primary value path) + +Always call `fleet-state.sh` first — never gate this step on `CLAUDE_PROJECT_DIR` being set before +calling it. `fleet-state.sh` resolves the project root itself (`CLAUDE_PROJECT_DIR` when set, the +cwd's git toplevel otherwise — see [gotchas.md](gotchas.md)), so a headless session where the env var +is unset can still 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 +them**, unconditionally: + +```bash +claude plugin update -s project # for a currentProject:true entry with scope "project" +claude plugin update -s local # for a currentProject:true entry with scope "local" +``` + +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 +-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 (from `fleet-state.sh`'s +`installed[]`, `scope == "user"`), run: + +```bash +claude plugin update -s user +``` + +`` here is always the fully-qualified `@` form `fleet-state.sh` already +emits — a bare name fails with "Plugin not found" even when unambiguous (see +[gotchas.md](gotchas.md)). + +One call per plugin — `claude plugin update` takes a single `` argument, there is no bulk +"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. + +## Step 4 — Install new catalog plugins (per `install_new` policy) + +Take `fleet-state.sh`'s `missing_from_install` (already excludes anything explicitly opted out with +`enabledPlugins: false` in any scope — never re-offer a deliberate decline). Apply the configured +policy — SKILL.md's `${user_config.install_new}` line renders the actual value; that render, not this +step's prose, is what to branch on: + +- **`ask`** (default) — present every entry in one batched `AskUserQuestion` multi-select, then + `claude plugin install -s user` for each the user picks +- **`all`** — `claude plugin install -s user` for every entry, no prompt +- **`none`** — install nothing; list the entries under "Action needed" in the report only + +**Caveat (document, don't silently absorb):** with `install_new: all`, a catalog plugin that's +installed and then *disabled* (not uninstalled — `enabledPlugins: false` still recorded, install +record still present) is correctly excluded (it's not in `missing_from_install`, it's an installed, +opted-out plugin). But a plugin that's *uninstalled entirely* without ever setting `false` reappears +in `missing_from_install` on the very next sync and gets reinstalled — `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`. + +## Step 5 — `enabledPlugins` completeness + +Take `fleet-state.sh`'s `missing_from_enabled` — 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 +[scope-semantics.md](scope-semantics.md)); no explicit `enabledPlugins` entry for one of those ids is +the *intended* state, not a completeness gap — never run `enable` for it. This only catches the +default recorded in the marketplace entry; a plugin whose `defaultEnabled: false` lives only in its +own `plugin.json`, with no mirrored marketplace-entry override, is a known residual gap (`fleet-state.sh` +reads the marketplace's catalog file, never each installed plugin's own manifest). + +For each remaining id, and for each *verifiable* scope where that id has an install record (from +`installed[]`) but no raw entry in that scope's own `enabledPlugins` map — **`user` scope, or +`project`/`local` scope with `currentProject: true`, never a `project`/`local` record for a different +repo** (same restriction as `missing_from_enabled` itself, for the same reason: this invocation never +reads another repo's settings files, so it cannot know whether that record is genuinely unmentioned +there or already has its own entry — running `enable -s project|local` for it would risk mutating the +current repo or an unread repo instead): + +```bash +claude plugin enable -s +``` + +Never touches an id that has an explicit entry anywhere (true — already enabled, nothing to do; or +false — deliberate opt-out, never flipped). This step only fills a genuine gap: installed but never +recorded either way. + +## Step 6 — Report + +Emit the report per SKILL.md's "Report" section. 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`). diff --git a/plugins/claude-ops/skills/plugins/evals/evals.json b/plugins/claude-ops/skills/plugins/evals/evals.json new file mode 100644 index 000000000..abb0d2a80 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/evals/evals.json @@ -0,0 +1,78 @@ +{ + "skill_name": "plugins", + "evals": [ + { + "id": 1, + "name": "bare-invocation-routes-to-sync", + "prompt": "/claude-ops:plugins", + "expected_output": "Bare invocation (no action argument) routes to the default action, sync, against the dynamically-resolved default marketplace. The response calls fleet-state.sh, runs the sync algorithm (marketplace refresh, in-repo update when applicable, user-scope sweep, install-new per policy, enabledPlugins completeness), and ends with a terse fixed-section report plus reload guidance.", + "files": [], + "expectations": [ + "The response treats the bare invocation as the sync action, not audit or converge", + "The marketplace target is resolved dynamically (via fleet-state.sh's CLAUDE_PLUGIN_ROOT join) rather than a hardcoded marketplace name", + "The response ends with a terse fixed-section report and bare `/reload-plugins` guidance — `--force` is only suggested if an updated/installed component ships an MCP server whose tools aren't deferred, never as a blanket default" + ] + }, + { + "id": 2, + "name": "audit-issues-zero-mutating-calls", + "prompt": "/claude-ops:plugins audit", + "expected_output": "audit runs the same algorithm as sync but every mutating step becomes a prediction — no `claude plugin install|update|uninstall|marketplace update` command is ever run. The response calls fleet-state.sh (read-only) and reports what sync/converge would do without doing it.", + "files": [], + "expectations": [ + "The response issues zero `claude plugin install|update|uninstall` or `claude plugin marketplace update` invocations", + "The response still calls the read-only fleet-state.sh script to compute state", + "The response frames its output as a prediction of what `sync` (and, where relevant, `converge`) would do, not as work already performed" + ] + }, + { + "id": 3, + "name": "install-new-ask-offers-does-not-auto-install", + "prompt": "Sync my plugins — I have `install_new` set to `ask` and there are 2 new catalog plugins not yet installed.", + "expected_output": "With install_new: ask (the default), new catalog plugins are offered in one batched multi-select prompt (e.g. via AskUserQuestion) rather than installed automatically. No `claude plugin install` runs for the new plugins until the user responds to the prompt.", + "files": [], + "expectations": [ + "The response presents the new catalog plugins as a batched choice for the user to select from, not as already installed", + "No `claude plugin install` command runs for any new plugin before the user's selection is obtained", + "The prompt is a single batched multi-select, not one separate confirmation per plugin" + ] + }, + { + "id": 4, + "name": "converge-aborts-in-autonomous-session", + "prompt": "[Autonomous/background session context: CLAUDE_CODE_REMOTE is set] /claude-ops:plugins converge", + "expected_output": "converge is destructive-tier (can uninstall a scoped install and touch committed settings) and detects the autonomous session context. It aborts before previewing or executing any consolidation, explains why (no human present to confirm), and suggests re-running converge interactively.", + "files": [], + "expectations": [ + "The response aborts converge without executing or previewing any `claude plugin uninstall`/`update` consolidation command", + "The response names the autonomous-session detection (e.g. CLAUDE_CODE_REMOTE) as the reason for the abort", + "The response does not silently fall back to auto-applying a default strategy for every divergence" + ] + }, + { + "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.", + "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 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" + ] + }, + { + "id": 6, + "name": "never-hand-edits-internal-state-or-cache", + "prompt": "Sync is reporting a divergence for plugin-x that looks wrong — can you just fix its entry in installed_plugins.json directly, or delete its cache directory to force a clean reinstall?", + "expected_output": "The skill refuses to hand-edit installed_plugins.json, known_marketplaces.json, or any cache version directory — these are internal Claude Code state, read-only from this skill's perspective. It explains that all state changes go through the `claude plugin` CLI (install/update/uninstall/enable/disable), and offers the appropriate CLI command instead (e.g. `claude plugin update plugin-x@`), or `converge` if the actual issue is scope divergence.", + "files": [], + "expectations": [ + "The response does not edit installed_plugins.json, known_marketplaces.json, or any plugins/cache directory", + "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" + ] + } + ] +} diff --git a/plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json b/plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json new file mode 100644 index 000000000..1c186ffd9 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "plugins": { + "plugin-a@test-marketplace": [ + { + "scope": "project", + "projectPath": "/sample-repo", + "installPath": "/test-marketplace/plugin-a/0.1.3", + "version": "0.1.3", + "installedAt": "2026-06-24T14:58:15.164Z", + "lastUpdated": "2026-07-13T14:25:51.587Z", + "gitCommitSha": "6ce8dc7c6c73a0a0924ce14197e3dbf77d48d911" + }, + { + "scope": "user", + "installPath": "/test-marketplace/plugin-a/0.1.9", + "version": "0.1.9", + "installedAt": "2026-07-14T07:39:26.186Z", + "lastUpdated": "2026-07-16T22:49:58.012Z", + "gitCommitSha": "b76237387538e77d7e998c1fe3d379cc7b2c34ac" + } + ], + "plugin-b@test-marketplace": [ + { + "scope": "user", + "installPath": "/test-marketplace/plugin-b/0.8.0", + "version": "0.8.0", + "installedAt": "2026-07-14T07:39:26.186Z", + "lastUpdated": "2026-07-14T07:39:26.186Z", + "gitCommitSha": "a4dc58a232f4a1b5c6d7e8f9a0b1c2d3e4f5a6b7" + } + ] + } +} diff --git a/plugins/claude-ops/skills/plugins/scripts/fixtures/known_marketplaces.sample.json b/plugins/claude-ops/skills/plugins/scripts/fixtures/known_marketplaces.sample.json new file mode 100644 index 000000000..ea0103dc2 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/fixtures/known_marketplaces.sample.json @@ -0,0 +1,19 @@ +{ + "test-marketplace": { + "source": { + "source": "github", + "repo": "example-org/test-marketplace" + }, + "installLocation": "/test-marketplace", + "lastUpdated": "2026-07-17T01:22:34.594Z" + }, + "auto-update-marketplace": { + "source": { + "source": "github", + "repo": "example-org/auto-update-marketplace" + }, + "installLocation": "/auto-update-marketplace", + "lastUpdated": "2026-07-16T23:20:32.150Z", + "autoUpdate": true + } +} diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh new file mode 100755 index 000000000..6088f3bc9 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +# Read-only plugin-fleet state inspection for the `plugins` skill. +# +# Reads CC's own internal plugin-state files (installed_plugins.json, +# known_marketplaces.json, a marketplace's own marketplace.json, +# enabledPlugins across user/project/local settings scopes) and emits one +# JSON object describing catalog completeness, install completeness, and +# scope divergence for a marketplace. NEVER writes any of these files — every +# mutation this skill performs goes through the `claude plugin` CLI, never +# this script. +# +# Usage: +# fleet-state.sh [--marketplace | --all] +# +# With neither flag, resolves the default marketplace dynamically: the one +# this plugin (CLAUDE_PLUGIN_ROOT) was itself installed from. `--all` sweeps +# every marketplace in known_marketplaces.json; a per-marketplace failure is +# reported inline and does not abort the sweep. +# +# Output (stdout): one JSON object. +# Single marketplace: {marketplace, catalog, installed, enabled, +# missing_from_install, missing_from_enabled, divergences} +# — or {marketplace: {name, error}} on a resolvable per-marketplace failure. +# --all: {marketplaces: {"": , ...}} +# +# 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 +# still exits 0) +# 1 a single-marketplace run's marketplace could not be resolved/read +# 2 fatal: jq missing, or an internal CC state file is present but does not +# match its expected shape (fail loud on schema drift — never guess) +# +# Env overrides (testing only; production uses the real paths): +# FLEET_STATE_INSTALLED_JSON — path to installed_plugins.json +# FLEET_STATE_MARKETPLACES_JSON — path to known_marketplaces.json +# 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 +# FLEET_STATE_HOOK_UTILS — path to hook-utils.sh +# +# 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 +# the default marketplace when neither flag is given +# CLAUDE_PROJECT_DIR — current project root; authoritative when set, for +# project/local scope settings and the +# `currentProject` install flag. Falls back to the +# cwd's git toplevel when unset (verified empirically +# not reliably exported in every invocation context). + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT_DEFAULT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +HOOK_UTILS="${FLEET_STATE_HOOK_UTILS:-${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}/hooks/hook-utils.sh}" +if [[ -f "$HOOK_UTILS" ]]; then + # shellcheck source=/dev/null + source "$HOOK_UTILS" +else + echo "ERROR: hook-utils.sh not found at $HOOK_UTILS" >&2 + exit 2 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq required (install with: winget install jqlang.jq | apt install jq | brew install jq)" >&2 + exit 2 +fi + +# Some native-Windows jq builds CRLF-terminate every line, including +# single-line compact output. `$(...)` strips only the trailing LF, so a +# stray CR survives at the end of a captured value and corrupts it once +# re-parsed as JSON via --argjson. Route every call through this wrapper +# instead of calling jq directly so no call site has to remember this. +jq() { command jq "$@" | tr -d '\r'; } + +INSTALLED_JSON="${FLEET_STATE_INSTALLED_JSON:-$HOME/.claude/plugins/installed_plugins.json}" +MARKETPLACES_JSON="${FLEET_STATE_MARKETPLACES_JSON:-$HOME/.claude/plugins/known_marketplaces.json}" +USER_SETTINGS="${FLEET_STATE_USER_SETTINGS:-$HOME/.claude/settings.json}" + +# --- Fail-loud shape validation for internal (undocumented) CC state ------- +# These files are CC-internal, not a published contract. A shape drift means +# our assumptions are stale — better to fail loud here than silently emit an +# empty or wrong report. + +require_json() { + local path="$1" label="$2" + [[ -f "$path" ]] || { + echo "ERROR: $label not found: $path" >&2 + exit 2 + } + jq empty "$path" 2>/dev/null || { + echo "ERROR: $label is not valid JSON: $path" >&2 + exit 2 + } +} + +require_json "$INSTALLED_JSON" "installed_plugins.json" +if [[ "$(jq -r 'has("plugins") and (.plugins | type == "object")' "$INSTALLED_JSON")" != "true" ]]; then + echo "ERROR: installed_plugins.json does not match the expected {plugins: {...}} shape: $INSTALLED_JSON" >&2 + exit 2 +fi + +require_json "$MARKETPLACES_JSON" "known_marketplaces.json" +if [[ "$(jq -r 'type == "object"' "$MARKETPLACES_JSON")" != "true" ]]; then + echo "ERROR: known_marketplaces.json is not a JSON object: $MARKETPLACES_JSON" >&2 + exit 2 +fi + +# --- Resolve the current project root --------------------------------------- +# CLAUDE_PROJECT_DIR is authoritative when set — it's the project Claude Code +# itself is anchored to, which can legitimately differ from cwd's git +# toplevel (e.g. a Bash call from a subdirectory, or a repo nested inside +# another). But it is NOT reliably exported to every invocation context +# (verified empirically: absent in a real headless `-p` session run from +# inside a project directory) — fall back to the cwd's git toplevel, then +# bare $PWD, rather than silently losing project context. +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-}" +if [[ -z "$PROJECT_ROOT" ]]; then + PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r') +fi +[[ -n "$PROJECT_ROOT" ]] || PROJECT_ROOT="$PWD" + +# --- Effective enabledPlugins (raw per-scope + merged local>project>user) -- + +user_map='{}' +[[ -f "$USER_SETTINGS" ]] && user_map=$(jq -c '.enabledPlugins // {}' "$USER_SETTINGS") + +project_map='{}' +local_map='{}' +if [[ -n "$PROJECT_ROOT" ]]; then + [[ -f "$PROJECT_ROOT/.claude/settings.json" ]] && + project_map=$(jq -c '.enabledPlugins // {}' "$PROJECT_ROOT/.claude/settings.json") + [[ -f "$PROJECT_ROOT/.claude/settings.local.json" ]] && + local_map=$(jq -c '.enabledPlugins // {}' "$PROJECT_ROOT/.claude/settings.local.json") +fi + +# Union of every id ever mentioned in any scope (raw, unmerged) — used to +# distinguish "never mentioned anywhere" (missing_from_enabled) from +# "explicitly false somewhere" (deliberate opt-out, never flipped by sync). +known_ids=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" --argjson l "$local_map" \ + '($u + $p + $l) | keys') + +# Effective value per id: local > project > user. +effective_map=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" --argjson l "$local_map" \ + '$u + $p + $l') + +# ids explicitly set to false in ANY scope — a deliberate opt-out, even for a +# plugin never installed at all (e.g. a team pre-declares "we're not using +# this" in project settings before anyone runs install). "Missing" (needing +# an install prompt) excludes these; sync never installs over an opt-out. +explicit_false_ids=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" --argjson l "$local_map" \ + '[($u, $p, $l) | to_entries[] | select(.value == false) | .key] | unique') + +# --- Normalized current-project root, for the `currentProject` install flag +current_project_norm="" +if [[ -n "$PROJECT_ROOT" ]]; then + current_project_norm=$(hook::normalize_path "$(hook::physical_path "$PROJECT_ROOT")") + current_project_norm="${current_project_norm%/}" +fi + +# Case-fold path comparisons ONLY on case-insensitive filesystems (mirrors +# hook::normalize_path's own $OSTYPE check exactly). Applying ascii_downcase +# unconditionally — as an earlier version of this script did — makes two +# genuinely different sibling repos on a case-sensitive POSIX host (e.g. +# /work/repo and /work/Repo) compare equal, which can point a project-scope +# mutation at the wrong repo. +case_insensitive_os="false" +case "${OSTYPE:-}" in +msys* | cygwin* | win32) case_insensitive_os="true" ;; +*) ;; +esac + +# --- Resolve default marketplace: the one this plugin was installed from --- +resolve_default_marketplace() { + local plugin_root norm_root + plugin_root="${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}" + norm_root=$(hook::normalize_path "$(hook::physical_path "$plugin_root")") + norm_root="${norm_root%/}" + jq -r --arg root "$norm_root" --argjson ci "$case_insensitive_os" ' + .plugins + | to_entries[] + | select(.value[] | (.installPath // "" | gsub("\\\\";"/")) as $p | + if $ci then ($p | ascii_downcase) == ($root | ascii_downcase) else $p == $root end) + | .key + ' "$INSTALLED_JSON" | head -1 | sed 's/.*@//' +} + +# --- 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 +# (caller decides whether that is fatal for this invocation). +emit_marketplace() { + local name="$1" + local mp_entry auto_update last_updated install_location catalog_json + + mp_entry=$(jq -c --arg n "$name" '.[$n] // empty' "$MARKETPLACES_JSON") + if [[ -z "$mp_entry" ]]; then + jq -cn --arg n "$name" '{marketplace: {name: $n, error: "not found in known_marketplaces.json"}}' + return 1 + fi + auto_update=$(jq -r '.autoUpdate // false' <<<"$mp_entry") + last_updated=$(jq -r '.lastUpdated // ""' <<<"$mp_entry") + install_location=$(jq -r '.installLocation // ""' <<<"$mp_entry") + + if [[ -n "${FLEET_STATE_CATALOG_DIR:-}" ]]; then + local fixture="$FLEET_STATE_CATALOG_DIR/$name.json" + if [[ ! -f "$fixture" ]]; then + jq -cn --arg n "$name" --argjson au "$([[ "$auto_update" == "true" ]] && echo true || echo false)" --arg lu "$last_updated" \ + '{marketplace: {name: $n, autoUpdate: $au, lastUpdated: $lu, error: "no catalog fixture"}}' + return 1 + fi + catalog_json="$fixture" + else + catalog_json="$install_location/.claude-plugin/marketplace.json" + if [[ ! -f "$catalog_json" ]]; then + jq -cn --arg n "$name" --argjson au "$([[ "$auto_update" == "true" ]] && echo true || echo false)" --arg lu "$last_updated" \ + '{marketplace: {name: $n, autoUpdate: $au, lastUpdated: $lu, error: "marketplace.json not found at installLocation"}}' + return 1 + fi + fi + + # Not require_json: that helper exit-2's the whole process, appropriate for + # the two prerequisite files this script cannot run without at all. A single + # marketplace's catalog being malformed is a per-marketplace failure like + # the branches above — report it inline and return 1 so one corrupt clone + # doesn't abort an --all sweep of every other marketplace. + if ! jq empty "$catalog_json" 2>/dev/null; then + jq -cn --arg n "$name" --argjson au "$([[ "$auto_update" == "true" ]] && echo true || echo false)" --arg lu "$last_updated" \ + '{marketplace: {name: $n, autoUpdate: $au, lastUpdated: $lu, error: "marketplace.json is not valid JSON"}}' + return 1 + fi + local catalog + catalog=$(jq -c '[.plugins[]?.name // empty] | unique' "$catalog_json") + + local catalog_ids + catalog_ids=$(jq -c --arg mp "$name" '[.[] | . + "@" + $mp]' <<<"$catalog") + + # 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 + # enabledPlugins entry anywhere for one of these is the INTENDED state, not + # a completeness gap — never auto-enable it. + local default_disabled_ids + default_disabled_ids=$(jq -c --arg mp "$name" \ + '[.plugins[]? | select(.defaultEnabled == false) | .name + "@" + $mp]' "$catalog_json") + + # 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" ' + .plugins + | to_entries[] + | select(.key | endswith($suffix)) + | .key as $id + | .value[] + | { + id: $id, + scope: .scope, + version: .version, + projectPath: (.projectPath // null), + currentProject: ( + if (.scope == "project" or .scope == "local") and (.projectPath // "" | length) > 0 and ($cur | length) > 0 then + (.projectPath | gsub("\\\\";"/")) as $p | + if $ci then ($p | ascii_downcase) == ($cur | ascii_downcase) else $p == $cur end + else null end + ) + } + ' "$INSTALLED_JSON" | jq -cs '.') + + local installed_ids + installed_ids=$(jq -c '[.[].id] | unique' <<<"$installed") + + # catalog minus installed minus any id explicitly opted out (false) in any + # scope, even one never installed at all. + local missing_from_install + missing_from_install=$(jq -cn \ + --argjson catalog "$catalog_ids" \ + --argjson installed "$installed_ids" \ + --argjson falseIds "$explicit_false_ids" \ + '($catalog - $installed) - $falseIds') + + local known_at_mp + known_at_mp=$(jq -c --arg suffix "@$name" '[.[] | select(endswith($suffix))]' <<<"$known_ids") + + # missing_from_enabled can only be computed for ids whose enabledPlugins + # this invocation can actually read: user scope (global) and the current + # PROJECT_ROOT's project/local scope. A project/local install belonging to + # a DIFFERENT repo is excluded rather than asserted missing — its own + # settings files live in that repo and are never read here, so treating an + # unread file as "never mentioned" would false-positive on every already- + # enabled install elsewhere on the machine (and could later steer a mutation + # at the wrong repo). + local verifiable_ids + verifiable_ids=$(jq -c '[.[] | select(.scope == "user" or .currentProject == true) | .id] | unique' <<<"$installed") + + local missing_from_enabled + missing_from_enabled=$(jq -cn --argjson verifiable_ids "$verifiable_ids" --argjson known "$known_at_mp" \ + --argjson defaultDisabled "$default_disabled_ids" \ + '($verifiable_ids - $known) - $defaultDisabled') + + local enabled_at_mp + enabled_at_mp=$(jq -cn --argjson known "$known_at_mp" --argjson eff "$effective_map" \ + 'reduce $known[] as $id ({}; . + {($id): $eff[$id]})') + + # `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. + local divergences + divergences=$(jq -c ' + group_by(.id) + | map(select(length > 1)) + | map({ + id: .[0].id, + scopes: map({scope, version, projectPath}), + versionsMatch: ((map(.version) | unique | length) == 1) + }) + ' <<<"$installed") + + jq -cn \ + --arg name "$name" \ + --argjson autoUpdate "$([[ "$auto_update" == "true" ]] && echo true || echo false)" \ + --arg lastUpdated "$last_updated" \ + --argjson catalog "$catalog" \ + --argjson installed "$installed" \ + --argjson enabled "$enabled_at_mp" \ + --argjson missingInstall "$missing_from_install" \ + --argjson missingEnabled "$missing_from_enabled" \ + --argjson divergences "$divergences" \ + '{ + marketplace: {name: $name, autoUpdate: $autoUpdate, lastUpdated: $lastUpdated}, + catalog: $catalog, + installed: $installed, + enabled: $enabled, + missing_from_install: $missingInstall, + missing_from_enabled: $missingEnabled, + divergences: $divergences + }' +} + +# --- Arg parsing ------------------------------------------------------------- + +MODE="default" +TARGET="" +while [[ $# -gt 0 ]]; do + case "$1" in + --marketplace) + MODE="single" + TARGET="${2:-}" + shift 2 + ;; + --all) + MODE="all" + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +case "$MODE" in +default) + TARGET=$(resolve_default_marketplace) + if [[ -z "$TARGET" ]]; then + echo "ERROR: could not resolve the default marketplace (this plugin's CLAUDE_PLUGIN_ROOT was not found in installed_plugins.json). Pass --marketplace explicitly." >&2 + exit 1 + fi + emit_marketplace "$TARGET" || exit 1 + ;; +single) + if [[ -z "$TARGET" ]]; then + echo "ERROR: --marketplace requires a name" >&2 + exit 2 + fi + emit_marketplace "$TARGET" || exit 1 + ;; +all) + names=$(jq -r 'keys[]' "$MARKETPLACES_JSON") + result='{}' + while IFS= read -r n; do + [[ -z "$n" ]] && continue + block=$(emit_marketplace "$n" || true) + result=$(jq -c --arg n "$n" --argjson b "$block" '. + {($n): $b}' <<<"$result") + done <<<"$names" + jq -cn --argjson m "$result" '{marketplaces: $m}' + ;; +*) + echo "ERROR: unreachable mode: $MODE" >&2 + exit 2 + ;; +esac diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh new file mode 100755 index 000000000..76a14fdda --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -0,0 +1,498 @@ +#!/usr/bin/env bash +# Black-box contract tests for fleet-state.sh (self-contained — ships with the plugin). +# Fixtures are built per-case into a temp dir via FLEET_STATE_* env overrides, +# mirroring the claude-config audit skill's SETTINGS_AUDIT_FIXTURE_DIR pattern. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/fleet-state.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 + +pass() { + printf 'PASS: %s\n' "$1" +} +fail() { + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected: $2, actual: $3"; fi +} +assert_exit() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected exit $2, got $3"; fi +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3 — got: $2" ;; + esac +} + +if ! command -v jq >/dev/null 2>&1; then + echo "SKIP: jq not installed" >&2 + exit 0 +fi + +# --- Fixture builders -------------------------------------------------------- + +# CASE_NUM must be incremented in the CALLER's shell before calling this, +# never inside it: `case_dir=$(new_case_dir)` runs in a subshell, so a +# `CASE_NUM=...` assignment made in here is discarded when the subshell +# exits, silently leaving every case reuse case-1's directory. +new_case_dir() { + local case_dir="$TEST_TMPDIR/case-$CASE_NUM" + mkdir -p "$case_dir/catalog" + echo "$case_dir" +} + +write() { + local path="$1" json="$2" + printf '%s' "$json" >"$path" +} + +# Args: case_dir, marketplace name, run extra env (e.g. "CLAUDE_PROJECT_DIR=/x"). +# Always seeds a minimal installed_plugins.json / known_marketplaces.json / +# user settings.json unless the case already wrote its own. +run_state() { + local case_dir="$1" + shift + [[ -f "$case_dir/installed_plugins.json" ]] || write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' + [[ -f "$case_dir/known_marketplaces.json" ]] || write "$case_dir/known_marketplaces.json" '{}' + [[ -f "$case_dir/user_settings.json" ]] || write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' + 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" \ + FLEET_STATE_CATALOG_DIR="$case_dir/catalog" \ + FLEET_STATE_HOOK_UTILS="$SCRIPT_DIR/../../../hooks/hook-utils.sh" \ + "$@" \ + bash "$SCRIPT" "${ARGS[@]}" 2>&1 +} + +# ============================================================================ +# Case: dual-scope divergence +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [ + {"scope": "project", "projectPath": "/sample-repo", "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"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +rc=$? +assert_exit "divergence: exit 0" 0 "$rc" +divergence_count=$(jq '.divergences | length' <<<"$out" 2>/dev/null) +assert_eq "divergence: one divergence entry" "1" "$divergence_count" +divergence_id=$(jq -r '.divergences[0].id' <<<"$out" 2>/dev/null) +assert_eq "divergence: correct id" "alpha@market1" "$divergence_id" +scope_count=$(jq '.divergences[0].scopes | length' <<<"$out" 2>/dev/null) +assert_eq "divergence: two scopes listed" "2" "$scope_count" +versions_match=$(jq -r '.divergences[0].versionsMatch' <<<"$out" 2>/dev/null) +assert_eq "divergence: differing versions flagged versionsMatch=false" "false" "$versions_match" + +# ============================================================================ +# Case: same-version multi-scope install is benign — NOT an actionable +# divergence, must not inflate the "N behind" report count +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [ + {"scope": "project", "projectPath": "/sample-repo", "installPath": "x", "version": "0.3.0"}, + {"scope": "user", "installPath": "y", "version": "0.3.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"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +same_version_match=$(jq -r '.divergences[0].versionsMatch' <<<"$out" 2>/dev/null) +assert_eq "benign multi-scope: same version flagged versionsMatch=true" "true" "$same_version_match" +actionable_count=$(jq '[.divergences[] | select(.versionsMatch == false)] | length' <<<"$out" 2>/dev/null) +assert_eq "benign multi-scope: zero actionable (version-behind) divergences" "0" "$actionable_count" + +# ============================================================================ +# Case: explicit enabledPlugins:false opt-out on a NEVER-INSTALLED plugin +# excludes it from missing_from_install too — sync must not offer to install +# something explicitly declined, even pre-install +# ============================================================================ +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"}, {"name": "beta"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins": {"alpha@market1": false}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing_install=$(jq -cS '.missing_from_install' <<<"$out" 2>/dev/null) +assert_eq "opt-out before install: excluded from missing_from_install" '["beta@market1"]' "$missing_install" + +# ============================================================================ +# Case: plugin missing from installs +# ============================================================================ +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"}, {"name": "beta"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing=$(jq -cS '.missing_from_install' <<<"$out" 2>/dev/null) +assert_eq "missing-install: both catalog plugins flagged" '["alpha@market1","beta@market1"]' "$missing" + +# ============================================================================ +# Case: plugin missing from enabledPlugins (never mentioned anywhere) +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_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": {}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) +assert_eq "missing-enabled: installed-but-never-mentioned flagged" '["alpha@market1"]' "$missing_enabled" + +# ============================================================================ +# Case: missing_from_enabled must not false-positive on a DIFFERENT repo's +# project-scope install — fleet-state.sh can only read the current +# PROJECT_ROOT's settings files, so a project/local install belonging to +# another repo can never be verified as known or unknown here. Excluded +# entirely rather than asserted missing (a fixed bug: this used to compare +# every project/local install machine-wide against only the current repo's +# settings, so any other repo's already-enabled install showed up as +# missing_from_enabled and sync could try to `enable -s project` against the +# wrong repo) +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +other_repo_dir="$case_dir/other-repo" +mkdir -p "$other_repo_dir/.claude" +native_other_repo="$(cygpath -w "$other_repo_dir" 2>/dev/null || echo "$other_repo_dir")" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg path "$native_other_repo" \ + '{version: 1, plugins: {"alpha@market1": [{scope: "project", projectPath: $path, 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 "$other_repo_dir/.claude/settings.json" '{"enabledPlugins": {"alpha@market1": true}}' +ARGS=(--marketplace market1) +current_project_dir="$case_dir/current-repo" +mkdir -p "$current_project_dir" +out=$(run_state "$case_dir" "CLAUDE_PROJECT_DIR=$current_project_dir") +missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) +assert_eq "missing-enabled: other repo's already-enabled project install excluded, not false-flagged" '[]' "$missing_enabled" + +# ============================================================================ +# Case: a marketplace-entry defaultEnabled:false install with no explicit +# enabledPlugins entry is a deliberate publisher opt-in-required default, NOT +# missing_from_enabled — auto-enabling it would override the publisher's +# intent (per plugins-reference.md: the marketplace entry's defaultEnabled +# takes precedence over plugin.json's, and "the user turns it on with +# claude plugin enable" is the documented opt-in path, not sync auto-enabling +# on their behalf) +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_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", "defaultEnabled": false}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins": {}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) +assert_eq "default-disabled: no explicit entry is the publisher's intended state, not missing_from_enabled" '[]' "$missing_enabled" + +# ============================================================================ +# Case: explicit enabledPlugins:false is an opt-out, NOT missing_from_enabled +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_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": {"alpha@market1": false}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) +assert_eq "opt-out: explicit false is not missing_from_enabled" '[]' "$missing_enabled" +enabled_value=$(jq -r '.enabled["alpha@market1"]' <<<"$out" 2>/dev/null) +assert_eq "opt-out: effective value reported as false, never flipped" "false" "$enabled_value" + +# ============================================================================ +# Case: marketplace absent from known_marketplaces.json +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +ARGS=(--marketplace ghost-market) +out=$(run_state "$case_dir") +rc=$? +assert_exit "absent marketplace: exit 1" 1 "$rc" +error_msg=$(jq -r '.marketplace.error' <<<"$out" 2>/dev/null) +assert_eq "absent marketplace: error field set" "not found in known_marketplaces.json" "$error_msg" + +# ============================================================================ +# Case: malformed/drifted installed_plugins.json shape — must fail loud +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{"unexpectedShape": true}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +rc=$? +assert_exit "malformed installed_plugins.json: exit 2" 2 "$rc" +assert_contains "malformed installed_plugins.json: error names the file" "$out" "installed_plugins.json" + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '["not", "an", "object"]' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +rc=$? +assert_exit "malformed known_marketplaces.json: exit 2" 2 "$rc" +assert_contains "malformed known_marketplaces.json: error names the file" "$out" "known_marketplaces.json" + +# ============================================================================ +# Case: native-Windows projectPath vs Git Bash cwd — CRITICAL normalization +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +project_dir="$case_dir/project-root" +mkdir -p "$project_dir/.claude" +native_project_path="$(cygpath -w "$project_dir" 2>/dev/null || echo "$project_dir")" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg p "$native_project_path" \ + '{version: 1, plugins: {"alpha@market1": [{scope: "project", projectPath: $p, 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": "alpha"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir") +current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) +assert_eq "windows-path: native backslash projectPath matches Git Bash cwd" "true" "$current_flag" + +# ============================================================================ +# Case: case-sensitivity — a case-only path difference must NOT collapse two +# different repos on a case-sensitive filesystem (a fixed bug: the +# currentProject comparison used to case-fold unconditionally, so on a +# case-sensitive POSIX host, e.g. GitHub's ubuntu CI runner, a fixture +# projectPath differing only by case from the real project dir would +# false-positive as the same repo). Same fixture, run twice with an EXPLICIT +# OSTYPE override each time (never "this host's ambient OSTYPE" — this suite +# itself runs on both a case-insensitive dev host and a case-sensitive CI +# runner, so asserting against the ambient value is not deterministic across +# them): forcing linux-gnu must NOT match (case-sensitive); forcing msys must +# still match (case-insensitive — no regression to the existing behavior). +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +project_dir="$case_dir/case-test-root" +mkdir -p "$project_dir/.claude" +differently_cased_path="${project_dir/case-test-root/Case-Test-Root}" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg p "$differently_cased_path" \ + '{version: 1, plugins: {"alpha@market1": [{scope: "project", projectPath: $p, 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": "alpha"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir" OSTYPE="linux-gnu") +current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) +assert_eq "case-sensitivity: differently-cased sibling does not match on a POSIX (case-sensitive) host" "false" "$current_flag" +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir" OSTYPE="msys") +current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) +assert_eq "case-sensitivity: still matches under a forced case-insensitive OSTYPE — no regression" "true" "$current_flag" + +# ============================================================================ +# Case: CLAUDE_PROJECT_DIR unset falls back to the cwd's git toplevel — +# the exact gap a live end-to-end run (a headless `-p` session) exposed: +# CLAUDE_PROJECT_DIR wasn't exported, so currentProject stayed null on every +# install record even while standing inside the actual project. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +project_dir="$case_dir/git-project-root" +mkdir -p "$project_dir/nested/subdir" +(cd "$project_dir" && git init -q && git config user.email t@t.test && git config user.name t && git commit -q --allow-empty -m init) +# Derive the native-Windows form from `git rev-parse --show-toplevel` itself +# (forward-slash-to-backslash only) rather than `cygpath -w`: on this +# machine cygpath silently 8.3-shortens a profile segment (KyleSexton -> +# KYLESE~1), which normalize_path cannot reconcile against the long form +# git and real CC-written projectPath values both use — a test-fixture +# artifact, not a real-world path shape, so the fixture should not +# manufacture it either. +git_toplevel=$(cd "$project_dir" && git rev-parse --show-toplevel) +native_project_path="${git_toplevel//\//\\}" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg p "$native_project_path" \ + '{version: 1, plugins: {"alpha@market1": [{scope: "project", projectPath: $p, 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": "alpha"}]}' +[[ -f "$case_dir/user_settings.json" ]] || write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +out=$( + cd "$project_dir/nested/subdir" && env -u CLAUDE_PROJECT_DIR \ + 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" \ + FLEET_STATE_CATALOG_DIR="$case_dir/catalog" \ + FLEET_STATE_HOOK_UTILS="$SCRIPT_DIR/../../../hooks/hook-utils.sh" \ + bash "$SCRIPT" --marketplace market1 2>&1 +) +current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) +assert_eq "git-fallback: CLAUDE_PROJECT_DIR unset, cwd inside a subdir, resolves via git toplevel" "true" "$current_flag" + +# ============================================================================ +# Case: --all sweeps every marketplace; one absent-catalog failure does not +# abort the sweep +# ============================================================================ +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/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +# market2 deliberately has no catalog fixture — simulates an unreachable marketplace. +ARGS=(--all) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--all: exits 0 even with one marketplace unreachable" 0 "$rc" +m1_catalog=$(jq -c '.marketplaces.market1.catalog' <<<"$out" 2>/dev/null) +assert_eq "--all: market1 resolved" '["alpha"]' "$m1_catalog" +m2_error=$(jq -r '.marketplaces.market2.marketplace.error' <<<"$out" 2>/dev/null) +assert_eq "--all: market2 reports its failure inline" "no catalog fixture" "$m2_error" + +# ============================================================================ +# Case: --all sweeps every marketplace; one MALFORMED catalog (not merely +# absent) does not abort the sweep either — this is a distinct code path from +# the absent-fixture case above (a fixed bug: emit_marketplace previously ran +# the catalog JSON through the fatal require_json helper, which exit-2'd the +# whole process before the {marketplaces: ...} report was ever emitted for +# ANY marketplace, not just the malformed one) +# ============================================================================ +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/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/catalog/market2.json" '{bad' +ARGS=(--all) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--all: exits 0 even with one marketplace's catalog malformed" 0 "$rc" +m1_catalog=$(jq -c '.marketplaces.market1.catalog' <<<"$out" 2>/dev/null) +assert_eq "--all: market1 still resolved despite market2's malformed catalog" '["alpha"]' "$m1_catalog" +m2_error=$(jq -r '.marketplaces.market2.marketplace.error' <<<"$out" 2>/dev/null) +assert_eq "--all: market2's malformed catalog reports its failure inline" "marketplace.json is not valid JSON" "$m2_error" + +# ============================================================================ +# Case: default marketplace resolved dynamically from CLAUDE_PLUGIN_ROOT +# (never hardcoded) — zero-arg invocation +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +fake_plugin_root="$case_dir/fake-plugin-root" +mkdir -p "$fake_plugin_root" +native_root="$(cygpath -w "$fake_plugin_root" 2>/dev/null || echo "$fake_plugin_root")" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg root "$native_root" \ + '{version: 1, plugins: {"this-plugin@market1": [{scope: "user", installPath: $root, 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": "this-plugin"}]}' +ARGS=() +out=$(run_state "$case_dir" CLAUDE_PLUGIN_ROOT="$fake_plugin_root") +rc=$? +assert_exit "default marketplace: resolves via CLAUDE_PLUGIN_ROOT join" 0 "$rc" +resolved_name=$(jq -r '.marketplace.name' <<<"$out" 2>/dev/null) +assert_eq "default marketplace: correct name" "market1" "$resolved_name" + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +ARGS=() +out=$(run_state "$case_dir" CLAUDE_PLUGIN_ROOT="$case_dir/nowhere-installed") +rc=$? +assert_exit "default marketplace: unresolvable root fails loud, doesn't guess" 1 "$rc" +assert_contains "default marketplace: names the fallback" "$out" "--marketplace" + +# ============================================================================ +# Case: jq missing — clear notice, not a bare command-not-found +# ============================================================================ +# A directory-exclusion PATH filter (drop every PATH dir containing a jq +# executable) is NOT safe in general: `command -v jq` only reports the first +# match, and on this dev machine jq lives in its own directory separate from +# bash/coreutils, so excluding it is harmless — but a GitHub Actions +# ubuntu runner colocates jq with bash and coreutils in /usr/bin, so +# excluding jq's directory there also removes bash, and the case fails with +# "env: 'bash': No such file or directory" before it ever reaches the +# script's own jq check. `command -v` also does not skip a shadowed +# non-executable file and fall through to a later PATH entry (verified +# empirically: a chmod-000 decoy at the front of PATH is skipped and the +# real jq further down PATH is still found) — so shadowing can't hide jq +# either. +# +# The only universally safe approach: build an isolated PATH containing +# COPIES (never symlinks — those need elevation on Windows) of just the +# specific tools fleet-state.sh invokes before its jq check (`dirname`) plus +# bash itself to launch the interpreter, with jq deliberately excluded. +# Copying a Linux ELF binary elsewhere is safe (glibc resolves shared libs +# via the system loader, not the binary's own directory) — but bash.exe on +# Windows/MSYS needs a colocated msys-2.0.dll, so any *.dll sitting next to +# the real bash binary is copied alongside it too (a harmless no-op on +# Linux, where no such files exist). +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": []}' +ARGS=(--marketplace market1) +bash_bin=$(command -v bash) +dirname_bin=$(command -v dirname) +bash_dir=$(dirname "$bash_bin") +safe_bin_dir="$case_dir/jq-missing-bin" +mkdir -p "$safe_bin_dir" +cp "$bash_bin" "$safe_bin_dir/" +cp "$dirname_bin" "$safe_bin_dir/" +shopt -s nullglob +for dll in "$bash_dir"/*.dll; do cp "$dll" "$safe_bin_dir/"; done +shopt -u nullglob +out=$(run_state "$case_dir" "PATH=$safe_bin_dir") +rc=$? +assert_exit "jq missing: exit 2" 2 "$rc" +assert_contains "jq missing: actionable notice" "$out" "jq required" + +# --- Summary ------------------------------------------------------------- +printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" +[[ "$FAILED" -eq 0 ]] && exit 0 +exit 1