This handbook is the implementation source of truth for the current repository state.
It reflects code behavior in src/ as of 2026-03-02.
Use this precedence order when documents disagree:
src/code- this handbook (
docs/implementation-handbook.md) - focused doc (
docs/current-implementation.md) - legacy/spec extracts (
docs/spec-*.md)
This project currently implements a unified single-cycle execution model (not a daemon poll loop in main.ts).
Core pipeline:
env/config -> provider resolve -> fetch/normalize -> cache -> render -> stdout
Main modules:
src/main.ts: CLI args, mode detection, env/config loading, provider resolution, cache read/write side effectssrc/core/execute-cycle.ts: pure decision engine (Path A/B/C/D)src/providers/*: adapter fetch + normalize per backendsrc/services/config.ts: config load/merge/validate/savesrc/services/env.ts: env +settings.jsonoverlaysrc/services/cache.ts: cache IO, cache validity checks, config hash, provider detection cache metadatasrc/renderer/*: component rendering, colors, truncation, error statessrc/types/*: canonical data/config/cache types
Mode is determined in main.ts:
piped mode:!process.stdin.isTTY- primary mode for ccstatusline custom command usage
- stdin payload is accepted and discarded
tty once mode:--once- one execution cycle, then exit
tty interactive placeholder: TTY without--once- currently prints a placeholder message and exits
--help,-h--version,-v(reads dynamically frompackage.json)--once--config <path>--install(register as Claude Code statusline widget)--uninstall(remove statusline widget registration)--runner <npx|bunx>(specify package runner for install, default: auto-detect)--force(force overwrite existing statusline configuration)--embedded(skip host formatting, for use inside cc-statusline; also set viaCC_API_STATUSLINE_EMBEDDEDenv var)
Required:
ANTHROPIC_BASE_URLANTHROPIC_AUTH_TOKEN
Optional:
CC_STATUSLINE_PROVIDERCC_STATUSLINE_POLL(seconds, min 5, default 30)CC_STATUSLINE_TIMEOUT(piped total timeout budget ms, default 5000)CC_API_STATUSLINE_EMBEDDED(accepts'1'or'true', skip host formatting in embedded piped mode)CC_API_STATUSLINE_CACHE_DIR(cache dir override)CC_API_STATUSLINE_LOG_DIR(debug log dir override)CLAUDE_CONFIG_DIR(forsettings.jsonoverlay path)DEBUGorCC_STATUSLINE_DEBUG(enable debug logging to~/.claude/cc-api-statusline/debug.log)
Optional User-Agent spoofing for API providers that restrict access to Claude Code clients.
Config field: spoofClaudeCodeUA?: boolean | string
Behavior:
false/undefined: No User-Agent header sent (default)true: Auto-detect Claude Code version from~/.claude/bin/claude --version, fallback toclaude-cli/2.1.56 (external, cli)"string": Use exact User-Agent string provided
Per-provider override (custom providers only):
CustomProviderConfig.spoofClaudeCodeUAoverrides global setting
Detection logic:
- Check
CLAUDECODE=1env var (only detect when running under Claude Code) - Execute
~/.claude/bin/claude --versionwith 1s timeout - Parse version from output (regex:
/(\d+\.\d+\.\d+)/) - Fallback to hardcoded version if detection fails
Implementation: src/services/user-agent.ts
src/services/env.ts behavior:
- reads
CLAUDE_CONFIG_DIR/settings.jsonif set, else~/.claude/settings.json - uses
settings.envvalues when present - precedence is
settings.env > process.env
- config dir:
~/.claude/cc-api-statusline - config file:
~/.claude/cc-api-statusline/config.json - cache dir default:
~/.claude/cc-api-statusline - cache file per base URL:
cache-<shortHash(baseUrl)>.json
display.layout:standarddisplay.displayMode:textdisplay.progressStyle:icondisplay.barSize:mediumdisplay.barStyle:blockdisplay.divider:{ text: '|', margin: 1, color: '#555753' }display.maxWidth:100(percentage of terminal width)display.clockFormat:24hpollIntervalSeconds:30pipedRequestTimeoutMs:3000
Default component visibility:
- enabled:
daily,weekly,monthly,balance - disabled:
tokens,rateLimit,plan
loadConfig() clamps:
display.maxWidthto20..100pollIntervalSecondsto>=5pipedRequestTimeoutMsto>=100
saveConfig() writes to <path>.tmp then rename().
All adapters return this shape:
- metadata (non-null):
providerbillingMode(subscriptionorbalance)planNamefetchedAt(ISO)resetSemantics
- nullable data fields:
daily,weekly,monthly(QuotaWindow | null)balance(BalanceInfo | null)resetsAt(soonest reset)tokenStatsrateLimit
Renderer behavior depends on null-tolerant semantics: missing data hides components, never crashes rendering.
Cache entry fields:
version(CACHE_VERSION)providerbaseUrltokenHashconfigHashdata(NormalizedUsage)renderedLinefetchedAtttlSecondserrorState
Validity checks (services/cache.ts):
- TTL not expired
- baseUrl match
- version match
- tokenHash match
- provider match (checked separately)
- request:
GET {baseUrl}/v1/usage - auth:
Authorization: Bearer {token} - billing mode detection:
- has
subscriptionobject ->subscription - otherwise ->
balance
- has
Mapping highlights:
- subscription windows:
daily_usage_usd/daily_limit_usdweekly_usage_usd/weekly_limit_usdmonthly_usage_usd/monthly_limit_usd
- window reset computation:
- daily: next midnight UTC
- weekly: next Monday 00:00 UTC
- monthly: first of next month 00:00 UTC
- balance mode:
remainingmapped tobalance.remainingremaining === -1treated as unlimited
- token stats:
- snake_case to camelCase mapping for today/total/rpm/tpm
- edge handling:
429returns minimal "quota exhausted" normalized object
- request:
POST {baseUrl}/apiStats/api/user-stats - auth: JSON body
{ "apiKey": token } - expects response wrapper
success: true
Mapping highlights:
- always
billingMode: subscription - daily quota:
currentDailyCost/dailyCostLimit
- weekly quota:
weeklyOpusCost/weeklyOpusCostLimit- reset computed from
weeklyResetDay+weeklyResetHour
- monthly quota: not provided (
null) - rate limit window:
rateLimitWindowminutes converted towindowSeconds- limit values
<=0normalized tonull(unlimited)
- token stats:
- total only (
today: null)
- total only (
- config-driven provider definitions
- supported auth modes:
- header auth
- body auth
- response mapping via lightweight JSONPath resolver:
- supports dot notation and numeric indexes
- does not support wildcards/filters/recursive descent
- mapping normalizes into
NormalizedUsage - applies
0 -> nulllimit normalization for daily/weekly/monthly in custom mapping path
Resolution order:
CC_STATUSLINE_PROVIDERoverride- in-memory baseUrl cache hit
- disk cache (24h TTL with dynamic TTL adjustment via
DETECTION_TTL_*constants) - health probe (
healthMatch) — most-specific match wins - built-in relay heuristics (
/apistats,relay,/api/user-stats) - default fallback:
sub2api
Cache metadata (src/services/cache.ts): The readDetectionCacheMeta(baseUrl): DetectionCacheMeta function reads both cache age and TTL in a single file operation, returning {ageMs: number | null, ttlMs: number}. This replaces the separate age/TTL read operations from prior versions.
src/providers/http.ts provides secureFetch() with hard guards:
- only
https://allowed, except loopbackhttp://localhost|127.0.0.1|::1 - redirect policy:
redirect: 'manual' - cross-domain redirect blocking
- response body cap: 1MB streaming read
- timeout:
AbortSignal.timeout(timeoutMs)
Typed errors:
HttpErrorTimeoutErrorRedirectErrorResponseTooLargeError
src/core/execute-cycle.ts is the decision engine.
Conditions:
- cache valid
- provider matches
configHashmatches
Action:
- return cached
renderedLine - no fetch
Conditions:
- cache valid + provider match
- rendered hash mismatch
Action:
- render from cached
data - return
cacheUpdatewith newrenderedLineandconfigHash
Conditions:
- no valid cache for A/B
- time budget sufficient
Action:
- fetch via provider adapter
- render
- create new cache entry
TTL for new cache entry is derived from:
getEffectivePollInterval(config, env.pollIntervalOverride)
Triggers:
- insufficient remaining budget
- fetch failure
Behavior:
- if stale cached line exists: render stale/error-indicated output path
- otherwise:
[loading...]or standalone error output
In main.ts:
- piped timeout budget:
CC_STATUSLINE_TIMEOUTor5000 - tty once budget:
10000 - fetch timeout:
- piped:
min(config.pipedRequestTimeoutMs ?? 3000, timeoutBudgetMs - 100) - tty once:
10000
- piped:
In execute-cycle.ts:
- execution deadline uses a 50ms tail buffer
- if remaining budget <= 50ms, skip fetch and fallback immediately
Piped mode installs a watchdog setTimeout at rawTimeoutMs - TIMEOUT_HEADROOM_MS (where TIMEOUT_HEADROOM_MS = 100ms). If it fires (process is about to be killed by Claude Code), it writes ⟳ Refreshing... to stdout and calls process.exit(0). This prevents the [Signal: SIGKILL] error indicator from appearing in the statusline when the host budget expires before the execution cycle completes.
Supported components:
daily,weekly,monthlybalancetokensrateLimitplan
Component order:
- key order in
config.componentsfirst - omitted components appended in default order:
daily -> weekly -> monthly -> balance -> tokens -> rateLimit -> plan
Layouts:
standardpercent-first
Display modes (displayMode — label style):
textcompactemojinerdhidden
Progress styles (progressStyle — usage fraction visualization):
bariconhidden
- ANSI named colors (normal + bright)
- hex colors (
#rgb,#rrggbb) - alias-based dynamic colors (
auto,chill, or user-defined) - per-part color overrides (
label,bar,value,countdown)
For non-percentage components, alias resolution uses the alias low color.
- supported formats:
auto,duration,time - configurable divider/prefix
- invalid or missing reset timestamps produce empty countdown text
Error states include:
network-error,auth-error,rate-limited,server-error,parse-error,provider-unknown,missing-env- transition states:
switching-provider,new-credentials,new-endpoint,auth-error-waiting
Rules:
- transition states replace full output
- with cached output, non-transition errors append indicators
- without cache, non-transition errors replace output
- terminal width from
process.stdout.columnswith fallback80 display.maxWidthinterpreted as percentage of terminal width- truncation is ANSI-aware and appends
…
ccstatusline invokes custom commands with Node execSync and:
input: JSON.stringify(context.data)— stdin payload is JSON text; must be accepted without blockingtimeout: item.timeout ?? 1000— enforced by host processstdio: ['pipe', 'pipe', 'ignore']— stderr is ignored by hostenv: process.env— host forwards environment as-is; no dedicated timeout env variable injected
- host applies
.trim()to stdout content - if
preserveColorsis false, host strips SGR codes:output.replace(/\x1b\[[0-9;]*m/g, '') - optional
maxWidthtruncation applied by host after command completion - important: when
preserveColors: trueandmaxWidthis set, host truncation checks byte length including ANSI escape sequences — ANSI codes inflate byte length and can cause premature truncation; perform ANSI-aware truncation internally to stay within the visible limit
Host maps failures to fixed tokens:
ENOENT→[Cmd not found]ETIMEDOUT→[Timeout]EACCES→[Permission denied]- process signal →
[Signal: <name>] - non-zero exit status →
[Exit: N] - fallback →
[Error]
- piped mode must return within 5000ms default host timeout
- command must accept stdin JSON but does not need to use it for provider data
- color output must degrade cleanly when
preserveColorsis false - do not assume host sets
CC_STATUSLINE_TIMEOUT; use safe default budget unless explicitly overridden - use non-zero exits only for actionable error states; prefer fast fallback output to reduce timeout risk
- Host default timeout: 5000ms
- Planning target: return output within ≤4900ms
- Safety margin: ≥50ms tail buffer in
execute-cycle.ts
Piped-mode path targets:
| Path | Condition | Target | p95 |
|---|---|---|---|
| A | warm cache, rendered line valid | ≤25ms | ≤100ms |
| B | warm cache, stale render hash | ≤55ms | ≤100ms |
| C | cold/stale cache, network fetch | ≤840ms worst case | — |
| D | fallback (budget exhausted or fetch failed) | ≤25ms | — |
- Never start network fetch when remaining budget < request timeout window
- Prefer stale cached output over timeout
- Avoid full config parse/validation in fast path when rendered cache is usable
- Use per-baseUrl cache files to avoid cross-terminal cache contention
Run each scenario ≥10 times, record p50/p95:
- piped mode, warm rendered cache
- piped mode, warm data cache with forced re-render
- piped mode, cold cache + unavailable network
- standalone single fetch (
--once) with valid network
Record: wall clock duration, path taken (A/B/C/D), whether deadline was met.
- p95 of warm rendered-cache path ≤100ms
- no timeout in piped-mode tests under default 5000ms host budget
- fallback path returns deterministic output under offline/error scenarios
bun run checkbun run testbun run lintbun run build
Use local debug credentials file:
/Users/liafo/Development/GitWorkspace/cc-api-statusline/.agent/debug.env
Example local run:
set -a
source .agent/debug.env
set +a
bun run build
node ./dist/cc-api-statusline.js --oncePowerShell:
Get-Content .agent/debug.env | ForEach-Object {
if ($_ -match '^(.*?)=(.*)$') {
[System.Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')
}
}
bun run build
node .\dist\cc-api-statusline.js --onceGuidelines:
- never commit
.agent/debug.env - never log raw tokens
- use temporary env scope for manual tests
src/services/log-rotator.ts — called by Logger constructor on debug-mode startup (probabilistic: 1-in-20 invocations).
Rotation conditions for debug.log:
- Size ≥ 500 KB, age < 24h → rename to
debug.YYYY-MM-DDTHH-MM.log(plain archive) - Age ≥ 24h → rename + gzip via detached child (
gzip -f), non-blocking
Cleanup pass (runs after rotation check):
- Plain
.logarchives older than 24h → trigger gzip .log.gzarchives older than 3 days → delete
All rotation operations are silent-failure (never crash the statusline).
Constants (from src/core/constants.ts): LOG_ROTATION_PROBABILITY, LOG_MAX_SIZE_BYTES, LOG_MAX_AGE_MS, LOG_RETENTION_MS.
- tests build
distbefore execution - E2E tests validate fast-path and re-render cache behavior
- perf tests enforce p95 thresholds for CLI invocation path
- Interactive TTY mode is placeholder-only (future: TUI configuration interface)
- Cache schema includes
errorState, but fetch-created cache entries inexecuteCyclecurrently do not explicitly populate it
Before merging behavior changes:
- preserve
NormalizedUsageas the only renderer/cache input contract - preserve Path A/B/C/D semantics unless intentionally redesigned
- keep poll default at 30s unless explicitly changed in code and docs together
- verify host-timeout-safe behavior under piped execution
- run
bun run check - update this handbook and
docs/current-implementation.mdin same change