From c55aa97696b30054d4fc55da201bdb29b509f50a Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 10:50:29 -0300 Subject: [PATCH 1/5] feat(doctor): report a CLI older than the plugin, and a statusLine that is not ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty statusline had two silent causes, and doctor named neither. A cprof older than the installed plugin is the first. The two halves update through different channels — the CLI through Homebrew or the curl installer, the plugin through `cprof update` — so they drift, and the drift hides: an interactive shell may reach the plugin's own copy through a resolver function while every subprocess Claude Code spawns gets whatever PATH holds. A CLI from before 0.13.0 treats `cprof statusline` as an unknown subcommand, writes usage to stderr and exits non-zero, which a statusline renders as nothing at all. doctor now compares the two and names the fix for whichever is behind, and fails while they disagree, because a stale CLI withholds documented features. Version comparison is numeric per segment, since string order ranks 0.9.0 above 0.13.0 — which is how a three-release-old CLI looked current. A version that is not a plain dotted number is never called older, so a dev build produces no advice, and a version is only ever printed once it has parsed: that is what keeps a control byte in a binary's output out of the report. The second cause is a statusLine pointing somewhere else entirely, which cprof could not see. doctor now names the settings file of the profile a session here would use, and reports whether its command references cprof. A wrapper script counts: when the command names a readable file, the check looks inside it one level, so the documented setup — a command line that says only `bash "$HOME/.claude/statusline.sh"` — is recognised by the script's contents instead of being called foreign. The command itself is never echoed back; a JSON string can hold any byte, and the report names the file only. This one does not fail doctor, since running another statusline is a choice. CP_CPROF_BIN joins CP_CLAUDE_BIN and CP_CURL_BIN as a test seam: set and empty means "no cprof on PATH", which a test cannot otherwise arrange on a machine that has one installed. docs/statusline.md gains a section on an empty line, including the one precaution that makes verification trustworthy — `cprof` is often a shell function, so a command must be tested through `sh -c` with a payload on stdin, the way the subprocess will run it, not in an interactive shell where a function hides the failure. Signed-off-by: Diego Cotelo --- CHANGELOG.md | 11 ++ README.md | 2 +- docs/statusline.md | 49 ++++++++ scripts/lib/auth.sh | 13 ++- scripts/lib/statusline.sh | 72 ++++++++++++ scripts/lib/update.sh | 98 ++++++++++++++++ tests/lib.sh | 3 + tests/test_doctor_hints.sh | 225 +++++++++++++++++++++++++++++++++++++ 8 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 tests/test_doctor_hints.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e07fa8..a96168d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ Notable changes per release. Versions follow [semver](https://semver.org); the release workflow reads its notes from the section matching the tag. ## [Unreleased] +### Added +- `cprof doctor` reports two install problems that used to be invisible. It + compares the `cprof` on `PATH` against the newest installed plugin and names + the fix for whichever is behind — the two halves update through different + channels, and a CLI older than the plugin lacks subcommands the plugin's own + docs describe, which is enough to render an empty statusline. It also reports + when Claude Code's `statusLine` is set to a command that does not reference + `cprof`, naming the settings file without quoting the command back. Skew + fails `doctor`; the wiring report does not, since running another statusline + is a choice. + ### Changed - The README is the showcase and the quickstart; the reference material moved into `docs/` — routing, install details, commands, the statusline, usage and diff --git a/README.md b/README.md index 65bdfbb..99255c3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Platform](https://img.shields.io/badge/Platform-macOS-1a1b27?style=for-the-badge&color=7aa2f7)](#install) [![Bash](https://img.shields.io/badge/Bash-3.2%2B-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) [![Requires](https://img.shields.io/badge/Requires-jq-1a1b27?style=for-the-badge&color=7aa2f7)](#install) -[![Tests](https://img.shields.io/badge/Tests-1055%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) +[![Tests](https://img.shields.io/badge/Tests-1109%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) diff --git a/docs/statusline.md b/docs/statusline.md index 37c624a..4793929 100644 --- a/docs/statusline.md +++ b/docs/statusline.md @@ -314,3 +314,52 @@ printf '%s' "$payload" | your-existing-statusline Neither entry point fails a statusline: a missing `jq`, an unreadable config, an absent `git`, or a missing CLI prints nothing, or as much of the line as it can, and exits 0. + +## When the line is empty + +`cprof doctor` reports the two causes that produce no output at all: + +```console +$ cprof doctor +work: ok +cprof on PATH is 0.9.0; the installed plugin is 0.13.0 - run: brew upgrade dcotelo/tap/cprof +statusLine in ~/.claude/settings.json is set but does not reference cprof - see docs/statusline.md +active profile here: work +``` + +**A CLI older than the plugin.** The two halves update through different +channels — the CLI through Homebrew or the curl installer, the plugin through +`cprof update` — so they drift apart, and `cprof statusline` only exists from +0.13.0. An older CLI on `PATH` treats it as an unknown subcommand, writes usage +to stderr and exits non-zero, which a statusline shows as nothing. `doctor` +fails while this is true, because it silently withholds features the rest of +this page documents. A statusline wired to `segment.sh` is immune either way: +the segment resolves the CLI beside itself, not through `PATH`. + +**A `statusLine` pointing somewhere else.** `doctor` names the settings file +and says the command does not reference `cprof`. A wrapper script of your own +counts: when the command names a readable file, `doctor` looks inside it one +level, so the setup above — whose command line says only +`bash "$HOME/.claude/statusline.sh"` — is recognised by the script's contents +rather than reported. It does not fail on this and +does not quote the command back — running another statusline is a choice, and +the configured string is data, not something to echo into a terminal. + +**Verifying a command by hand takes one precaution.** `cprof` is often a shell +*function* — the resolver in +[Installing the plugin without Homebrew](install.md#plugin-without-homebrew) +is one, and so is anything similar in your own shell config. A function exists +only in an interactive shell, while Claude Code runs the statusline in a +subprocess that has none. So test the way the subprocess will run it, with a +payload on stdin: + +```bash +payload='{"model":{"display_name":"Opus 5"},"workspace":{"current_dir":"'"$PWD"'"}, + "context_window":{"used_percentage":39}, + "rate_limits":{"five_hour":{"used_percentage":8}}}' +printf '%s' "$payload" | sh -c "$(jq -r .statusLine.command ~/.claude/settings.json)" +``` + +Two lines of output mean it works. Nothing, or a usage message, means the +command resolved to something the subprocess cannot run — which is exactly what +`sh -c` exposes and an interactive shell hides. diff --git a/scripts/lib/auth.sh b/scripts/lib/auth.sh index 5c4d23a..a463241 100644 --- a/scripts/lib/auth.sh +++ b/scripts/lib/auth.sh @@ -166,7 +166,7 @@ cp_cmd_login() { } cp_cmd_doctor() { - local cfg names name st logged active ms left_days status=0 usage_data pct CP_COLOR_ON=0 resets problems + local cfg names name st logged active ms left_days status=0 usage_data pct CP_COLOR_ON=0 resets problems skew sl_dir cfg="$(cp_config_read)" || return 1 # cp_usage_render (usage.sh) reads CP_COLOR_ON through bash's dynamic # scoping, the same cross-file pattern cp_colorize already relies on. @@ -221,6 +221,17 @@ cp_cmd_doctor() { printf '%s\n' "$problems" status=1 fi + # A CLI older than the plugin is a real defect in the install, not a + # preference: it silently lacks subcommands the plugin's docs describe. + skew="$(cp_skew_problems)" + if [ -n "$skew" ]; then + printf '%s\n' "$skew" + status=1 + fi + # Claude Code reads settings from the config directory of the profile a + # session here would use, so that is the file worth reporting on. + sl_dir="$(cp_profile_dir "$cfg" "${active:-}")" + cp_sl_wiring_problems "${sl_dir:-$HOME/.claude}/settings.json" printf 'active profile here: %s\n' "${active:-none}" return "$status" } diff --git a/scripts/lib/statusline.sh b/scripts/lib/statusline.sh index 70a7ff0..5fd7d10 100644 --- a/scripts/lib/statusline.sh +++ b/scripts/lib/statusline.sh @@ -719,3 +719,75 @@ cp_cmd_statusline() { cp_sl_assemble "$layout" "$sep" return 0 } + +# cp_sl_wiring_names_cprof +# +# True when a file named on the command line mentions cprof. The command is +# split on whitespace and read, never evaluated: $HOME and a leading ~ are +# substituted textually, and nothing else in it is expanded, so a hostile +# string reaches nothing but `grep`. +cp_sl_wiring_names_cprof() { + local cmd="${1:-}" tok toks=() i=0 + # read -a splits on IFS without globbing or expanding. + read -r -a toks <<< "$cmd" + while [ "$i" -lt "${#toks[@]}" ]; do + tok="${toks[$i]}" + i=$((i + 1)) + tok="${tok%\"}"; tok="${tok#\"}" + tok="${tok%\'}"; tok="${tok#\'}" + # Both patterns match text, not a path this shell should expand: the + # configured command holds a literal $HOME or ~ that only Claude Code's + # own shell would ever expand, so the substitution is done by hand. + # shellcheck disable=SC2016,SC2088 + case "$tok" in + '$HOME'/*) tok="$HOME/${tok#\$HOME/}" ;; + '~/'*) tok="$HOME/${tok#\~/}" ;; + /*) : ;; + *) continue ;; + esac + [ -r "$tok" ] || continue + [ -f "$tok" ] || continue + grep -q cprof "$tok" 2>/dev/null && return 0 + done + return 1 +} + +# cp_sl_wiring_problems +# +# What Claude Code's `statusLine` is pointed at. Silent when it names cprof and +# when it is not configured at all — not configuring one is a choice, and so is +# running a different one, so this reports rather than fails. +# +# The configured command is a JSON string that can hold anything, so jq returns +# only a classification and the command itself is never echoed back. Quoting it +# would put an attacker-chosen byte sequence in a terminal. +cp_sl_wiring_problems() { + local f="${1:-}" cls shown cmd + [ -r "$f" ] || return 0 + cls="$(jq -r ' + if (has("statusLine") | not) then "absent" + elif (.statusLine | type) != "object" then "malformed" + elif (.statusLine.command | type) != "string" then "malformed" + elif (.statusLine.command | index("cprof")) != null then "ours" + else "foreign" + end' "$f" 2>/dev/null)" || return 0 + # The documented setup points at a wrapper script of the user's own, whose + # command line need not mention cprof at all -- so before calling one + # foreign, follow it one level and look inside. + if [ "$cls" = 'foreign' ]; then + cmd="$(jq -r '.statusLine.command' "$f" 2>/dev/null)" + cp_sl_wiring_names_cprof "$cmd" && cls='ours' + fi + shown="$(cp_path_display "$f" | LC_ALL=C tr -d '\000-\037\177')" + case "$cls" in + foreign) + printf 'statusLine in %s is set but does not reference cprof - see docs/statusline.md\n' \ + "$shown" + ;; + malformed) + printf 'statusLine in %s is not a command object - see docs/statusline.md\n' \ + "$shown" + ;; + *) return 0 ;; + esac +} diff --git a/scripts/lib/update.sh b/scripts/lib/update.sh index 2014e75..e569aff 100644 --- a/scripts/lib/update.sh +++ b/scripts/lib/update.sh @@ -25,3 +25,101 @@ cp_cmd_update() { [ "$rc" -eq 0 ] && printf 'restart Claude Code to apply the update\n' return "$rc" } + +# Version skew between the cprof on PATH and the newest installed plugin. +# +# The two halves update through different channels — the CLI through Homebrew +# or the curl installer, the plugin through `cprof update` — so they drift, and +# the drift is invisible: an interactive shell may reach the plugin's own copy +# through a resolver function while every subprocess Claude Code spawns gets +# whatever `PATH` holds. A statusline wired to `cprof statusline` then renders +# nothing at all when the CLI on PATH predates that subcommand. + +# True when a version is a plain dotted number, the only shape worth comparing. +# A dev build or a pre-release tag is deliberately not comparable, so it never +# produces advice. +cp_ver_parseable() { + case "${1:-}" in + ''|*[!0-9.]*|*..*|.*|*.) return 1 ;; + *) return 0 ;; + esac +} + +# cp_ver_lt — true when a is strictly older than b, compared segment +# by segment as numbers. String order would rank 0.9.0 above 0.13.0, which is +# exactly the skew this check exists to catch. +cp_ver_lt() { + local a="${1:-}" b="${2:-}" i=1 ai bi + cp_ver_parseable "$a" || return 1 + cp_ver_parseable "$b" || return 1 + while [ "$i" -le 4 ]; do + ai="$(printf '%s' "$a" | cut -d. -f"$i")" + bi="$(printf '%s' "$b" | cut -d. -f"$i")" + # A missing segment reads as zero, so 0.13 and 0.13.0 are equal. 10# keeps + # a zero-padded segment decimal rather than octal. + ai=$((10#0${ai:-0})) + bi=$((10#0${bi:-0})) + [ "$ai" -lt "$bi" ] && return 0 + [ "$ai" -gt "$bi" ] && return 1 + i=$((i + 1)) + done + return 1 +} + +# The newest cprof version installed as a plugin, or empty when none is. +# Reads each manifest rather than trusting the directory name, and ignores one +# it cannot parse: a corrupt manifest is not evidence of a version. +cp_plugin_version() { + local f v newest='' + for f in "$HOME"/.claude/plugins/cache/*/cprof/*/plugin.json; do + [ -r "$f" ] || continue + v="$(jq -r '.version // empty' "$f" 2>/dev/null)" || continue + cp_ver_parseable "$v" || continue + if [ -z "$newest" ] || cp_ver_lt "$newest" "$v"; then + newest="$v" + fi + done + printf '%s' "$newest" +} + +# cp_skew_report +# +# Prints at most one advisory line. Both versions reach this from outside — one +# from a binary's stdout, one from a JSON file — so neither is echoed unless it +# parsed as a dotted number, which is what keeps a control byte in either from +# reaching the report. +cp_skew_report() { + local pv="${1:-}" gv="${2:-}" where="${3:-}" + where="$(printf '%s' "$where" | LC_ALL=C tr -d '\000-\037\177')" + # No cprof on PATH is a supported install (the plugin carries its own), and + # no plugin installed leaves nothing to compare against. + [ -n "$where" ] || return 0 + cp_ver_parseable "$gv" || return 0 + if ! cp_ver_parseable "$pv"; then + printf 'could not read the version of the cprof at %s - skipping the version check\n' \ + "$where" + return 0 + fi + if cp_ver_lt "$pv" "$gv"; then + printf 'cprof on PATH is %s; the installed plugin is %s - run: brew upgrade dcotelo/tap/cprof\n' \ + "$pv" "$gv" + elif cp_ver_lt "$gv" "$pv"; then + printf 'the installed plugin is %s; cprof on PATH is %s - run: cprof update\n' \ + "$gv" "$pv" + fi +} + +# Resolves both versions and reports. `type -P` deliberately ignores functions +# and aliases: the question is what a subprocess would run, not what this +# shell resolves. +# +# CP_CPROF_BIN overrides the lookup, the same seam CP_CLAUDE_BIN and +# CP_CURL_BIN provide for their tools. Set and empty means "no cprof on PATH", +# which a test cannot otherwise arrange on a machine that has one installed. +cp_skew_problems() { + local bin pv + bin="${CP_CPROF_BIN-$(type -P cprof 2>/dev/null)}" || bin='' + [ -n "$bin" ] || return 0 + pv="$("$bin" version 2>/dev/null | head -1 | awk '{print $2}')" || pv='' + cp_skew_report "$pv" "$(cp_plugin_version)" "$bin" +} diff --git a/tests/lib.sh b/tests/lib.sh index 30a0eac..2768d04 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -25,6 +25,9 @@ cp_t_setup() { # Usage and fallback controls a developer's shell may carry must not leak # into the fixtures (CPROF_NO_USAGE=1 would silently skip every fetch). unset CPROF_NO_USAGE CP_USAGE_URL CPROF_FALLBACK_THRESHOLD + # The version-skew seam: a developer's shell must not decide which cprof the + # fixtures compare against. + unset CP_CPROF_BIN } cp_t_teardown() { diff --git a/tests/test_doctor_hints.sh b/tests/test_doctor_hints.sh new file mode 100644 index 0000000..1a4f2ce --- /dev/null +++ b/tests/test_doctor_hints.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +set -u +# Version skew between the cprof on PATH and the installed plugin, and what +# `statusLine` is wired to. Both are advisory: a user can run a stale CLI or +# another statusline on purpose, but neither should be invisible. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" +cp_t_setup +trap cp_t_teardown EXIT +for lib in config resolve profiles auth output color usage statusline fallback share update; do + # shellcheck source=/dev/null + . "$(dirname "$0")/../scripts/lib/$lib.sh" +done +CLI="$(cd "$(dirname "$0")/.." && pwd -P)/scripts/cprof" +ESC="$(printf '\033')" + +# --------------------------------------------------------- cp_ver_lt ordering +# String comparison is the bug being guarded against: "0.9.0" sorts after +# "0.13.0" lexically, which is how a stale CLI looked current. +assert_ok cp_ver_lt 0.9.0 0.13.0 +assert_fail cp_ver_lt 0.13.0 0.9.0 +assert_fail cp_ver_lt 0.13.0 0.13.0 +assert_ok cp_ver_lt 0.13.0 0.13.1 +assert_ok cp_ver_lt 0.13.9 0.14.0 +assert_fail cp_ver_lt 1.0.0 0.99.99 +assert_ok cp_ver_lt 0.99.99 1.0.0 +# A missing segment reads as zero, so these are equal, not less. +assert_fail cp_ver_lt 0.13 0.13.0 +assert_fail cp_ver_lt 0.13.0 0.13 +# Nothing comparable is never "less". +for bad in '' x 1.2.x '0.13.0-rc1' '1..2' ' 1.2.3'; do + assert_fail cp_ver_lt "$bad" 9.9.9 + assert_fail cp_ver_lt 9.9.9 "$bad" +done + +# ------------------------------------------------------- cp_skew_report lines +out="$(cp_skew_report 0.9.0 0.13.0 /opt/homebrew/bin/cprof)" +assert_eq '1' "$(printf '%s' "$out" | grep -c .)" 'a stale CLI reports one line' +case "$out" in + *'0.9.0'*'0.13.0'*'brew upgrade'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'the stale-CLI line names both versions and the fix' + +out="$(cp_skew_report 0.13.0 0.12.0 /opt/homebrew/bin/cprof)" +case "$out" in + *'0.12.0'*'cprof update'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'a stale plugin points at cprof update instead' + +assert_eq '' "$(cp_skew_report 0.13.0 0.13.0 /bin/cprof)" 'agreement is silent' +assert_eq '' "$(cp_skew_report '' 0.13.0 '')" 'no cprof on PATH is silent' +assert_eq '' "$(cp_skew_report 0.13.0 '' /bin/cprof)" 'no plugin installed is silent' + +out="$(cp_skew_report 'weird output' 0.13.0 /bin/cprof)" +case "$out" in + *'could not read'*'/bin/cprof'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'an unreadable CLI version says so, naming the path' + +# A version string is data: it must not carry control bytes into the report. +out="$(cp_skew_report "0.9.0${ESC}[31m" 0.13.0 /bin/cprof)" +assert_eq '' "$(printf '%s' "$out" | LC_ALL=C tr -d '\040-\176\n')" \ + 'no control byte survives a hostile version string' +assert_eq '1' "$(printf '%s' "$out" | grep -c .)" 'and it still reports one line' + +# ------------------------------------------------- cp_skew_problems, resolved +mk_path_cprof() { # $1 = what `cprof version` prints; '' = no binary at all + rm -f "$CP_T_TMP/bin/cprof" + [ -n "${1:-}" ] || return 0 + printf '#!/usr/bin/env bash\nprintf "%%s\\\\n" %s\n' "$(printf '%q' "$1")" \ + > "$CP_T_TMP/bin/cprof" + chmod +x "$CP_T_TMP/bin/cprof" +} +mk_plugin() { # $@ = versions to install in the plugin cache + rm -rf "$HOME/.claude/plugins/cache" + for v in "$@"; do + mkdir -p "$HOME/.claude/plugins/cache/dcotelo/cprof/$v" + printf '{"name":"cprof","version":"%s"}\n' "$v" \ + > "$HOME/.claude/plugins/cache/dcotelo/cprof/$v/plugin.json" + done +} +PATH="$CP_T_TMP/bin:$PATH" + +mk_path_cprof 'cprof 0.9.0'; mk_plugin 0.13.0 +out="$(cp_skew_problems)" +case "$out" in + *'0.9.0'*'0.13.0'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'probe finds a stale PATH CLI against a newer plugin' + +mk_plugin 0.9.0 0.11.0 0.13.0 +out="$(cp_skew_problems)" +case "$out" in + *'0.13.0'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'the newest installed plugin wins, not the last globbed' + +mk_path_cprof 'cprof 0.13.0'; mk_plugin 0.13.0 +assert_eq '' "$(cp_skew_problems)" 'matching versions report nothing' + +mk_plugin +assert_eq '' "$(cp_skew_problems)" 'a brew-only install reports nothing' + +mk_plugin 0.13.0 +assert_eq '' "$(CP_CPROF_BIN='' cp_skew_problems)" \ + 'a plugin-only install reports nothing' + +mk_path_cprof 'cprof 0.9.0'; mk_plugin 0.13.0 +printf 'not json\n' > "$HOME/.claude/plugins/cache/dcotelo/cprof/0.13.0/plugin.json" +assert_eq '' "$(cp_skew_problems)" 'an unparseable plugin.json advises nothing' + +mk_path_cprof 'cprof (dev build)'; mk_plugin 0.13.0 +out="$(cp_skew_problems)" +case "$out" in + *'could not read'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'a CLI with no parseable version is reported, not ignored' + +# ------------------------------------------------------ cp_sl_wiring_problems +SETTINGS="$CP_T_TMP/settings.json" +rm -f "$SETTINGS" +assert_eq '' "$(cp_sl_wiring_problems "$SETTINGS")" 'a missing settings file is silent' + +printf '{"model":"opus"}\n' > "$SETTINGS" +assert_eq '' "$(cp_sl_wiring_problems "$SETTINGS")" 'no statusLine key is silent' + +printf '{"statusLine":{"type":"command","command":"bash /nowhere/other.sh"}}\n' > "$SETTINGS" +assert_eq '1' "$(cp_sl_wiring_problems "$SETTINGS" | grep -c .)" \ + 'a command naming neither cprof nor a readable script is reported' + +# The documented setup: statusLine runs a wrapper of the user's own whose +# command line never says "cprof". Following it one level is what keeps the +# project's own recommendation from being reported as foreign. +WRAP="$HOME/.claude/statusline.sh" +mkdir -p "$HOME/.claude" +cat > "$WRAP" <<'SL' +#!/usr/bin/env bash +seg=$({ ls -1 "$HOME"/.claude/plugins/cache/*/cprof/*/statusline/segment.sh ; } 2>/dev/null | sort -V | tail -1) +[ -r "$seg" ] && bash "$seg" --full +exit 0 +SL +jq -n --arg c 'bash "$HOME/.claude/statusline.sh"' \ + '{statusLine:{type:"command",command:$c}}' > "$SETTINGS" +assert_eq '' "$(cp_sl_wiring_problems "$SETTINGS")" \ + 'the documented wrapper script is accepted through its contents' + +jq -n --arg c 'bash ~/.claude/statusline.sh' \ + '{statusLine:{type:"command",command:$c}}' > "$SETTINGS" +assert_eq '' "$(cp_sl_wiring_problems "$SETTINGS")" \ + 'and the same wrapper written with a tilde' + +printf '#!/usr/bin/env bash\nprintf "something else\\n"\n' > "$WRAP" +assert_eq '1' "$(cp_sl_wiring_problems "$SETTINGS" | grep -c .)" \ + 'a wrapper that mentions cprof nowhere is still reported' +rm -f "$WRAP" + +printf '{"statusLine":{"type":"command","command":"cprof statusline --stdin"}}\n' > "$SETTINGS" +assert_eq '' "$(cp_sl_wiring_problems "$SETTINGS")" 'the documented direct command is accepted' + +printf '{"statusLine":"whatever"}\n' > "$SETTINGS" +assert_eq '1' "$(cp_sl_wiring_problems "$SETTINGS" | grep -c .)" \ + 'a malformed statusLine is reported once' + +# The configured command is data — a JSON string can hold anything — so the +# report names the file and never echoes the command back. +jq -n --arg c "run${ESC}[2K me" '{statusLine:{type:"command",command:$c}}' > "$SETTINGS" +out="$(cp_sl_wiring_problems "$SETTINGS")" +assert_eq '' "$(printf '%s' "$out" | LC_ALL=C tr -d '\040-\176\n')" \ + 'no control byte from the configured command reaches the report' +assert_eq '1' "$(printf '%s' "$out" | grep -c .)" 'and the report stays one line' +case "$out" in + *'run'*) r='echoed the command' ;; + *) r='named the file only' ;; +esac +assert_eq 'named the file only' "$r" 'the report does not quote the command back' + +# -------------------------------------------------------- doctor integration +mkdir -p "$CP_T_TMP/state/usage" +cp_t_write_config < "$CP_CLAUDE_BIN" <<'STUB' +#!/usr/bin/env bash +printf '{"loggedIn":true,"account":{"email":"you@example.com"}}\n' +STUB +chmod +x "$CP_CLAUDE_BIN" +mkdir -p "$HOME/.claude" + +mk_path_cprof 'cprof 0.9.0'; mk_plugin 0.13.0 +out="$("$CLI" doctor 2>&1)"; rc=$? +case "$out" in + *'0.9.0'*'0.13.0'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'doctor surfaces version skew' +assert_eq '1' "$rc" 'and fails, because a stale CLI is actionable' + +mk_path_cprof 'cprof 0.13.0'; mk_plugin 0.13.0 +printf '{"statusLine":{"type":"command","command":"bash /somewhere/else.sh"}}\n' \ + > "$HOME/.claude/settings.json" +out="$("$CLI" doctor 2>&1)"; rc=$? +case "$out" in + *statusLine*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'doctor reports a statusLine pointing away from cprof' +assert_eq '0' "$rc" 'but does not fail: another statusline is a choice' + +rm -f "$HOME/.claude/settings.json" +out="$("$CLI" doctor 2>&1)"; rc=$? +assert_eq '0' "$rc" 'a clean setup still exits zero' +case "$out" in + *statusLine*|*'0.9.0'*) r="noisy: $out" ;; + *) r=quiet ;; +esac +assert_eq 'quiet' "$r" 'and says nothing about either hint' + +cp_t_summary From 584abf67456a5a71db67c37c2854d06f50d1869d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:51:13 +0000 Subject: [PATCH 2/5] chore(release): 0.14.0 --- .claude-plugin/marketplace.json | 4 ++-- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 3 ++- scripts/cprof | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d39a49d..275509b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,13 +7,13 @@ }, "metadata": { "description": "Per-repository Claude account switching: keep personal and work subscriptions separate.", - "version": "0.13.0" + "version": "0.14.0" }, "plugins": [ { "name": "cprof", "source": "./", - "version": "0.13.0", + "version": "0.14.0", "description": "Select which Claude account a session uses, by default profile, per-repo pin, or directory rule. Each profile is its own config directory, so a work subscription and a personal one never share credentials.", "category": "workflow", "keywords": [ diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1a903a4..626e927 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cprof", - "version": "0.13.0", + "version": "0.14.0", "description": "Select which Claude account a session uses, by default profile, per-repo pin, or directory rule.", "author": { "name": "Diego Cotelo", diff --git a/CHANGELOG.md b/CHANGELOG.md index a96168d..e0ad77e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Notable changes per release. Versions follow [semver](https://semver.org); the release workflow reads its notes from the section matching the tag. ## [Unreleased] + +## [0.14.0] ### Added - `cprof doctor` reports two install problems that used to be invisible. It compares the `cprof` on `PATH` against the newest installed plugin and names @@ -21,7 +23,6 @@ release workflow reads its notes from the section matching the tag. fallback, and safety — with development, the dependency policy and releasing in `CONTRIBUTING.md`. Every section kept its heading, so an anchor such as `#statusline` still resolves inside the file it moved to. - ## [0.13.0] ### Added - `cprof statusline` draws the whole statusline in one `cprof` invocation, diff --git a/scripts/cprof b/scripts/cprof index 5dc8e61..478068f 100755 --- a/scripts/cprof +++ b/scripts/cprof @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -u -CP_VERSION='0.13.0' +CP_VERSION='0.14.0' # The plugin's own marketplace and scoped name, consumed by cp_cmd_update. # 'update' always targets this specific listing — it is not something a user # configures, so it lives next to CP_VERSION rather than in ~/.cprof.json. From a03188e2e81dc7c820d9d3b2bf1df134f4f7ff79 Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 11:06:31 -0300 Subject: [PATCH 3/5] fix(doctor): compare every version component, and fail only for a real skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the version check, three accepted and one narrowed. `cp_ver_lt` split versions with `cut -d. -f`, which prints the whole line when the delimiter is absent: a single-component "1" answered "1" for every field, so `cp_ver_lt 1.0 1` called 1.0 the older of two equal versions. It now splits into arrays and compares as many components as the longer side has, which also fixes a version carrying more components than a release ever does — 1.2.3.4.5 and 1.2.3.4.6 compared equal when the loop stopped at four. `doctor` failed for any advisory the check produced, including "could not read the version", which would leave anyone running a dev build with a permanently failing doctor. That state now prints without failing. A plugin older than the CLI keeps failing, the same as the reverse: both halves are one command from current, and doctor already fails on comparable actionable states — an expiring refresh token, a window past 90%. The CLI path in the advisory goes through `cp_path_display`, as every other path shown to a user does. Assertions for each: both directions of 1 against 1.0 and 1.0.1, the five-component pair, the report's exit status for all four states separately from its output, the shortened path, and two doctor integrations for the states that must not fail it. Signed-off-by: Diego Cotelo --- README.md | 2 +- docs/statusline.md | 4 ++- scripts/lib/auth.sh | 7 ++--- scripts/lib/update.sh | 29 +++++++++++++++------ tests/test_doctor_hints.sh | 52 +++++++++++++++++++++++++++++++++++++- 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 99255c3..5267140 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Platform](https://img.shields.io/badge/Platform-macOS-1a1b27?style=for-the-badge&color=7aa2f7)](#install) [![Bash](https://img.shields.io/badge/Bash-3.2%2B-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) [![Requires](https://img.shields.io/badge/Requires-jq-1a1b27?style=for-the-badge&color=7aa2f7)](#install) -[![Tests](https://img.shields.io/badge/Tests-1109%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) +[![Tests](https://img.shields.io/badge/Tests-1127%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) diff --git a/docs/statusline.md b/docs/statusline.md index 4793929..8288512 100644 --- a/docs/statusline.md +++ b/docs/statusline.md @@ -333,7 +333,9 @@ channels — the CLI through Homebrew or the curl installer, the plugin through 0.13.0. An older CLI on `PATH` treats it as an unknown subcommand, writes usage to stderr and exits non-zero, which a statusline shows as nothing. `doctor` fails while this is true, because it silently withholds features the rest of -this page documents. A statusline wired to `segment.sh` is immune either way: +this page documents — and it fails the same way when the plugin is the older +half, which `cprof update` fixes. A version `doctor` cannot parse at all is +reported without failing, so a dev build is not treated as a broken install. A statusline wired to `segment.sh` is immune either way: the segment resolves the CLI beside itself, not through `PATH`. **A `statusLine` pointing somewhere else.** `doctor` names the settings file diff --git a/scripts/lib/auth.sh b/scripts/lib/auth.sh index a463241..ac496a4 100644 --- a/scripts/lib/auth.sh +++ b/scripts/lib/auth.sh @@ -223,11 +223,12 @@ cp_cmd_doctor() { fi # A CLI older than the plugin is a real defect in the install, not a # preference: it silently lacks subcommands the plugin's docs describe. - skew="$(cp_skew_problems)" - if [ -n "$skew" ]; then - printf '%s\n' "$skew" + # Non-zero means a real skew, which is a defect in the install. A version it + # could not read still prints, but does not fail: a dev build is not broken. + if ! skew="$(cp_skew_problems)"; then status=1 fi + [ -n "$skew" ] && printf '%s\n' "$skew" # Claude Code reads settings from the config directory of the profile a # session here would use, so that is the file worth reporting on. sl_dir="$(cp_profile_dir "$cfg" "${active:-}")" diff --git a/scripts/lib/update.sh b/scripts/lib/update.sh index e569aff..43163d5 100644 --- a/scripts/lib/update.sh +++ b/scripts/lib/update.sh @@ -49,16 +49,22 @@ cp_ver_parseable() { # by segment as numbers. String order would rank 0.9.0 above 0.13.0, which is # exactly the skew this check exists to catch. cp_ver_lt() { - local a="${1:-}" b="${2:-}" i=1 ai bi + local a="${1:-}" b="${2:-}" i=0 n ai bi + local -a av bv cp_ver_parseable "$a" || return 1 cp_ver_parseable "$b" || return 1 - while [ "$i" -le 4 ]; do - ai="$(printf '%s' "$a" | cut -d. -f"$i")" - bi="$(printf '%s' "$b" | cut -d. -f"$i")" - # A missing segment reads as zero, so 0.13 and 0.13.0 are equal. 10# keeps - # a zero-padded segment decimal rather than octal. - ai=$((10#0${ai:-0})) - bi=$((10#0${bi:-0})) + # Split rather than `cut -d. -f`: without -s, cut prints the whole line + # when the delimiter is absent, so a single-component "1" reported "1" for + # every field and compared as though it were 1.1.1. + IFS=. read -r -a av <<< "$a" + IFS=. read -r -a bv <<< "$b" + n=${#av[@]} + [ "${#bv[@]}" -gt "$n" ] && n=${#bv[@]} + while [ "$i" -lt "$n" ]; do + # A missing component reads as zero, so 0.13 and 0.13.0 are equal. The + # 10# prefix keeps a zero-padded component decimal rather than octal. + ai=$((10#0${av[$i]:-0})) + bi=$((10#0${bv[$i]:-0})) [ "$ai" -lt "$bi" ] && return 0 [ "$ai" -gt "$bi" ] && return 1 i=$((i + 1)) @@ -88,9 +94,13 @@ cp_plugin_version() { # from a binary's stdout, one from a JSON file — so neither is echoed unless it # parsed as a dotted number, which is what keeps a control byte in either from # reaching the report. +# +# Returns non-zero only for a real skew. A version it could not read is a +# diagnostic, not a defect: a dev build would otherwise fail doctor forever. cp_skew_report() { local pv="${1:-}" gv="${2:-}" where="${3:-}" where="$(printf '%s' "$where" | LC_ALL=C tr -d '\000-\037\177')" + where="$(cp_path_display "$where")" # No cprof on PATH is a supported install (the plugin carries its own), and # no plugin installed leaves nothing to compare against. [ -n "$where" ] || return 0 @@ -103,10 +113,13 @@ cp_skew_report() { if cp_ver_lt "$pv" "$gv"; then printf 'cprof on PATH is %s; the installed plugin is %s - run: brew upgrade dcotelo/tap/cprof\n' \ "$pv" "$gv" + return 1 elif cp_ver_lt "$gv" "$pv"; then printf 'the installed plugin is %s; cprof on PATH is %s - run: cprof update\n' \ "$gv" "$pv" + return 1 fi + return 0 } # Resolves both versions and reports. `type -P` deliberately ignores functions diff --git a/tests/test_doctor_hints.sh b/tests/test_doctor_hints.sh index 1a4f2ce..afa726e 100644 --- a/tests/test_doctor_hints.sh +++ b/tests/test_doctor_hints.sh @@ -25,9 +25,21 @@ assert_ok cp_ver_lt 0.13.0 0.13.1 assert_ok cp_ver_lt 0.13.9 0.14.0 assert_fail cp_ver_lt 1.0.0 0.99.99 assert_ok cp_ver_lt 0.99.99 1.0.0 -# A missing segment reads as zero, so these are equal, not less. +# A missing component reads as zero, so these are equal, not less. The +# single-component cases are the ones `cut` got wrong: without -s it prints the +# whole line when the delimiter is absent, so "1" answered "1" for every field. assert_fail cp_ver_lt 0.13 0.13.0 assert_fail cp_ver_lt 0.13.0 0.13 +assert_fail cp_ver_lt 1 1.0 +assert_fail cp_ver_lt 1.0 1 +assert_ok cp_ver_lt 1 1.0.1 +assert_fail cp_ver_lt 1.0.1 1 +assert_ok cp_ver_lt 1 2 +# More components than a release ever carries still compare, rather than +# reading as equal because the loop stopped early. +assert_ok cp_ver_lt 1.2.3.4.5 1.2.3.4.6 +assert_fail cp_ver_lt 1.2.3.4.6 1.2.3.4.5 +assert_fail cp_ver_lt 1.2.3.4.5 1.2.3.4.5 # Nothing comparable is never "less". for bad in '' x 1.2.x '0.13.0-rc1' '1..2' ' 1.2.3'; do assert_fail cp_ver_lt "$bad" 9.9.9 @@ -51,6 +63,23 @@ esac assert_eq 'yes' "$r" 'a stale plugin points at cprof update instead' assert_eq '' "$(cp_skew_report 0.13.0 0.13.0 /bin/cprof)" 'agreement is silent' + +# The status, not the output, is what decides whether doctor fails. +assert_fail cp_skew_report 0.9.0 0.13.0 /bin/cprof +assert_fail cp_skew_report 0.13.0 0.12.0 /bin/cprof +assert_ok cp_skew_report 0.13.0 0.13.0 /bin/cprof +assert_ok cp_skew_report 'weird output' 0.13.0 /bin/cprof +assert_ok cp_skew_report '' 0.13.0 '' + +# Paths shown to users go through cp_path_display, so the one line that names +# the CLI shortens a path under HOME the way every other cprof message does. +out="$(cp_skew_report 'weird output' 0.13.0 "$HOME/.local/bin/cprof")" +case "$out" in + *'~/.local/bin/cprof'*) r=yes ;; + *"$HOME"*) r="unshortened: $out" ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'the CLI path is displayed the way every other path is' assert_eq '' "$(cp_skew_report '' 0.13.0 '')" 'no cprof on PATH is silent' assert_eq '' "$(cp_skew_report 0.13.0 '' /bin/cprof)" 'no plugin installed is silent' @@ -202,6 +231,27 @@ esac assert_eq 'yes' "$r" 'doctor surfaces version skew' assert_eq '1' "$rc" 'and fails, because a stale CLI is actionable' +# A version it could not read is a diagnostic: reported, but not a failure, +# or a dev build would fail doctor forever. +mk_path_cprof 'cprof (dev build)'; mk_plugin 0.13.0 +out="$("$CLI" doctor 2>&1)"; rc=$? +case "$out" in + *'could not read'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'doctor reports a CLI whose version it cannot read' +assert_eq '0' "$rc" 'and does not fail for it' + +# A stale plugin is the mirror of a stale CLI, and just as actionable. +mk_path_cprof 'cprof 0.13.0'; mk_plugin 0.12.0 +out="$("$CLI" doctor 2>&1)"; rc=$? +case "$out" in + *'cprof update'*) r=yes ;; + *) r="no: $out" ;; +esac +assert_eq 'yes' "$r" 'doctor surfaces a plugin older than the CLI' +assert_eq '1' "$rc" 'and fails for it too' + mk_path_cprof 'cprof 0.13.0'; mk_plugin 0.13.0 printf '{"statusLine":{"type":"command","command":"bash /somewhere/else.sh"}}\n' \ > "$HOME/.claude/settings.json" From 29bbd9b367008718872aa0d56e92004fe9860077 Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 11:16:17 -0300 Subject: [PATCH 4/5] docs(doctor): the skew comment describes both directions, as the code does The comment above the call survived from the first commit, when only a stale CLI failed doctor, and sat beside the newer one that describes the actual contract. One comment now, covering either half being older: each carries behaviour the other cannot supply, which is why both fail. Signed-off-by: Diego Cotelo --- scripts/lib/auth.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/lib/auth.sh b/scripts/lib/auth.sh index ac496a4..22a8274 100644 --- a/scripts/lib/auth.sh +++ b/scripts/lib/auth.sh @@ -221,10 +221,11 @@ cp_cmd_doctor() { printf '%s\n' "$problems" status=1 fi - # A CLI older than the plugin is a real defect in the install, not a - # preference: it silently lacks subcommands the plugin's docs describe. - # Non-zero means a real skew, which is a defect in the install. A version it - # could not read still prints, but does not fail: a dev build is not broken. + # Either half being the older one is a real defect in the install, not a + # preference: each carries behaviour the other cannot supply — subcommands on + # the CLI side, the hooks and statusline segment on the plugin side — so a + # non-zero status means skew in either direction. A version it could not read + # still prints but does not fail; a dev build is not a broken install. if ! skew="$(cp_skew_problems)"; then status=1 fi From 1c4481c17cb397089f215dd89f124f48e2d0e90c Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 13:28:01 -0300 Subject: [PATCH 5/5] fix(doctor): a statusLine Claude Code will not run counts as malformed Full-review findings. The wiring classifier checked the command without checking the discriminator that decides whether the command runs at all. Claude Code requires `statusLine.type` to be `command`; anything else, or nothing, leaves the statusline unrun however good the command is. A wrong type with a cprof command therefore classified as ours and reported nothing. The type is now checked before the command, so a cprof command cannot mask it, and assertions cover a wrong type, a missing type, and the required type staying silent. docs/statusline.md claimed doctor reports two causes of an empty line. With the malformed diagnostic there are three, so the section now describes it: what `statusLine in is not a command object` covers, and why a type Claude Code does not accept belongs in that list. Signed-off-by: Diego Cotelo --- README.md | 2 +- docs/statusline.md | 9 ++++++++- scripts/lib/statusline.sh | 9 +++++++-- tests/test_doctor_hints.sh | 13 +++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5267140..f137e52 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Platform](https://img.shields.io/badge/Platform-macOS-1a1b27?style=for-the-badge&color=7aa2f7)](#install) [![Bash](https://img.shields.io/badge/Bash-3.2%2B-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) [![Requires](https://img.shields.io/badge/Requires-jq-1a1b27?style=for-the-badge&color=7aa2f7)](#install) -[![Tests](https://img.shields.io/badge/Tests-1127%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) +[![Tests](https://img.shields.io/badge/Tests-1130%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) diff --git a/docs/statusline.md b/docs/statusline.md index 8288512..a73f47a 100644 --- a/docs/statusline.md +++ b/docs/statusline.md @@ -317,7 +317,7 @@ it can, and exits 0. ## When the line is empty -`cprof doctor` reports the two causes that produce no output at all: +`cprof doctor` reports the causes that produce no output at all: ```console $ cprof doctor @@ -347,6 +347,13 @@ rather than reported. It does not fail on this and does not quote the command back — running another statusline is a choice, and the configured string is data, not something to echo into a terminal. +**A `statusLine` Claude Code will not run.** `type` is the discriminator, and +`command` is its only value, so a `statusLine` that omits it or sets something +else never runs however good its command is. `doctor` reports that as +`statusLine in is not a command object`, which also covers a +`statusLine` that is not an object at all and one whose `command` is not a +string. + **Verifying a command by hand takes one precaution.** `cprof` is often a shell *function* — the resolver in [Installing the plugin without Homebrew](install.md#plugin-without-homebrew) diff --git a/scripts/lib/statusline.sh b/scripts/lib/statusline.sh index 5fd7d10..cc5cd29 100644 --- a/scripts/lib/statusline.sh +++ b/scripts/lib/statusline.sh @@ -754,8 +754,12 @@ cp_sl_wiring_names_cprof() { # cp_sl_wiring_problems # -# What Claude Code's `statusLine` is pointed at. Silent when it names cprof and -# when it is not configured at all — not configuring one is a choice, and so is +# What Claude Code's `statusLine` is pointed at. `type` is the discriminator +# Claude Code requires, and "command" is its only value, so anything else +# leaves the statusline unrun however good the command is -- which is why the +# type is checked before the command it would run. +# +# Silent when the command names cprof and when nothing is configured at all — not configuring one is a choice, and so is # running a different one, so this reports rather than fails. # # The configured command is a JSON string that can hold anything, so jq returns @@ -767,6 +771,7 @@ cp_sl_wiring_problems() { cls="$(jq -r ' if (has("statusLine") | not) then "absent" elif (.statusLine | type) != "object" then "malformed" + elif .statusLine.type != "command" then "malformed" elif (.statusLine.command | type) != "string" then "malformed" elif (.statusLine.command | index("cprof")) != null then "ours" else "foreign" diff --git a/tests/test_doctor_hints.sh b/tests/test_doctor_hints.sh index afa726e..c564e04 100644 --- a/tests/test_doctor_hints.sh +++ b/tests/test_doctor_hints.sh @@ -197,6 +197,19 @@ printf '{"statusLine":"whatever"}\n' > "$SETTINGS" assert_eq '1' "$(cp_sl_wiring_problems "$SETTINGS" | grep -c .)" \ 'a malformed statusLine is reported once' +# type is the discriminator Claude Code requires, and "command" its only value: +# a wrong one leaves the statusline unrun however good the command is, so it is +# checked before the command -- otherwise a cprof command would mask it. +printf '{"statusLine":{"type":"cmd","command":"cprof statusline --stdin"}}\n' > "$SETTINGS" +assert_eq '1' "$(cp_sl_wiring_problems "$SETTINGS" | grep -c .)" \ + 'a wrong statusLine type is reported even when the command names cprof' +printf '{"statusLine":{"command":"cprof statusline --stdin"}}\n' > "$SETTINGS" +assert_eq '1' "$(cp_sl_wiring_problems "$SETTINGS" | grep -c .)" \ + 'and so is a missing type' +printf '{"statusLine":{"type":"command","command":"cprof statusline --stdin"}}\n' > "$SETTINGS" +assert_eq '' "$(cp_sl_wiring_problems "$SETTINGS")" \ + 'while the required type with a cprof command stays silent' + # The configured command is data — a JSON string can hold anything — so the # report names the file and never echoes the command back. jq -n --arg c "run${ESC}[2K me" '{statusLine:{type:"command",command:$c}}' > "$SETTINGS"