ccline-cli
v0.2.0
Published
A fast, fail-soft statusline toolkit for Claude Code.
Maintainers
Readme
Quickstart
npx ccline-cli installThat merges one key into .claude/settings.json, and leaves every other key in
the file exactly as it was:
{"statusLine": {"type": "command", "command": "npx ccline-cli statusline"}}Start a new Claude Code session and the line is there:
ctx 78% left · 811.3k tok · Opus 5 · main · $2.14 · 18mContext left, not context used, because a budget you spend down is a number you can act on mid-task. Then real tokens, the model, your branch, the session cost, and how long you have been at it.
Three commands worth knowing next:
ccline config # pick segments, order and theme, with a live preview
ccline preview # render your line with sample data
ccline doctor # explain why the line is not what you expected
ccline pets # the optional animals, and what each pose looks likeNothing is taken over behind your back. If another tool already owns your
statusline, install stops and tells you rather than replacing it, and
ccline uninstall removes the key only if the command in it is still ccline's.
The line
When the window is nearly gone, the line says so:
NO_COLOR is honoured:
And you can cut it down to whatever you actually read:
Every image above was produced by running the renderer. They are captured
from real output by npm run assets, and a test fails if any of them stops
matching what the tool prints. See
screenshots are not mockups.
Two things this project takes seriously
The render path never fails loudly. ccline statusline never exits
non-zero and never writes to stderr, because a broken statusline must not touch
your session. Every segment is independently fail-soft: one that throws, or that
overruns the shared budget, drops itself and the rest of the line still renders.
Slow is the same as broken. A statusline that misses its deadline renders empty and looks perfectly healthy while telling you nothing. So the budget is asserted by a test, and that test also asserts the line was not empty, because otherwise the timing assertion would pass on a line that rendered nothing.
Both rules, and what they cost, are in docs/ARCHITECTURE.md.
The four surfaces
1. Statusline
Every segment is optional and reorderable. The table is everything that ships, and the last row is registered but off until you ask for it.
| Segment | Renders | Notes |
|---|---|---|
| context | ctx 37% left | Remaining, not used. Yellow at 40 left, red at 15 |
| tokens | 811.3k tok | Input + output. Cache reads excluded by default |
| model | Opus 5 | From the payload. Truncated past 32 characters |
| branch | main | Read from .git/HEAD, no git process spawned |
| cost | $2.14 | From the payload. Gray until $5, then yellow |
| duration | 18m | Since the session's first message |
| update | ↑ 0.2.0 | Only when a newer version is known. Nothing when current, and no network from the render path |
| pet | █ | Off by default. One cell of animal, reacting to the session. Six ship, ccline pets lists them |
$ ccline segments
Segments, in render order.
context on 1 Context window remaining, e.g. ctx 37% left
options.context.warnBelow = 40 Turn yellow below this much remaining
options.context.dangerBelow = 15 Turn red below this much remaining
tokens on 2 Input plus output tokens this session, cache reads excluded
options.tokens.includeCache = false Add cache read and creation tokens to the total
model on 3 The model display name
options.model.maxLength = 32 Truncate longer names
branch on 4 Git branch, read from .git/HEAD without spawning git
options.branch.maxLength = 28 Truncate longer branch names
cost on 5 Session cost in USD
options.cost.noticeAbove = 5 Turn yellow at or above this many dollars
duration on 6 Time since the session's first message
update on 7 A newer published version, when one is known. Never touches the network
options.update.staleAfterHours = 168 Stop showing a check older than this
pet off A pet in one terminal cell, reacting to the session. Off unless you enable it
options.pet.name = "bubba" Which pet to draw
options.pet.cells = 1 Terminal cells to occupy, 1 or 2
options.pet.alertBelow = 15 Go alert below this much context remaining
options.pet.appearWithinMs = 30000 Show the arriving pose for this long into a session
options.pet.longTurnMs = 45000 Flick the tail after this long with no new message
options.pet.sleepAfterMs = 300000 Fall asleep after this long with no new message
Order comes from "segments" in your config, not from the list above.
Add your own: createRegistry() from "ccline-cli/segments" takes { name, summary, render }.
Config read from: defaults onlyIt also flags a configured name that nothing implements, which is the one mistake the render path can never report:
brnach unknown no implementation registered, renders nothingOn the render path that typo is invisible by construction: it looks exactly like a segment with nothing to say.
Two numbers worth understanding. Tokens are deduplicated by message.id,
because Claude Code writes one assistant message as several transcript lines
that each repeat the same cumulative usage. Summing them naively inflates the
total by about 1.9x (measured on a real 2232-line transcript: 1,056,805,602
against a true 534,220,602). And cache reads are excluded from the headline
figure, because on a real session input plus output was 811,270 against
348,167,965 cache reads. A number dominated 429 to 1 by cache traffic is not a
number about your work. Turn it back on with options.tokens.includeCache.
Pets
Six animals ship, each living in one terminal cell and changing pose with the session. They are off by default and stay off until you name one:
{
"segments": ["pet", "context", "tokens", "model", "branch", "cost", "duration"],
"options": { "pet": { "name": "bubba" } }
}The pose is the point. It is a pure function of the payload and the transcript
stats, so the same session state always draws the same picture and the line
never flickers, and it rides the single memoised transcript read that tokens
and duration already make rather than adding a second one. Five states, and
each of these lines is a real render of the session beside it:
| Pose | State | When | Colour |
|---|---|---|---|
| arriving | appear | the first 30 seconds of a session | green |
| alert | alert | context at or below 15% remaining | yellow |
| tail flick | tailflick | 45 seconds into a turn with no new message | cyan |
| asleep | sleep | five minutes with nothing happening | dim |
| sitting | idle | everything else | gray |
Alert outranks every other pose, because it is the only one carrying
information you have to act on, and it fires at the same threshold that turns
the context number red. The rest is decoration, and decoration must never
outrank a signal.
$ ccline pets
Pets, one terminal cell each.
bubba The reference cat, sitting in one terminal cell
appear ▐ alert ▀ tailflick ▙ sleep ▄ idle █
crab A crab, claws up and shell low, scuttling sideways
appear ▐ alert ▀ tailflick ▌ sleep ▄ idle █
dev The human at the other end, in a long coat
appear ▐ alert ▀ tailflick ▌ sleep ▄ idle █
duck A duck, flat bill and low body, waddling in one terminal cell
appear ▐ alert █ tailflick ▙ sleep ▄ idle ▟
fox A fox, mostly tail, facing left
appear ▐ alert ▀ tailflick ▟ sleep ▄ idle █
ghost A small sheeted spook, bobbing where the cat would sit
appear ▐ alert ▀ tailflick ▜ sleep ▄ idle █
The pet segment is OFF by default. Turn it on by naming it in "segments":
{"segments": ["context", "pet", "model"], "options": {"pet": {"name": "bubba"}}}
"ccline pets <name>" draws every pose 1:1. Adding one: docs/PETS.md.ccline pets <name> draws every pose at one pixel per cell, next to the glyph
it reduces to. A terminal that cannot render block drawing characters gets no
pet at all, rather than an ASCII face: at one cell wide the alternatives are a
period or a hash, and a stray period in a statusline reads as a bug, not as a
cat.
Drawing one is the easiest first contribution here, and docs/PETS.md is the whole procedure: the frame contract, why one cell holds only sixteen distinguishable pictures, and a complete worked example you can follow without reading any source.
2. Hooks
Four hooks, each installed on the events it needs.
$ ccline hooks list
ccline hooks (project: /your/project/.claude/settings.json)
notify not installed
Desktop notification when Claude finishes or needs permission.
session-log not installed
Append one JSONL record per session to ~/.ccline/sessions.jsonl.
auto-format not installed
Run the project's own formatter on files Claude edits.
guard not installed
Block edits to paths you marked protected.$ ccline hooks add guard
installed guard in /your/project/.claude/settings.json
runs on: PreToolUse| Hook | Event | What it does |
|---|---|---|
| notify | Stop, Notification | osascript / notify-send / PowerShell, and nothing at all when none is available. Re-entrant stops are ignored, so a session that keeps going does not spam you |
| session-log | SessionEnd | One JSONL row per session: project, model, branch, duration, and four separate token counters |
| auto-format | PostToolUse | Runs prettier, biome or dprint only if the project already depends on it and the binary is in node_modules. Formats the edited file, never the repo, and never installs anything |
| guard | PreToolUse | Denies edits to protected paths, with a reason |
guard is the one with teeth. It answers with Claude Code's decision object and
exit code 0, so "blocked" and "crashed" are never the same observable event:
$ echo '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/p/.env"},"cwd":"/p"}' | ccline hook guard
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"ccline guard: .env is protected by the pattern \".env\". Edit it yourself, or remove the pattern from options.guard.protect in your ccline config."}}An allowed path prints nothing and exits 0. So does malformed input. The
defaults cover .env, .env.*, *.pem, *.key, id_rsa, id_ed25519,
.git/** and .ssh/**, with .env.example and its siblings carved back out
because those exist to be committed and edited. Add your own with
options.guard.protect, or drop the defaults with
options.guard.useDefaults: false.
Scopes work the same everywhere in ccline:
ccline hooks add notify --project # .claude/settings.json, shared with the repo (default)
ccline hooks add notify --local # .claude/settings.local.json, yours, gitignored
ccline hooks add notify --user # ~/.claude/settings.json, every projectsession-log installs on SessionEnd, not Stop. Stop fires once per turn
and would write dozens of rows per session. The cost is that a session killed by
closing the terminal never fires SessionEnd and gets no row.
3. Slash commands
Four prompts, installed as markdown into .claude/commands.
$ ccline commands add all
Project commands /your/project/.claude/commands
created review.md
created ship.md
created catchup.md
created explain.md| Command | What it asks for |
|---|---|
| /review | Defects, not taste. Names what to skip, and demands a concrete failing case or no finding |
| /ship | Pre-flight: tests, types, tree, branch. Reports, never pushes. Never passes a check it did not run |
| /catchup | What changed since a ref, and why. Says "reason not recorded" instead of inventing one |
| /explain | A file or symbol, for someone seeing it for the first time |
Run it twice and nothing is written:
$ ccline commands add all
Project commands /your/project/.claude/commands
unchanged review.md
unchanged ship.md
unchanged catchup.md
unchanged explain.mdOwnership lives in the file, as a <!-- ccline:managed ... --> marker, not in a
manifest that goes stale the moment you delete a file by hand. A file ccline did
not write is never overwritten (refused-foreign), and a file it wrote that you
then edited is not either (refused-modified). Both exit 1, say so on stderr,
and leave the bytes identical. --force is the only way past.
Every shipped command grants read-only git in allowed-tools, and a test fails
any bare Bash grant. A shipped prompt that can run anything is a blank cheque
on your machine.
4. Output styles
$ ccline styles list
Output styles (project scope)
directory: /your/project/.claude/output-styles
settings: /your/project/.claude/settings.json
concise available Short answers with no preamble. Code first, prose only where it changes a decision.
teaching available Explains the why, names the tradeoff, and assumes a reader who wants to learn.
review available Adversarial reviewer. Looks for what is wrong before what is right.
Active style: none, Claude Code is using its default.
Switch with "ccline styles use <name>".$ ccline styles use review
Installed review to /your/project/.claude/output-styles/review.md
Active output style is now "review" (/your/project/.claude/settings.json)ccline styles off clears it, ccline styles remove <name> deletes the file,
and removing a style clears the active setting only when it was that style.
Full list: ccline styles help.
Configuration
Three layers, each overriding the one before:
- built-in defaults
~/.ccline/config.json, yours, every project- the nearest
.ccline.json, searched upward from the session's directory
{
"segments": ["context", "tokens", "model", "branch", "cost", "duration", "update"],
"separator": " · ",
"color": true,
"theme": "default",
"budgetMs": 300,
"update": { "check": true, "everyHours": 24 },
"options": {
"context": { "warnBelow": 40, "dangerBelow": 15 },
"tokens": { "includeCache": false },
"model": { "maxLength": 32 },
"branch": { "maxLength": 28 },
"cost": { "noticeAbove": 5 },
"update": { "staleAfterHours": 168 },
"guard": { "protect": ["infra/**"], "useDefaults": true }
}
}| Field | Type | Default | Meaning |
|---|---|---|---|
| segments | string[] | the seven on by default above | Which segments render, and in what order. Duplicates and unknown names are dropped |
| separator | string | " · " | Joined between rendered segments |
| color | boolean | true | NO_COLOR still wins over a true here |
| theme | string | "default" | default, bright, ocean, sunset, plain. plain means no colour |
| budgetMs | number | 300 | Wall-clock budget for the whole render, shared by all segments. Clamped to 5000 |
| update | object | { "check": true, "everyHours": 24 } | Whether a command may ask npm for a newer version, and how often. Never the statusline. See the update check |
| options | object | {} | Per-segment option bags, keyed by segment name |
Every field validates and repairs. A bad value falls back to the default, a bad file falls back to the layer below it. Nothing in the config path throws, because a statusline that dies over a trailing comma is a statusline that ate your session.
A project .ccline.json is opt-in, and it can only change how the line looks.
ccline will not load a plugin module named in a config file: that file
arrives with a git clone, so honouring a module path in it would mean cloning
a repo executes its code on your next prompt render.
For the same reason, a project .ccline.json may turn the update check off
but never back on. A repository you cloned does not get to make a network
request happen on a machine whose owner switched that off.
The update check
The update segment shows ↑ 0.2.0 when a newer ccline has been published.
Getting that answer needs one HTTP request, and the render path never makes
one. The two halves are completely separate, and the seam between them is a
file:
a command you typed ~/.ccline/cache.json every prompt render
ccline install ─┐ │
ccline doctor ─┼─► check ─► GET dist-tags ─► {checkedAt, ┌────┘
ccline hooks ─┘ (≤1/day) latest, │
current} ──────► update segment
(reads, never asks)Exactly when it runs. Only from one of these commands, and only if the last
check was more than update.everyHours (24 by default) ago:
install uninstall config commands hooks preview segments pets styles doctorTwo commands are deliberately absent and never will be: statusline, which
runs on every prompt render, and hook, which runs on every tool use and is
invoked by Claude Code rather than by a person. version, help and the bare
ccline banner are out too, so a script that shells out for a version number
never starts anything.
Exactly what is sent.
GET https://registry.npmjs.org/-/package/ccline-cli/dist-tags
accept: application/json
user-agent: ccline-cli/0.1.0That is the entire request. No body, no cookies, no other headers. No session
id, no machine id, no paths, no code, no usage, no telemetry of any kind. The
reply today is 18 bytes, {"latest":"0.1.0"}, counted as it arrives and
abandoned past 64KB or 3 seconds, whichever comes first.
The registry learns an IP address and that something asked about this package,
which is exactly what npm view ccline-cli tells it. The small dist-tags
endpoint is asked rather than registry.npmjs.org/ccline-cli, which is the full
packument: 27KB here today, and 15MB for a package with real history, to
extract one string on somebody's tethered laptop.
The first run only announces. It prints a short note to stderr (so
--json output stays parseable) saying a check will happen, and makes no
request at all. Nothing is asked until a previous run has said it is coming, and
the announcement is only remembered if the note could be recorded, so a
read-only ~/.ccline never gets past this step.
Turn it off in any one of three ways. Any single one is enough, and there is no precedence in which one overrides another:
CCLINE_NO_UPDATE_CHECK=1 # environment, any value except "", "0", "false"
ccline doctor --no-update-check # one invocation, accepted by every command{ "update": { "check": false } }With any of them set, no request is made, no note is printed, and the segment
renders nothing. A project .ccline.json may turn the check off but never
back on: that file arrives with a git clone, and a repository you cloned
does not get to make a network request happen on a machine whose owner switched
it off.
It fails to silence, never to noise. Offline, behind a proxy, a 404, a 500,
a captive portal's HTML, a body that is not JSON, a latest that is a tag name
rather than a version: every one becomes "unknown", and unknown renders nothing.
ccline doctor is where the four different silences are told apart, including
when the last check ran, when the next is due, and why an answer is stale. Point
CCLINE_REGISTRY at a mirror if you have one.
The interactive picker
ccline configCheckboxes for segments, reordering, a theme list, and a live preview of the
real line through the real renderer. Enter writes ~/.ccline/config.json,
escape writes nothing at all. It preserves keys it does not manage (budgetMs,
options, and anything a future version adds), and it refuses a
config.json with a syntax error rather than flattening it.
It needs a terminal, and says so instead of hanging when it does not have one:
$ ccline config < /dev/null
ccline config needs an interactive terminal.
Run it directly in a shell, or edit /home/you/.ccline/config.json by hand.
"ccline segments" lists the names, "ccline preview" shows the line.Writing your own
Full guide for all four surfaces: docs/PLUGINS.md. The short version for segments, which is the extension point most people want.
A segment is a function from the parsed payload to text, or to nothing.
// pomodoro.mjs
import { createRegistry } from "ccline-cli/segments";
import { renderLine } from "ccline-cli/render";
import { parsePayload, toCtx } from "ccline-cli/payload";
import { loadConfig } from "ccline-cli/config";
const registry = createRegistry([
{
name: "pomodoro",
summary: "Minutes left in the current 25 minute block",
render: (ctx) => {
const left = 25 - Math.floor((ctx.now / 60000) % 25);
return { text: `${left}m block`, color: left <= 5 ? "yellow" : "gray" };
},
},
]);
let raw = "";
for await (const chunk of process.stdin) raw += chunk;
const payload = parsePayload(raw);
const base = loadConfig({ cwd: payload.cwd ?? process.cwd(), env: process.env });
const config = { ...base, segments: ["context", "pomodoro", "model"] };
const ctx = toCtx(payload, { config, env: process.env, now: Date.now() });
const line = await renderLine(ctx, registry);
if (line !== "") console.log(line);$ echo '{"model":{"display_name":"Opus 5"},"context_window":{"used_percentage":30}}' | node pomodoro.mjs
ctx 70% left · 22m block · Opus 5Point Claude Code at it and it is your statusline:
{"statusLine": {"type": "command", "command": "node /abs/path/pomodoro.mjs"}}Three rules, two of which the renderer enforces for you:
- Returning
nullis normal. No data is not an error. Drop yourself. - Throwing is survivable. A segment that throws or rejects drops itself and nothing else, so you do not need a try/catch for correctness.
- Do expensive work
async. This one is yours. No deadline in JavaScript can interrupt a synchronous loop, so a busy sync segment blocks the prompt no matter whatbudgetMssays.
A later spec replaces an earlier one of the same name, so overriding a built-in is the same operation as adding a new one.
Troubleshooting
Start here. The render path is silent by contract, so doctor is where ccline
explains itself.
ccline doctorIt runs a check per concern and tells you which one is the reason:
ccline doctor 0.1.0
[ ok ] Statusline installation
ccline runs your statusline, from /your/project/.claude/settings.json:
npx ccline-cli statusline
[info] Installed invocation
The command runs through npx unpinned, so each render resolves whatever is
latest on the registry rather than this copy. This ccline is 0.1.0.
[fail] Transcript and checkpoint
No transcript directory for this working directory:
~/.claude/projects/-your-project
Either Claude Code has never run here, or CLAUDE_CONFIG_DIR points
somewhere other than where Claude Code writes. tokens and duration
cannot render without it.
[warn] Segment "tokens"
Renders nothing. No transcript could be read, and tokens are not in the
payload at all, so there is no source for this number.
[warn] Update check
ccline 0.2.0 is published and you are running 0.1.0. The last check ran
3 hours ago, and the next one is due in 21 hours.
The "update" segment is showing ↑ 0.2.0 on your line.
fix: npm install -g ccline-cli@latest
[ ok ] Render time
Rendered in 1ms against a 300ms budget, 100% headroom.
Your line right now:
ctx 37% left · 4.8M tok · Opus 5 · main · $12.40 · 26d 1hIt exits non-zero when something is actually wrong, so it works in a script, and
ccline doctor --json gives the same report as JSON.
| Symptom | Likely cause |
|---|---|
| No line at all | Nothing installed, or a higher-precedence settings file shadows yours. Claude Code reads settings.local.json over settings.json over ~/.claude/settings.json. Doctor names the winner, and install warns at the time |
| Line appeared, then stopped | The installed command points at a path that no longer exists. A failing statusline is silent by design, so this looks like nothing at all |
| tokens and duration missing | No readable transcript. They are the only two segments that need one, and tokens are not in the payload |
| First render short, later ones complete | Expected on a very large transcript. The cold parse cannot finish inside the budget, so those segments drop once, the checkpoint lands, and every later render is complete |
| No colour | NO_COLOR is set, "color": false, or "theme": "plain". Doctor reads all three |
| Changed settings, nothing happened | Claude Code reads settings.json at session start. Start a new session |
| update never shows anything | Usually because you are on the latest version, which is the point. ccline doctor says which of the four silent cases it is: up to date, never checked, checked and got no answer, or an answer too old to trust |
Doctor can also prove it is not lying to you:
$ ccline doctor --self-test
25/25 checks proved they can fail.That feeds fabricated broken worlds through the real check functions and asserts each one reports a problem. A check that cannot fail is indistinguishable from a check that passes, which is the failure this whole project is organised around.
Screenshots are not mockups
Every image in this README is generated:
npm run assets # regenerate the mark and re-capture the screenshots
npm run assets:check # fail if anything committed no longer matchesscripts/gen-screenshots.mjs runs the real renderer over pinned fixtures and
converts the escape codes it actually emitted, including the pet cards, so the
poses in the images are the poses the segment produced for those sessions.
scripts/gen-mark.mjs draws the cat from a pixel grid, and
scripts/gen-pets.mjs draws its five poses into src/pets/frames.ts. No output
is edited by hand, npm test fails if one drifts from its source, and CI runs
the check on every push.
Contributing
Yes please. CONTRIBUTING.md has the layout, the test bar and what a good PR looks like. The short version:
git clone https://github.com/br9704/ccline.git
cd ccline
npm install
npm testA new segment is the easiest first PR, and docs/PLUGINS.md walks the whole thing.
One rule stricter here than in most repos: a test must assert the failing case. Proving a guard passes when things are fine says nothing. Prove it fires when they are not.
The mark
This is Bubba. He is a real cat, recoloured orange and white, and he is drawn
from a 64x64 pixel grid rather than stored as a picture, so the geometry has one
source and cannot be nudged out of shape by accident. assets/logo.svg is the
bare mark, assets/favicon.svg is the 16px version on its own plate, and
node scripts/gen-mark.mjs --preview prints the whole grid in your terminal.
Licence and credits
Code: MIT. Use it, fork it, ship it.
The cat is not. The mark is Bubba, and the artwork in assets/ plus the
pixel grids in scripts/gen-mark.mjs are © Bruno Jaamaa, all rights reserved.
Fork the code, draw your own pet.
Built by the team behind Aethereum, which gives a team's AI coding agents a shared brain. ccline does not depend on it, does not talk to it, and never phones home. If you have never heard of Aethereum, nothing in this tool changes.
