diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e4bdf..de99566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Statusline: staff-level overhaul.** Removed ~3,900 lines of dead code (an entire abandoned cache subsystem, superseded flat modules, rotted dev tools) and retired the background daemon — its output had no consumers and the OAuth refresh now fires directly from the render path, non-blocking. The whole tree is shellcheck-clean, usage percentages are now threshold-colored (green/yellow/red), the test suite was rebuilt against the live modules with shared assert helpers, glob discovery, and full /tmp isolation (9/9 suites), docs were consolidated into STATUSLINE.md and verified against the code, and CI gained a Linux shellcheck+test job. +- **Statusline: context moved next to the model.** Line 1 now renders the context-window percentage (`📊`) immediately after the model instead of after the git/worktree components, so token usage stays glanceable next to what's consuming it. Order is now model → context → directory → git → worktree → cost → session. ### Fixed +- **Statusline: account badge now shows without `CLAUDE_CODE_OAUTH_TOKEN`.** The `CLAUDE_STATUSLINE_ACCOUNT_LABEL` badge was gated on the OAuth-token env var also being set, so accounts switched via a separate credential store (`CLAUDE_SECURESTORAGE_CONFIG_DIR`) got no badge. The label is user-set display text and now renders whenever it's set, independent of how the account was selected. Per-account usage-cache isolation and background OAuth refresh remain keyed on `CLAUDE_CODE_OAUTH_TOKEN`. - **Plugin manifests:** `.claude-plugin/plugin.json` still carried the pre-v5 marketing description (it had also sat at version 2.0.0 from February through July while `marketplace.json` advanced — the snapshot stale Claude Desktop installs were showing). Descriptions are aligned, and `make validate-plugins` now fails on any version or description drift between the two manifests so a partial bump can't ship again. - **Statusline (post-review):** the background OAuth fetcher now holds a single-flight lock for the whole fetch, so overlapping renders can't stack concurrent calls against the rate-limited API; the tmux cache write reuses values the render already extracted instead of re-forking `cat`+`jq`; ISO timestamps with explicit UTC offsets parse to the correct epoch instead of being read as UTC; non-numeric values render as-is instead of `printf` garbage like `045%`; `hash_sha256` falls back to MD5 (then `default`) so per-account cache keys stay distinct on minimal systems; the error log is size-capped. The config surface was cut to the keys the code actually honors, `STATUSLINE_CONFIG_OVERRIDE` now really works, `configure_statusline.py` was rewritten to offer only working options (481→80 lines), and the dead `lib/tracking/` modules were removed (~550 lines). - **Statusline:** `extract_json` returned success with empty output on a grep-fallback miss, making every `||` fallback chain over it unreachable (e.g. session-id lookup never tried `session_id`/`conversation_id`); `lib/display/components.sh` didn't source its own `core/json.sh` dependency; `cache_clear` could expand `rm -rf` against `/*` if its directory variable was ever empty. diff --git a/docs/features.md b/docs/features.md index fe08f33..665a1b4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -294,7 +294,7 @@ Real-time cost and usage tracking in your Claude Code prompt — computed fresh **Example:** ``` -🤖 Opus 4.5 │ 📁 ~/projects/cc-arsenal │ 🌿 main ● │ 📊 66% │ 💰 $3.169 │ ⏱️ 21m +🤖 Opus 4.5 │ 📊 66% │ 📁 ~/projects/cc-arsenal │ 🌿 main ● │ 💰 $3.169 │ ⏱️ 21m 🔄 5h: 16% → 21:00 │ 📅 7d: 39% → Dec 31 21:00 ``` diff --git a/integrations/claude-code/statusline/README.md b/integrations/claude-code/statusline/README.md index 2bd3974..211e4e2 100644 --- a/integrations/claude-code/statusline/README.md +++ b/integrations/claude-code/statusline/README.md @@ -5,7 +5,7 @@ A bash+jq statusline for Claude Code showing model, git status, cost, context us ## Example ``` -🤖 Opus │ 📁 cc-arsenal │ 🌿 main ● │ 📊 22% │ 💰 $0.043 │ ⏱️ 21m +🤖 Opus │ 📊 22% │ 📁 cc-arsenal │ 🌿 main ● │ 💰 $0.043 │ ⏱️ 21m 🔄 5h: 16% → 21:00 │ 📅 7d: 39% → Dec 31 21:00 ``` diff --git a/integrations/claude-code/statusline/STATUSLINE.md b/integrations/claude-code/statusline/STATUSLINE.md index 06f1b95..9243137 100644 --- a/integrations/claude-code/statusline/STATUSLINE.md +++ b/integrations/claude-code/statusline/STATUSLINE.md @@ -16,9 +16,9 @@ Shows model, git, cost, context, and usage-window information in your Claude Cod The statusline renders two lines: -**Line 1** — model, directory, git, context, cost, session duration: +**Line 1** — model, context, directory, git, cost, session duration: ``` -🤖 Opus │ 📁 cc-arsenal │ 🌿 main ● │ 📊 22% │ 💰 $0.043 │ ⏱️ 21m +🤖 Opus │ 📊 22% │ 📁 cc-arsenal │ 🌿 main ● │ 💰 $0.043 │ ⏱️ 21m ``` **Line 2** — usage-window details (only when rate-limit data is available): @@ -28,10 +28,10 @@ The statusline renders two lines: Line 1 components, in order: - 🤖 **Model** — name/version, from `model.display_name` or `model.id` +- 📊 **Context** — `context_window.used_percentage`, rounded - 📁 **Directory** — current directory, `~`-shortened - 🌿 **Git** — branch, with `●` for uncommitted changes - 🌳 **Worktree** — worktree name, shown only when in a worktree -- 📊 **Context** — `context_window.used_percentage`, rounded - 💰 **Cost** — `cost.total_cost_usd` for the session - 📝 **Lines changed** — `+added/-removed`; **disabled by default**, enable via config - ⏱️ **Session duration** — from `cost.total_duration_ms`; hidden until a session has run @@ -146,13 +146,13 @@ To point the script at a different config file entirely (e.g. for previewing a c | `STATUSLINE_DEBUG` | `1` to log raw input JSON and context-window extraction to `/tmp/claude_statusline_debug.log` | | `STATUSLINE_PERF` | `1` to print `[perf] ms` to stderr after each run | | `CLAUDE_CODE_OAUTH_TOKEN` | Selects a specific account's OAuth token and enables the multi-account code path — see below | -| `CLAUDE_STATUSLINE_ACCOUNT_LABEL` | Badge text shown on line 2 when `CLAUDE_CODE_OAUTH_TOKEN` is also set; unset means no badge | +| `CLAUDE_STATUSLINE_ACCOUNT_LABEL` | Badge text shown on line 2 whenever it's set; unset means no badge. Independent of `CLAUDE_CODE_OAUTH_TOKEN` — works with any account-switch mechanism (env token, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) | | `OAUTH_USAGE_CACHE_FILE` | Overrides the OAuth usage cache path (otherwise auto-derived, see below) | | `OAUTH_USAGE_CACHE_TTL` | OAuth usage cache TTL in seconds (default `300`) | ## Multiple accounts -The statusline supports more than one Claude account on the same machine, driven entirely by `CLAUDE_CODE_OAUTH_TOKEN`. +The statusline supports more than one Claude account on the same machine. Per-account usage isolation (cache, lock, and backoff state) is keyed on `CLAUDE_CODE_OAUTH_TOKEN`; the account badge is independent of it and renders from `CLAUDE_STATUSLINE_ACCOUNT_LABEL` alone (see [Account badge](#account-badge)). ### Precedence @@ -170,11 +170,11 @@ This means two accounts running statuslines concurrently (e.g. in separate tmux ### Account badge -Set `CLAUDE_STATUSLINE_ACCOUNT_LABEL` (e.g. `work`, `personal`) alongside `CLAUDE_CODE_OAUTH_TOKEN` to show a badge on line 2: +Set `CLAUDE_STATUSLINE_ACCOUNT_LABEL` (e.g. `work`, `personal`) to show a badge on line 2: ``` 👤 work │ 🔄 5h: 16% → 21:00 ``` -Both must be set — an account without a label shows no badge (it still uses that account's isolated caches, just silently). +The label is independent of how you switched accounts — it renders whenever it's set, whether you select the account via `CLAUDE_CODE_OAUTH_TOKEN` or a separate credential store (`CLAUDE_SECURESTORAGE_CONFIG_DIR=~/.claude-alt`). Unset means no badge. Note the per-account usage-cache isolation and background OAuth refresh are still keyed on `CLAUDE_CODE_OAUTH_TOKEN`; with the securestorage path, line-2 usage comes from the `rate_limits` Claude Code sends on stdin for that account. ### Refresh behavior diff --git a/integrations/claude-code/statusline/lib/display/builder.sh b/integrations/claude-code/statusline/lib/display/builder.sh index 1d53909..28e8496 100644 --- a/integrations/claude-code/statusline/lib/display/builder.sh +++ b/integrations/claude-code/statusline/lib/display/builder.sh @@ -126,6 +126,10 @@ build_line_one() { comp=$(get_model_component "$MODEL") [[ -n "$comp" ]] && components+=("$comp") + # Context - kept right after the model so token usage stays glanceable + comp=$(get_context_component "$USED_PERCENTAGE") + [[ -n "$comp" ]] && components+=("$comp") + # Directory comp=$(get_directory_component "$current_dir") [[ -n "$comp" ]] && components+=("$comp") @@ -138,10 +142,6 @@ build_line_one() { comp=$(get_worktree_component "$NATIVE_WORKTREE_NAME") [[ -n "$comp" ]] && components+=("$comp") - # Context - comp=$(get_context_component "$USED_PERCENTAGE") - [[ -n "$comp" ]] && components+=("$comp") - # Cost comp=$(get_cost_component "$COST_USD") [[ -n "$comp" ]] && components+=("$comp") diff --git a/integrations/claude-code/statusline/lib/display/components.sh b/integrations/claude-code/statusline/lib/display/components.sh index 56df56f..60646a2 100644 --- a/integrations/claude-code/statusline/lib/display/components.sh +++ b/integrations/claude-code/statusline/lib/display/components.sh @@ -357,7 +357,10 @@ get_session_component() { # Display the active account label when multi-account mode is configured # Usage: get_account_component get_account_component() { - if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" || -z "${CLAUDE_STATUSLINE_ACCOUNT_LABEL:-}" ]]; then + # The label is user-set display text — show it whenever it's set, regardless + # of how the account was switched (CLAUDE_CODE_OAUTH_TOKEN env override, or a + # separate credential store via CLAUDE_SECURESTORAGE_CONFIG_DIR, etc.) + if [[ -z "${CLAUDE_STATUSLINE_ACCOUNT_LABEL:-}" ]]; then return 0 fi diff --git a/integrations/claude-code/statusline/tests/run_tests.sh b/integrations/claude-code/statusline/tests/run_tests.sh index 3a30904..b2ec8ec 100755 --- a/integrations/claude-code/statusline/tests/run_tests.sh +++ b/integrations/claude-code/statusline/tests/run_tests.sh @@ -152,12 +152,13 @@ run_integration_test() { echo -e "${GREEN}✅ Integration test: Statusline executed successfully${RESET}" echo "Output: $output" - # Basic validation that output contains expected elements - if [[ "$output" == *"🤖"* && "$output" == *"📁"* && "$output" == *"📊"* ]]; then - echo -e "${GREEN}✅ Integration test: Output contains expected components${RESET}" + # Validate expected components AND their order: model → context → directory + # (context is kept right after the model on line 1) + if [[ "$output" == *"🤖"*"📊"*"📁"* ]]; then + echo -e "${GREEN}✅ Integration test: Output contains expected components in order${RESET}" return 0 else - echo -e "${RED}❌ Integration test: Output missing expected components${RESET}" + echo -e "${RED}❌ Integration test: Output missing expected components or wrong order${RESET}" return 1 fi else diff --git a/integrations/claude-code/statusline/tests/test_display_components.sh b/integrations/claude-code/statusline/tests/test_display_components.sh index 45a7538..ba27cbb 100755 --- a/integrations/claude-code/statusline/tests/test_display_components.sh +++ b/integrations/claude-code/statusline/tests/test_display_components.sh @@ -319,16 +319,17 @@ test_account_component() { local result - # Gating: badge requires BOTH the env token and the label - any other - # combination must produce no output at all + # Gating: the badge is driven solely by the label - it renders whenever the + # label is set, independent of the OAuth token (so it works with any + # account-switch mechanism, e.g. CLAUDE_SECURESTORAGE_CONFIG_DIR) result=$( (unset CLAUDE_CODE_OAUTH_TOKEN CLAUDE_STATUSLINE_ACCOUNT_LABEL; get_account_component) ) assert_equals "" "$result" "no badge when neither token nor label is set" result=$( (unset CLAUDE_STATUSLINE_ACCOUNT_LABEL; export CLAUDE_CODE_OAUTH_TOKEN="tok"; get_account_component) ) assert_equals "" "$result" "no badge with token but no label" - result=$( (unset CLAUDE_CODE_OAUTH_TOKEN; export CLAUDE_STATUSLINE_ACCOUNT_LABEL="work"; get_account_component) ) - assert_equals "" "$result" "no badge with label but no token" + result=$( (unset CLAUDE_CODE_OAUTH_TOKEN; export CLAUDE_STATUSLINE_ACCOUNT_LABEL="work" STATUSLINE_DISPLAY_MODE=emoji; get_account_component) ) + assert_contains "👤 work" "$(strip_ansi "$result")" "badge renders with label but no token (securestorage switch)" # Rendering: label only, never the token or a hash result=$( (export CLAUDE_CODE_OAUTH_TOKEN="secret-tok" CLAUDE_STATUSLINE_ACCOUNT_LABEL="work" STATUSLINE_DISPLAY_MODE=emoji; get_account_component) ) diff --git a/integrations/claude-code/statusline/tests/test_multi_account.sh b/integrations/claude-code/statusline/tests/test_multi_account.sh index 6ccf8f6..5fc5aac 100644 --- a/integrations/claude-code/statusline/tests/test_multi_account.sh +++ b/integrations/claude-code/statusline/tests/test_multi_account.sh @@ -229,6 +229,14 @@ test_failsoft_render() { echo "$stdin_json" | bash "$STATUSLINE_SH" ) 2>/dev/null ) assert_contains "testacct" "$output" "account badge renders when both token and label are set" + # Label set, NO token - securestorage/alt-account switch (CLAUDE_SECURESTORAGE_CONFIG_DIR): + # the badge is user-set display text and must render without CLAUDE_CODE_OAUTH_TOKEN + output=$( ( export PATH="$MOCK_BIN_DIR:$PATH" + unset OAUTH_USAGE_CACHE_FILE CLAUDE_CODE_OAUTH_TOKEN + export CLAUDE_STATUSLINE_ACCOUNT_LABEL="altacct" + echo "$stdin_json" | bash "$STATUSLINE_SH" ) 2>/dev/null ) + assert_contains "altacct" "$output" "account badge renders with label set but no token (securestorage switch)" + # Both unset - default single-account rendering, no label output=$( ( unset OAUTH_USAGE_CACHE_FILE CLAUDE_CODE_OAUTH_TOKEN CLAUDE_STATUSLINE_ACCOUNT_LABEL echo "$stdin_json" | bash "$STATUSLINE_SH" ) 2>/dev/null ) diff --git a/uv.lock b/uv.lock index b43d0c9..7417a68 100644 --- a/uv.lock +++ b/uv.lock @@ -447,11 +447,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -469,7 +469,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.1" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -478,9 +478,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]]