Skip to content

claude-ops: plugins sync teaches an unguarded jq loop; Windows CR breaks every id but the last #2578

Description

@kyle-sexton

Summary

claude-ops's plugins skill tells the reader to derive plugin ids from fleet-state.sh's JSON
and loop a claude plugin call over them (Steps 2-5 of context/sync.md), but never supplies the
extraction command. Every reader therefore hand-writes their own jq -r. On Windows that
hand-written jq reintroduces a CR the skill's own fleet-state.sh is careful to strip, and the
sweep then fails silently for every id but the last.

Observed in production this session against a 65-plugin user-scope fleet: 64 of 65 updates
failed
, each reporting

Failed to update plugin "actionlint@melodic-software": Plugin "actionlint" not found

Re-running the identical loop with | tr -d '\r' gave checked=65 updated=0 problems=0.

Mechanism (verified empirically; jq 1.8.2 via winget jqlang.jq, Git Bash / MSYS bash 5.3.9)

The native Windows jq binary opens stdout in text mode, so every \n it writes becomes
\r\n:

$ printf '{"a":"one","b":"two"}' | jq -r '.a,.b' | od -c
0000000   o   n   e  \r  \n   t   w   o  \r  \n

Three facts decide which call sites are actually harmed. Two of them cut against the mechanism
context/gotchas.md describes today.

1. $(...) strips the trailing CRLF, not just the LF. A single-line capture comes back clean;
only the earlier lines of multi-line output keep their CR. This is what produces the diagnostic
all-but-last signature:

$ v=$(printf '{"a":"one"}' | jq -r '.a'); printf '%s' "$v" | od -c
0000000   o   n   e                     # clean

$ ids=$(printf '{"a":["one@mk","two@mk"]}' | jq -r '.a[]')
one@mk\r     <- corrupted
two@mk       <- clean (last)

gotchas.md currently states the opposite ("even single-line compact JSON output ends \r\n ... so
the \r survives at the end of the captured value"), which predicts the wrong symptom — all ids
failing, rather than all but the last — and so misdirects the very diagnosis it exists to shortcut.

2. jq's stdin is text-mode too, so jq -> jq relays are self-cleaning. A CR that jq emitted is
stripped again when jq reads the line back, as raw input or as JSON:

$ printf 'alpha@mk\r\nbeta@mk\r\n' | jq -R '.'
"alpha@mk"      <- CR gone, not carried into the string
"beta@mk"

The hazard is therefore narrower than "any jq output": it is jq's line output reaching a non-jq
consumer
— an external command's argv (claude plugin update <id>), a string comparison, or a
file write. That is exactly the shape the production loop had.

3. mapfile -t is unsafe for every element, including the last-t strips the newline but
not the CR, so it has no equivalent of the $(...) last-element reprieve.

IFS=$'\n' does not help in any of these: \r is not the separator, it rides inside the token.

Why the existing gotcha did not prevent it

context/gotchas.md already carries a CR section (#1177) and fleet-state.sh:105 already routes
its own calls through jq() { command jq "$@" | tr -d '\r'; } — the script is not at fault. The gap
is directional: the gotcha points at sync.md, but sync.md Steps 2-5 never point back at the
gotcha and never give a guarded extraction. A reader following Step 3 writes unguarded jq, hits
Plugin "<name>" not found — byte-identical to the documented bare-name gotcha — and concludes the
marketplace is broken.

Proposed fix

  1. Give fleet-state.sh an --ids <selector> mode emitting one fully-qualified id per line,
    CR-free by construction (it already has the wrapper), so Steps 2-5 need no reader-side jq.
  2. Cite --ids from sync.md Steps 2-5.
  3. Correct the mechanism in gotchas.md (points 1-3 above) rather than adding a second CR section.

Repo-wide sweep

A sweep of all 499 tracked .sh files and 1120 .md files for the hazard shape found no
unguarded occurrence
— every jq-to-line-wise-consumer site is either explicitly guarded, a
self-cleaning jq->jq relay, or a clean single-line capture. The full inventory, the classification
of each site, and the rationale for not adding a repo-wide lint will be recorded in the PR.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions