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
- 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.
- Cite
--ids from sync.md Steps 2-5.
- 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.
Summary
claude-ops'spluginsskill tells the reader to derive plugin ids fromfleet-state.sh's JSONand loop a
claude plugincall over them (Steps 2-5 ofcontext/sync.md), but never supplies theextraction command. Every reader therefore hand-writes their own
jq -r. On Windows thathand-written
jqreintroduces a CR the skill's ownfleet-state.shis careful to strip, and thesweep 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
Re-running the identical loop with
| tr -d '\r'gavechecked=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
jqbinary opens stdout in text mode, so every\nit writes becomes\r\n:Three facts decide which call sites are actually harmed. Two of them cut against the mechanism
context/gotchas.mddescribes 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:
gotchas.mdcurrently states the opposite ("even single-line compact JSON output ends\r\n... sothe
\rsurvives at the end of the captured value"), which predicts the wrong symptom — all idsfailing, 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:
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 afile write. That is exactly the shape the production loop had.
3.
mapfile -tis unsafe for every element, including the last —-tstrips the newline butnot the CR, so it has no equivalent of the
$(...)last-element reprieve.IFS=$'\n'does not help in any of these:\ris not the separator, it rides inside the token.Why the existing gotcha did not prevent it
context/gotchas.mdalready carries a CR section (#1177) andfleet-state.sh:105already routesits own calls through
jq() { command jq "$@" | tr -d '\r'; }— the script is not at fault. The gapis directional: the gotcha points at
sync.md, butsync.mdSteps 2-5 never point back at thegotcha and never give a guarded extraction. A reader following Step 3 writes unguarded
jq, hitsPlugin "<name>" not found— byte-identical to the documented bare-name gotcha — and concludes themarketplace is broken.
Proposed fix
fleet-state.shan--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.--idsfromsync.mdSteps 2-5.gotchas.md(points 1-3 above) rather than adding a second CR section.Repo-wide sweep
A sweep of all 499 tracked
.shfiles and 1120.mdfiles for the hazard shape found nounguarded 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.