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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,25 @@ 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
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
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,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-1130%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development)

</div>

Expand Down
58 changes: 58 additions & 0 deletions docs/statusline.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,61 @@ 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 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 — 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
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.

**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 <file> 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)
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.
2 changes: 1 addition & 1 deletion scripts/cprof
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
15 changes: 14 additions & 1 deletion scripts/lib/auth.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -221,6 +221,19 @@ cp_cmd_doctor() {
printf '%s\n' "$problems"
status=1
fi
# 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
[ -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:-}")"
cp_sl_wiring_problems "${sl_dir:-$HOME/.claude}/settings.json"
printf 'active profile here: %s\n' "${active:-none}"
return "$status"
}
77 changes: 77 additions & 0 deletions scripts/lib/statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -719,3 +719,80 @@ cp_cmd_statusline() {
cp_sl_assemble "$layout" "$sep"
return 0
}

# cp_sl_wiring_names_cprof <command-line>
#
# 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 <settings-file>
#
# 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
# 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.type != "command" then "malformed"
elif (.statusLine.command | type) != "string" then "malformed"
elif (.statusLine.command | index("cprof")) != null then "ours"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
}
111 changes: 111 additions & 0 deletions scripts/lib/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,114 @@ 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 <a> <b> — 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=0 n ai bi
local -a av bv
cp_ver_parseable "$a" || return 1
cp_ver_parseable "$b" || return 1
# Split rather than `cut -d. -f<n>`: 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))
done
return 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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 <path-version> <plugin-version> <path-to-cli>
#
# 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.
#
# 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')"
Comment thread
dcotelo marked this conversation as resolved.
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
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"
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
# 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"
}
3 changes: 3 additions & 0 deletions tests/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading