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.14.0"
"version": "0.15.0"
},
"plugins": [
{
"name": "cprof",
"source": "./",
"version": "0.14.0",
"version": "0.15.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.14.0",
"version": "0.15.0",
"description": "Select which Claude account a session uses, by default profile, per-repo pin, or directory rule.",
"author": {
"name": "Diego Cotelo",
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ release workflow reads its notes from the section matching the tag.

## [Unreleased]

## [0.15.0]

### Added

- A `weekly` statusline segment: the 7-day usage window as a bar, with the time
until it resets. It renders only once the window is at or above
`statusline.weekly_threshold` — a whole number from 1 to 100, 50 by default —
and renders nothing below it, so the line costs no space early in the week and
appears before the weekly cap ends a working day. Give it a line of its own in
`statusline.lines` and that line disappears with it. The figure comes from the
cache `cprof list` fills and is never fetched, because a Claude Code payload
carries the 5-hour window and the context but never the week, and because the
statusline must not add latency — so a profile whose usage has never been
fetched shows no weekly bar.

### Changed
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- A reset a day or more away is reported as days and hours, `3d 13h`, rather
than as hours and minutes, `85h 40m` — the same instant, told legibly. The
5-hour window cannot reach a day, so what it shows is unchanged.

## [0.14.0]
### Added
- `cprof doctor` reports two install problems that used to be invisible. It
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-1130%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development)
[![Tests](https://img.shields.io/badge/Tests-1170%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development)

</div>

Expand Down
31 changes: 31 additions & 0 deletions docs/statusline.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ defaults behind the render above:
"lines": [["badge", "model", "dir", "git"], ["context", "usage"]],
"bar": {"filled": "▓", "empty": "░", "width": 10},
"thresholds": {"warn": 70, "critical": 90},
"weekly_threshold": 50,
"colors": {"model": "cyan", "dir": "yellow", "git": "magenta", "branch": "cyan", "label": "dim"}
}
}
Expand All @@ -81,6 +82,7 @@ rely on them:
| `statusline.bar.filled` / `.empty` | exactly one character, and not an invisible one — a tab is one character and is rejected, with a message of its own | `▓` / `░` |
| `statusline.bar.width` | a whole number from 1 to 40 | `10` |
| `statusline.thresholds.warn` **and** `.critical` | both, together: whole numbers from 1 to 100 with `warn` below `critical` | `70` **and** `90` — setting only one, or an out-of-order pair, reverts both |
| `statusline.weekly_threshold` | a whole number from 1 to 100 | `50` — its own setting, so a rejected `thresholds` pair does not change it, and a rejected value here does not change the pair |
| `statusline.colors.*` — wrong shape | a string, 1-19 characters, with no invisible character in it — a trailing tab is rejected, with a message of its own | its own default (`cyan` for `model`/`branch`, `yellow` for `dir`, `magenta` for `git`, `dim` for `label`) |
| `statusline.colors.*` — right shape, unknown name | any name from [the palette](#colours), plus `dim` | *(not a fallback — see below)* |

Expand Down Expand Up @@ -168,6 +170,35 @@ Usage ██···· 30% (resets in 2h 19m)
| `git` | `git:(main*)`, the star meaning uncommitted changes | two git calls |
| `context` | `Context ▓▓▓▓░░░░░░ 37%` | the payload |
| `usage` | `Usage ▓▓▓░░░░░░░ 30% (resets in 2h 19m)` | the payload, else the profile's cached usage |
| `weekly` | `Usage Weekly ▓▓▓▓▓▓░░░░ 64% (resets in 3d 13h)`, and nothing at all below the threshold | the profile's cached usage — a payload never carries the week |

### The weekly bar appears only when it matters

`weekly` is the one segment whose presence depends on data. It renders when the
7-day window is at or above `statusline.weekly_threshold` (default 50) and
stays invisible below it, so the line costs nothing on a Monday and warns you
before the weekly cap ends a working day:

```console
⚑ work │ [Opus 5 (1M context)] │ cprof git:(main*)
Context ▓▓▓▓░░░░░░ 39% │ Usage ▓▓░░░░░░░░ 18% (resets in 4h 2m)
Usage Weekly ▓▓▓▓▓▓░░░░ 64% (resets in 3d 13h)
```

Give it a line of its own and that line disappears with it — a configured line
whose segments all render nothing is dropped rather than printed empty. Put it
beside other segments and only the bar goes; the rest of the line stays.

Two consequences of where the figure comes from. It is read from the same cache
`cprof list` fills and never fetched, because a Claude Code payload carries the
5-hour window and the context but never the week, and because the statusline
must not add latency — so a profile whose usage has never been fetched shows no
weekly bar. And a reset days away is rendered as `3d 13h` rather than `85h
40m`, which is the same instant told legibly.

It is its own threshold, not `thresholds.warn`. Those two are colour
thresholds, validated as a pair; retuning them should not silently change when
a line appears.

## Colours

Expand Down
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.14.0'
CP_VERSION='0.15.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
82 changes: 66 additions & 16 deletions scripts/lib/statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ cp_sl_bar() {
cp_sl_config() {
local cfg="${1:-}" out rc=0 four=0 nl
local d_layout='[["badge","model","dir","git"],["context","usage"]]'
local d_fill='▓' d_empty='░' d_width=10 d_warn=70 d_crit=90
local d_fill='▓' d_empty='░' d_width=10 d_warn=70 d_crit=90 d_weekly=50
local d_model=cyan d_dir=yellow d_git=magenta d_branch=cyan d_label=dim
nl='
'
Expand All @@ -117,9 +117,10 @@ cp_sl_config() {
--argjson deflayout "$d_layout" \
--arg fill "$d_fill" --arg empty "$d_empty" \
--argjson width "$d_width" --argjson warn "$d_warn" --argjson crit "$d_crit" \
--argjson weekly "$d_weekly" \
--arg model "$d_model" --arg dir "$d_dir" --arg git "$d_git" \
--arg branch "$d_branch" --arg label "$d_label" '
def known: ["badge","model","dir","git","context","usage"];
def known: ["badge","model","dir","git","context","usage","weekly"];
# Nothing below character 32 in a value that is kept. These four lines are
# read by their delimiters, and an invisible character collides with them:
# a tab shifts every field after it on its row -- so the directory colour
Expand All @@ -146,10 +147,14 @@ cp_sl_config() {
| (whole($t.warn; 1; 100; 0)) as $w
| (whole($t.critical; 1; 100; 0)) as $cr
| (if $w > 0 and $cr > 0 and $w < $cr then [$w, $cr] else [$warn, $crit] end) as $th
# Its own setting, not a third member of the pair above: warn and critical
# are colour thresholds validated together, and a bad pair must not drag
# the visibility threshold for the weekly bar down with it.
| (whole($s.weekly_threshold; 1; 100; $weekly)) as $wk
| ([ $layout[] | join(" ") ] | join(";")),
([glyph($b.filled; $fill), glyph($b.empty; $empty),
(whole($b.width; 1; 40; $width) | tostring)] | join("\t")),
($th | map(tostring) | join("\t")),
(($th + [$wk]) | map(tostring) | join("\t")),
([pick($c.model; $model), pick($c.dir; $dir), pick($c.git; $git),
pick($c.branch; $branch), pick($c.label; $label)] | join("\t"))
' 2>/dev/null)" || rc=$?
Expand All @@ -161,9 +166,9 @@ cp_sl_config() {
printf '%s\n' "$out"
return 0
fi
printf '%s\n%s\t%s\t%s\n%s\t%s\n%s\t%s\t%s\t%s\t%s\n' \
printf '%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\t%s\t%s\n' \
"$(printf '%s' "$d_layout" | jq -r '[.[] | join(" ")] | join(";")')" \
"$d_fill" "$d_empty" "$d_width" "$d_warn" "$d_crit" \
"$d_fill" "$d_empty" "$d_width" "$d_warn" "$d_crit" "$d_weekly" \
"$d_model" "$d_dir" "$d_git" "$d_branch" "$d_label"
}

Expand Down Expand Up @@ -227,7 +232,7 @@ cp_sl_config() {
# back to, and one bad section can never suppress another's report.
cp_sl_config_problems() {
local cfg="${1:-}" key name default state colors_ok tab defaults esc_def
local d_fill='' d_empty='' d_width='' d_warn='' d_crit=''
local d_fill='' d_empty='' d_width='' d_warn='' d_crit='' d_weekly=''
local d_model='' d_dir='' d_git='' d_branch='' d_label=''
tab="$(printf '\t')"
# The fallbacks these messages name are read off the resolver itself,
Expand All @@ -236,7 +241,7 @@ cp_sl_config_problems() {
{
read -r _
IFS="$tab" read -r d_fill d_empty d_width
IFS="$tab" read -r d_warn d_crit
IFS="$tab" read -r d_warn d_crit d_weekly
IFS="$tab" read -r d_model d_dir d_git d_branch d_label
} <<EOF
$defaults
Expand All @@ -247,7 +252,8 @@ EOF
# than repeating the ten defaults here as a third hand-typed copy, which
# could only drift from the resolver it is meant to describe.
if [ -z "$d_fill" ] || [ -z "$d_empty" ] || [ -z "$d_width" ] \
|| [ -z "$d_warn" ] || [ -z "$d_crit" ] || [ -z "$d_model" ] \
|| [ -z "$d_warn" ] || [ -z "$d_crit" ] || [ -z "$d_weekly" ] \
|| [ -z "$d_model" ] \
|| [ -z "$d_dir" ] || [ -z "$d_git" ] || [ -z "$d_branch" ] \
|| [ -z "$d_label" ]; then
return 0
Expand Down Expand Up @@ -288,8 +294,9 @@ EOF
'
printf '%s' "$cfg" | jq -r \
--arg fill "$d_fill" --arg empty "$d_empty" --argjson width "$d_width" \
--argjson warn "$d_warn" --argjson crit "$d_crit" "$esc_def"'
def known: ["badge","model","dir","git","context","usage"];
--argjson warn "$d_warn" --argjson crit "$d_crit" \
--argjson weekly "$d_weekly" "$esc_def"'
def known: ["badge","model","dir","git","context","usage","weekly"];
# The resolver rules, as cp_sl_config states them, so that a value is
# judged by what the resolver did with it and not by a second reading.
def clean($v): ($v|type) == "string" and (($v|explode|map(select(. < 32))|length) == 0);
Expand Down Expand Up @@ -338,8 +345,8 @@ EOF
# piped in, the way the unknown-segment check binds $seg: inside the
# pipe `.` is the list, and index() given a list looks for it as a
# subsequence instead.
( $s | keys[] | select(. as $k | ["lines","bar","thresholds","colors"] | index($k) | not)
| "statusline: unknown key \(safe(.)) (known: lines bar thresholds colors)" ),
( $s | keys[] | select(. as $k | ["lines","bar","thresholds","colors","weekly_threshold"] | index($k) | not)
| "statusline: unknown key \(safe(.)) (known: lines bar thresholds colors weekly_threshold)" ),
( obj($s.bar) | keys[] | select(. as $k | ["filled","empty","width"] | index($k) | not)
| "statusline.bar: unknown key \(safe(.)) (known: filled empty width)" ),
( obj($s.thresholds) | keys[] | select(. as $k | ["warn","critical"] | index($k) | not)
Expand All @@ -366,7 +373,7 @@ EOF
( if ($s.lines|type) == "array"
then ( [ $s.lines[] | select(type == "array") | .[] | select(type == "string") ]
| map(select(. as $seg | known | index($seg) | not)) | unique | .[]
| "statusline.lines: unknown segment \(safe(.)) (known: badge model dir git context usage)" )
| "statusline.lines: unknown segment \(safe(.)) (known: badge model dir git context usage weekly)" )
else empty end ),
( (ifnull($s.bar; {})) as $b
| if ($b|type) != "object"
Expand Down Expand Up @@ -396,7 +403,13 @@ EOF
| if rejected($t.warn; $th[0]) or rejected($t.critical; $th[1])
then "statusline.thresholds: warn must be a whole number below critical, both from 1 to 100; using \($warn) and \($crit)"
else empty end
end )
end ),
# Its own line, because it is its own setting: a rejected pair above
# says nothing about this, and a rejected value here says nothing
# about the pair.
( if rejected($s.weekly_threshold; whole($s.weekly_threshold; 1; 100; $weekly))
then "statusline.weekly_threshold: must be a whole number from 1 to 100; using \($weekly)"
else empty end )
end
' 2>/dev/null
# Colours last, and in bash: cp_sl_code decides what a usable name is, and
Expand Down Expand Up @@ -542,7 +555,8 @@ cp_sl_assemble() {
# resolved configuration says.
#
# Each segment renders into its own shell variable -- CP_SL_badge,
# CP_SL_model, CP_SL_dir, CP_SL_git, CP_SL_context, CP_SL_usage -- holding
# CP_SL_model, CP_SL_dir, CP_SL_git, CP_SL_context, CP_SL_usage,
# CP_SL_weekly -- holding
# only that segment's own text, with no separator. cp_sl_assemble then walks
# the configured layout and joins what is there. A segment the layout does
# not name is never rendered at all, so an unconfigured git segment runs no
Expand All @@ -564,7 +578,8 @@ cp_cmd_statusline() {
# layout's own segment names, which is why nothing in this function appears
# to use them.
local CP_SL_badge='' CP_SL_model='' CP_SL_dir='' CP_SL_git=''
local CP_SL_context='' CP_SL_usage=''
local CP_SL_context='' CP_SL_usage='' CP_SL_weekly=''
local th_weekly='' w_data='' w_pct='' w_at='' w_reset='' w_bar='' w_code=''

while [ "$#" -gt 0 ]; do
case "$1" in
Expand All @@ -588,6 +603,7 @@ cp_cmd_statusline() {
thresh_cfg="$(printf '%s' "$config" | sed -n '3p')"
th_warn="$(printf '%s' "$thresh_cfg" | cut -f1)"
th_crit="$(printf '%s' "$thresh_cfg" | cut -f2)"
th_weekly="$(printf '%s' "$thresh_cfg" | cut -f3)"
colors_cfg="$(printf '%s' "$config" | sed -n '4p')"
col_model="$(cp_sl_code "$(printf '%s' "$colors_cfg" | cut -f1)" 2>/dev/null)"
col_dir="$(cp_sl_code "$(printf '%s' "$colors_cfg" | cut -f2)" 2>/dev/null)"
Expand Down Expand Up @@ -716,6 +732,40 @@ cp_cmd_statusline() {
fi
fi

# The 7-day window, shown only once it is worth watching. Cache-only by
# nature: a Claude Code payload carries the 5-hour window and the context,
# never the week, so this reads the cache `cprof list` fills and never
# fetches -- the statusline must not add latency. Below the threshold the
# variable stays empty and cp_sl_assemble drops the line, which is how a
# quiet week costs no screen space.
if cp_sl_wants "$layout" weekly; then
w_data="$(cp_usage_read_cached_only "$name" 2>/dev/null)"
w_pct="$(cp_usage_pct "$w_data" seven_day 2>/dev/null)"
case "$w_pct" in ''|*[!0-9]*) w_pct='' ;; esac
case "$th_weekly" in ''|*[!0-9]*) th_weekly=50 ;; esac
if [ -n "$w_pct" ] && [ "$w_pct" -ge "$th_weekly" ]; then
w_at="$(cp_usage_resets_at "$w_data" seven_day 2>/dev/null)"
case "$w_at" in
'') ;;
*[!0-9]*) w_at="$(cp_time_epoch "$w_at")" || w_at='' ;;
esac
if [ -n "$w_at" ]; then
w_reset="$(cp_usage_reset_in "$w_at")" || w_reset=''
fi
w_bar="$(cp_usage_bar "$w_pct" "$b_fill" "$b_empty" "$b_width")"
w_code="$(cp_color_code "$(cp_usage_severity_colour "$w_pct" "$th_warn" "$th_crit" 2>/dev/null)" 2>/dev/null)"
if [ "$colour_on" -eq 1 ]; then
CP_SL_weekly="$(printf '%sUsage Weekly%s %s \033[%sm%s%%\033[0m' \
"$label_open" "$label_close" "$(cp_sl_bar "$w_bar" "$w_code" "$b_empty")" "$w_code" "$w_pct")"
[ -n "$w_reset" ] && CP_SL_weekly="$CP_SL_weekly$(printf ' %s(resets in %s)%s' "$label_open" "$w_reset" "$label_close")"
else
# shellcheck disable=SC2034 # read by cp_sl_assemble via eval
CP_SL_weekly="Usage Weekly $w_bar $w_pct%"
[ -n "$w_reset" ] && CP_SL_weekly="$CP_SL_weekly (resets in $w_reset)"
fi
fi
fi

cp_sl_assemble "$layout" "$sep"
return 0
}
Expand Down
16 changes: 11 additions & 5 deletions scripts/lib/usage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -279,17 +279,23 @@ cp_usage_detail() {
return 0
}

# cp_usage_reset_in <reset-epoch> [<now-epoch>] -> "4h 37m", "37m" or "<1m";
# nothing, return 1, when the reset is not a future epoch.
# cp_usage_reset_in <reset-epoch> [<now-epoch>] -> "3d 13h", "4h 37m", "37m" or
# "<1m"; nothing, return 1, when the reset is not a future epoch.
#
# The coarsest useful pair of units, never three: a 7-day window resets days
# out, where hours alone stop being readable -- 85h 40m and 3d 13h are the same
# instant and only one of them is legible. Minutes are dropped once a day is
# involved for the same reason.
cp_usage_reset_in() {
local at="${1:-}" now="${2:-}" left h m
local at="${1:-}" now="${2:-}" left d h m
case "$at" in ''|*[!0-9]*) return 1 ;; esac
[ -n "$now" ] || now="$(date +%s)"
case "$now" in ''|*[!0-9]*) return 1 ;; esac
left=$(( at - now ))
[ "$left" -gt 0 ] || return 1
h=$(( left / 3600 )); m=$(( (left % 3600) / 60 ))
if [ "$h" -gt 0 ]; then printf '%sh %sm\n' "$h" "$m"
d=$(( left / 86400 )); h=$(( left / 3600 )); m=$(( (left % 3600) / 60 ))
if [ "$d" -gt 0 ]; then printf '%sd %sh\n' "$d" "$(( (left % 86400) / 3600 ))"
elif [ "$h" -gt 0 ]; then printf '%sh %sm\n' "$h" "$m"
elif [ "$m" -gt 0 ]; then printf '%sm\n' "$m"
else printf '<1m\n'
fi
Expand Down
Loading
Loading