Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/claude-ops/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-ops",
"version": "0.32.0",
"version": "0.32.1",
"description": "Claude Code operations toolkit. Ten skills: inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow \u2014 CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and a bundled known-performance-issues reference; separates the three documented suspects \u2014 accumulated state, version regression, component bloat \u2014 and routes remediation out; reports, never mutates), observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures — the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.",
"author": {
"name": "Melodic Software",
Expand Down
36 changes: 36 additions & 0 deletions plugins/claude-ops/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@
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.32.1]

### Added

- **`plugins`:** `fleet-state.sh --ids <selector>` emits the id list each `sync` step loops — one
record per line, tab-separated, first field always the fully-qualified `<name>@<marketplace>`,
CR-free by construction — so no caller hand-writes `jq -r … | while read` over the JSON.
Selectors: `installed-user`, `current-project`, `missing-user-install`, `missing-enabled`.
`current-project` carries the record's `scope` as a second field, because one plugin can hold both
a project- and a local-scope record for the same repo and the id alone cannot pick the right `-s`
flag. Refuses an unknown or absent selector (validated at parse time, so it reports as a usage
error even when the marketplace is also unresolvable) and `--all` (no single block to project),
rather than emitting a silently-empty list. A per-marketplace failure block goes to stderr in this
mode, never stdout, since a `< <(…)` consumer cannot see the exit status and would read the error
JSON as an id (#2578).

### Fixed

- **`plugins` sync steps taught an unguarded `jq` loop.** Steps 2-5 said "take `fleet-state.sh`'s
`installed[]` / `missing_*`" and loop, without supplying the extraction, so every reader wrote
their own `jq -r`. On Windows the native `jq` writes stdout in text mode and `$(…)` strips only
the trailing CRLF, so every id but the last reached `claude plugin update` as
`<name>@<marketplace>\r` and failed with `Plugin "<name>" not found` — text identical to the
bare-name gotcha, so it misread as that. Observed live: 64/65 updates failed. Steps 2-5 now cite
`--ids` (#2578).

### Changed

- **`plugins` gotchas: corrected the CRLF mechanism.** The CR section claimed a single-line capture
retains the `\r`, which predicts the wrong symptom (all ids failing). Verified on jq 1.8.2 / MSYS
bash 5.3.9: `$(…)` strips the trailing `\r\n` as a unit, so a single-line capture is clean and
only multi-line output keeps a CR on every line **but the last** — the all-but-last signature that
identifies the cause on sight. Also records that `mapfile -t` has no last-element reprieve, and
that jq→jq relays are self-cleaning because jq's stdin is text-mode too, which narrows the hazard
to jq output reaching a non-jq consumer (#2578).

## [0.32.0]

### Added
Expand Down
7 changes: 7 additions & 0 deletions plugins/claude-ops/skills/plugins/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,15 @@ files directly, and never write them:

```bash
"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh [--marketplace <name> | --all]
"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh [--marketplace <name>] --ids <selector>
```

The second form emits the plain id list a mutating step loops, instead of the JSON report — one
tab-separated record per line, first field always the fully-qualified `<name>@<marketplace>`. Use it
whenever a step needs ids; never hand-write a `jq` extraction over the JSON, which reintroduces a
trailing `\r` on Windows and silently corrupts every id but the last (see
[context/gotchas.md](context/gotchas.md)).

Read [context/scope-semantics.md](context/scope-semantics.md) before interpreting its output — in
particular, `divergences[].versionsMatch` separates a benign same-version multi-scope install
(normal, no action) from a real version skew (the actionable "run converge" signal); a raw
Expand Down
58 changes: 44 additions & 14 deletions plugins/claude-ops/skills/plugins/context/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,22 +101,52 @@ crash the script."

## Captured values on Windows carry `\r` — strip it before embedding in any command or JSON

Discovered empirically while implementing `fleet-state.sh` (Windows/MSYS `jq`): even single-line
compact JSON output ends `\r\n`, not just `\n`. But this is **not a `jq`-only hazard** — *any* value
captured on Windows/MSYS (a native `python` `print(...)`, a PowerShell interop line, `git config`
output, a CRLF-terminated file read) can arrive with a trailing `\r`. `$(...)` command substitution
strips only the trailing `\n`, so the `\r` survives at the end of the captured value and corrupts it
once it is either:
Discovered empirically while implementing `fleet-state.sh`: the native-Windows `jq` binary opens
stdout in **text mode**, so every `\n` it writes becomes `\r\n`. This is **not a `jq`-only
hazard** — *any* value produced on Windows/MSYS (a native `python` `print(...)`, a PowerShell
interop line, `git config` output, a CRLF-terminated file read) can arrive with a trailing `\r`.

**Which capture is actually corrupted depends on how you read it** (verified on jq 1.8.2 / MSYS
bash 5.3.9 — get this wrong and you will chase the wrong suspect):

- `x=$(… )` **single-line** output — *clean*. Command substitution strips the trailing `\r\n` as a
unit, not just the `\n`. A one-value capture is safe, and that is a bash-side property, so it
holds whatever produced the value.
- `x=$(… )` **multi-line** output — *every line but the last carries `\r`*, because only the final
terminator is stripped. **This all-but-last pattern is the diagnostic signature**: if the last
item in a batch is the only one that worked, stop looking for a logic bug and check for `\r`.
- `mapfile -t` / `readarray -t` — *every element carries `\r`*, including the last: `-t` removes the
newline but not the CR, so there is no last-element reprieve here.
- `jq` output read back **by `jq`** (as raw input or as JSON) — *self-cleaning*. jq's stdin is
text-mode too, so a CR it emitted is stripped again on the way back in. A jq→jq relay is
therefore not a hazard; the danger is only jq's line output reaching a **non-jq** consumer.

`IFS=$'\n'` does **not** rescue any of these — `\r` is not the separator, it rides inside the token.

A surviving `\r` corrupts the value once it is either:

- re-embedded in another `jq --argjson` argument (`jq: invalid JSON text passed to --argjson`), or
- **embedded in a constructed `claude plugin` id.** A `<name>@<marketplace>\r` id is passed with the
full id present, yet the CLI reports `Plugin "<name>" not found` — the marketplace suffix is
silently corrupted. The symptom is byte-identical to the bare-name gotcha above and actively
misdirects diagnosis (the full id *was* passed). Observed live: extracting ids via
`python -c "print(...)"` on Windows gave every id but the last a trailing `\r`, and 57/58
`claude plugin update` calls failed this way.

Route every `jq` call through the `jq() { command jq "$@" | tr -d '\r'; }`-style wrapper
`fleet-state.sh` already uses, **and** strip `\r` (`tr -d '\r'`, or `${var%$'\r'}`) from every value
captured from any other source before embedding it in a `claude plugin` command or a JSON argument.
Don't rediscover this the hard way in a second script.
misdirects diagnosis (the full id *was* passed). Observed live twice, both with the all-but-last
signature: extracting ids via `python -c "print(...)"` on Windows failed 57/58 `claude plugin
update` calls, and a hand-written `jq -r … | while read` over `fleet-state.sh`'s JSON failed
64/65 (#2578).

**Never hand-write an id extraction.** `fleet-state.sh --ids <selector>` emits the id list for each
`sync` step directly — one fully-qualified id per line, CR-free by construction — so the loop that
feeds `claude plugin` needs no `jq` of its own at all:

```bash
while IFS= read -r id; do
[[ -n "$id" ]] || continue
claude plugin update "$id" -s user
done < <(…/scripts/fleet-state.sh --ids installed-user)
```

For anything `--ids` does not cover: route every `jq` call through the
`jq() { command jq "$@" | tr -d '\r'; }`-style wrapper `fleet-state.sh` already uses, **and** strip
`\r` (`tr -d '\r'`, or `${var%$'\r'}`) from every value captured from any other source before
embedding it in a `claude plugin` command or a JSON argument. Don't rediscover this the hard way in
a second script.
45 changes: 37 additions & 8 deletions plugins/claude-ops/skills/plugins/context/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ claude plugin update <id> -s project # for a currentProject:true entry with sc
claude plugin update <id> -s local # for a currentProject:true entry with scope "local"
```

`fleet-state.sh --ids current-project` emits exactly those records — use it rather than a
hand-written `jq` over `installed[]` (see Step 3 for why the hand-written form breaks on Windows).
Each line is `<id>\t<scope>`, so the `-s` flag comes off the same line as the id it belongs to:

```bash
while IFS=$'\t' read -r id scope; do
[[ -n "$id" ]] || continue
claude plugin update "$id" -s "$scope"
done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --ids current-project)
```

The scope rides on the record for a reason: one plugin can hold **both** a `project`- and a
`local`-scope record for the same repo (the multi-scope case `divergences[]` tracks), and both are
`currentProject: true`. An id-only list would show that id twice with nothing to distinguish the
lines — `sort -u`, or pairing against a separately-extracted scope list, would silently drop one of
the two updates. Do not re-derive scope from the id afterwards.

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
Expand All @@ -107,26 +124,37 @@ output (it has no per-plugin catalog version to compare against), so the only co

## 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:
For every catalog plugin id currently installed at `user` scope, run:

```bash
claude plugin update <id> -s user
```

`<id>` here is always the fully-qualified `<name>@<marketplace>` 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 `<plugin>` 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.

Take the ids from `fleet-state.sh --ids`, never from a hand-written `jq` over its JSON:

```bash
while IFS= read -r id; do
[[ -n "$id" ]] || continue
claude plugin update "$id" -s user
done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --ids installed-user)
```

`--ids` emits the fully-qualified `<name>@<marketplace>` form, one per line, CR-free — a bare name
fails with "Plugin not found" even when unambiguous, and on Windows a hand-written
`jq -r ... | while read` silently appends a `\r` to every id but the last, which fails with the
*same* "Plugin not found" text and so misreads as the bare-name problem. Both are
[gotchas.md](gotchas.md); `--ids` is why neither can happen here.

## Step 4 — Install new catalog plugins (per `install_new` policy)

Catalog-dependent: skipped (deferred) for a marketplace whose Step 1 refresh failed — see Step 1.

Take `fleet-state.sh`'s `missing_from_user_install` — catalog ids not installed at `user` scope
Take `fleet-state.sh`'s `missing_from_user_install` (`--ids missing-user-install` emits the id list
directly — see Step 3) — catalog ids not installed at `user` scope
(already excludes anything explicitly opted out with `enabledPlugins: false` in any scope — never
re-offer a deliberate decline). This is deliberately user-scope, not the all-scope `missing_from_install`:
a plugin installed only at `project`/`local` scope is absent from `missing_from_install` yet still not
Expand All @@ -153,7 +181,8 @@ AND disable (`enabledPlugins: false`), or switch the policy to `ask`/`none`.
Catalog-dependent (`defaultEnabled` comes from catalog metadata): skipped (deferred) for a
marketplace whose Step 1 refresh failed — see Step 1.

Take `fleet-state.sh`'s `missing_from_enabled` — ids installed somewhere but never mentioned (true
Take `fleet-state.sh`'s `missing_from_enabled` (`--ids missing-enabled` emits the id list directly —
see Step 3) — ids installed somewhere but never mentioned (true
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
Expand Down
Loading