README
¶
rabbithole-cli
Terminal companion for the Rabbit Hole VS Code extension — coding stats without leaving the shell.
Status
All six commands work against real data, with --json on every one of them.
Reads are local and read-only — the CLI never writes to the extension's data,
and never touches the network.
Install
The command is rabbithole. No toolchain is needed — every release ships
prebuilt binaries for macOS, Linux and Windows on both amd64 and arm64.
macOS and Linux, with Homebrew:
brew install platinumfalcon98/tap/rabbithole
Windows, with Scoop:
scoop bucket add platinumfalcon98 https://github.com/platinumfalcon98/scoop-bucket
scoop install rabbithole
macOS and Linux, without Homebrew:
curl -fsSL https://raw.githubusercontent.com/platinumfalcon98/rabbithole-cli/master/install.sh | sh
Windows, without Scoop:
irm https://raw.githubusercontent.com/platinumfalcon98/rabbithole-cli/master/install.ps1 | iex
Both scripts verify the download against the release's published SHA256 before
installing anything, and print the PATH line they want rather than editing your
shell's startup files behind your back. Set RABBITHOLE_INSTALL_DIR to choose
where the binary lands.
By hand: download an archive from the
releases page,
check it against checksums.txt, and put the binary somewhere on PATH.
With Go, if you already have it:
go install github.com/platinumfalcon98/rabbithole-cli/cmd/rabbithole@latest
That installs to $(go env GOPATH)/bin — on Windows C:\Users\<you>\go\bin,
which the Go installer puts on PATH. A terminal opened before Go was installed
will not see it until restarted.
The only real requirement is VS Code with the Rabbit Hole extension, with some tracked activity. The CLI only reads what the extension has already recorded; it tracks nothing itself.
Verify with rabbithole version.
Commands
| command | what it does |
|---|---|
rabbithole today |
today's active time, languages, projects, sessions |
rabbithole streak |
current streak and daily target progress |
rabbithole week [n] |
last 7 days as a bar chart, or the last n days |
rabbithole files [n] |
files touched over the same range, grouped by project |
rabbithole projects |
tracked projects and their recent activity |
rabbithole doctor |
check the data source for format drift |
rabbithole version |
print version |
week takes an optional day count — rabbithole week 30 — up to 366. Bars are
scaled against the busiest day in the range, not an absolute target, so the
shape is comparable within one run but not between two. They are also graded by
colour intensity against that same peak, which keeps a short day distinguishable
from a very short one after both have been rounded up to the one block that
keeps them visible.
files takes the same day count and defaults to the same week, because which
files you touched is rarely a question about today alone. Files are grouped
under the project they were touched in, ranked by lines changed, and a file
edited across several days is one row with its churn summed rather than one
row per day. Paths are trimmed to their last three segments — enough to tell
src/tracker/activityTracker.ts from test/tracker/activityTracker.ts, without
the leading directories every row would repeat. Ten files are listed per project
and the rest are counted; --json carries all of them, untrimmed.
today and week also report a file count and lines changed per project. Those
columns are a true decomposition of the totals on the header line rather than a
separate reading — every file is attributed to exactly one project, and the same
rows are what the totals are summed from.
Projects
today and week break their totals down per project, and --project narrows
them to one:
rabbithole today --project mekatrone
rabbithole week 30 --project=github.com/user/repo
The argument does not have to be the project id. Matching runs most to least precise — exact id, exact name, then a fragment of the name, path or id — and stops at the first round that matches, so an exact name is never dragged into ambiguity by a longer name containing it.
streak takes no --project. The streak is a property of the whole day across
every project, and the flag is rejected rather than quietly ignored.
Two wrinkles the data forces, both visible in a real history:
- One repo can be two projects. A repo registered before its git remote
existed keeps its
<folder>:<hash>id and gains a second entry under the remote URL later. They have the same name, so--project <name>reports the ambiguity and lists both ids; a fragment of either id resolves it. In a breakdown the shared name is qualified —rabbithole-cli (afbb1f9)againstrabbithole-cli (github.com). - Some logged ids are not registered projects. Legacy pre multi-project
entries carry no id at all and show as
(unattributed); ids that logged time without a matching registration are listed separately rather than dropped.
Per-project times are an exact decomposition, not an estimate: the extension attributes each session to one project, and their sum reproduces the day's rollup total.
JSON output
Every command takes --json, in any position, and writes one indented object to
stdout. Errors stay on stderr, so a failed run never mixes a half-written
document into the data stream.
rabbithole today --json | jq '.languages[0].name'
rabbithole week 30 --json | jq '[.days[] | select(.activeTimeMs > 0)] | length'
rabbithole files 30 --json | jq -r '.projects[0].files[] | "\(.linesAdded)\t\(.path)"'
rabbithole streak --json | jq -e '.targetMet' >/dev/null && echo done for today
The shapes are a deliberate contract rather than a dump of internals, so a change to how the extension stores things does not silently reshape them:
- Durations are milliseconds, always suffixed
Ms. No pre-formatted strings — formatting is the terminal's job. - Timestamps are RFC 3339 with a local offset. An open session has
"end": null,"open": true, anddurationMsof 0, matching how the extension checkpoints it. - Language and file lists are complete. The terminal shows the top five languages and ten files per project; that is a decision about screen space and scripts do not inherit it.
- File paths are absolute, exactly as the extension recorded them. The terminal trims to the last three segments to fit a column; a script is more likely to want to open the file than to display it.
- Empty collections are
[], nevernull, so.languages | lengthworks on a quiet day. - Unset is null, not zero.
streak --jsonreports"targetMinutes": nullwhen no target is configured, which is not the same as a target of zero. - Quiet days stay in
week.days, zeroed, so a range plots continuously. scopeis null when unfiltered, and an object when--projectis in play, so a consumer can tell a filtered document from a whole-history one without inferring it from a single-entryprojectsarray.- Project entries carry both
projectIdandname. The id is stable, the name is readable, and neither substitutes for the other when two ids share a name.
Colour
Human-readable output is styled with bold, dim and colour, and the colour is meant to carry information rather than decorate: green and red for lines added and deleted, green for an open session and a met target, amber for a target still short, dim for the secondary text beside every figure — session counts, project ids, quiet days.
Two places where the colour is the reading rather than an accent on it:
- Week bars are graded in four steps of green's intensity against the busiest day in the range. Green is what an amount is coloured everywhere else in this output, and a bar's height is exactly that, so grading it in shades of the same hue says more without claiming a second meaning. Intensity says more and less, and nothing else. The thresholds are uneven because most days sit well below the busiest one, and even steps would put nearly every bar in the bottom bucket and grade nothing.
- Languages keep a fixed colour, so one language looks the same in
todayas inweek, this run and the next — a colour that moved between runs would be decoration. Common languages are mapped bylanguageId; anything else is coloured from a hash of its name, which is stable without the map having to know every language.ignore,plaintext,logandpropertiesstay dim on purpose: they are routinely the largest entry in the list, and colouring them would spend the most prominence on the least interesting row.
Styling is off unless stdout is a terminal, so a redirect or a pipe produces the same plain text it always did and nothing has to be stripped downstream. On top of that, in order of precedence:
NO_COLOR set to a non-empty value |
off (no-color.org) |
TERM=dumb |
off |
FORCE_COLOR set to a non-empty value |
on, even into a pipe |
--no-color (or --no-colour) |
off, whatever the environment says |
--json |
off, always — see below |
--json is a veto rather than a default: an escape code inside the document
would be a parse error, not a cosmetic complaint, so no combination of flags or
environment variables can put one there.
Only foreground colours, bold and dim are used. No background colours, no underline, no italics — italics in particular are ignored by some terminals and rendered as inverse video by others, which would read as a bug. Column widths are measured on the unstyled text, so alignment is identical either way; the test suite asserts exactly that by stripping the escapes and comparing.
Data source
Rabbit Hole publishes its data two ways, and the CLI reads whichever is
available. rabbithole version prints which one answered.
JSON mirror (preferred)
The extension mirrors every day's log to plain JSON under its own globalStorage
directory, globalStorage/rabbit-hole.rabbit-hole/mirror: a meta.json holding
the schema version and the project list, plus one days/YYYY-MM-DD.json per
tracked date. This is a format the extension deliberately publishes, so it is
the source the CLI prefers.
One file per day, rather than one file, because the extension checkpoints every
few seconds: only the current day changes, so writes stay bounded no matter how
much history exists. meta.json deliberately does not index the days — the
reader asks for the dates it wants and a missing file is an empty day, so
nothing has to be kept in sync.
meta.json carries an integer schema. If it is newer than this build reads —
or the mirror is absent, or unreadable — the CLI silently falls back to SQLite
rather than failing. An older CLI meeting a newer extension keeps working.
VS Code globalState (fallback)
globalState physically lives in
%APPDATA%\Code\User\globalStorage\state.vscdb (SQLite), and the CLI reads
that file directly when there is no usable mirror. It is also the only path to
history written before the mirror existed.
The coupling to VS Code is smaller than it looks: an extension's entire
globalState is a single row in ItemTable, keyed by the extension id, whose
value is one JSON object. Everything inside that blob is Rabbit Hole's own
schema.
SELECT value FROM ItemTable WHERE key = 'rabbit-hole.rabbit-hole'
Three things that shaped the reader:
- VS Code holds the file open. It is copied to a temp file and the copy is queried, so a running editor can neither block the read nor fail it midway.
- Project IDs contain colons — they are git remote URLs, or
<folder>:<hash>when there is no remote. Log keys must be split from the right, never on the first colon. - Two key shapes exist.
rabbithole:log:<projectID>:<date>is current;rabbithole:log:<date>is left over from before multi-project support.
Both readers sit behind the store.Store interface, and a conformance test
asserts they return identical logs for the same data — that equivalence is what
makes preferring one of them safe.
Drift, and rabbithole doctor
The CLI reads a format the extension owns, and Go's encoding/json makes that
quietly dangerous: a field it cannot match is left at its zero value and no
error is returned. Rename activeTime to activeMs on the writing side and
every total becomes a believable 0 rather than a failure. The schema gate
only catches a change someone remembered to think about.
So two things guard it. The reader refuses a day file missing a field it
must have — a session with no startTime, a log with no date — because
reporting a renamed field as zeroes is worse than reporting nothing. And
rabbithole doctor checks every day file on disk and reports both directions:
rabbithole doctor # exits non-zero if the format moved
rabbithole doctor --json # same report, for a script or a CI step
- Errors — a required field is gone. This build now reads the data wrongly.
- Notes — a field is present that this build does not know, meaning the extension moved ahead. Reading still works; additive changes are always safe.
Fields recognised but deliberately unread are named as such rather than reported
as unknown, so the report stays quiet until something genuinely changes. Only
fields that four months of real history proves are always present are required —
the oldest sessions predate activeTime and carry idle instead, and a
validator that fails on the extension's own history would be worse than none.
A rename of an optional field therefore surfaces as a note rather than an
error, which is still the signal.
Run it after changing the extension. It is cheaper than remembering to eyeball every total.
Environment
RABBITHOLE_STATE_DB— path to astate.vscdb, overriding the default search. Useful for pointing at an Extension Development Host profile.RABBITHOLE_MIRROR_DIR— path to a mirror directory (the one containingmeta.json), overriding the default search. A mirror written by something other than the extension works just as well; the format is not tied to it.NO_COLOR,FORCE_COLOR,TERM— control styling; see Colour.
Build
go build -o rabbithole.exe ./cmd/rabbithole
go vet ./...
go test ./...
Tests cover the pure functions — log-key parsing, JSONC stripping, range
aggregation, the duration/bar formatting, the styling and padding helpers, and
the JSON contract — plus the whole mirror reader against fixtures built from the
schema, and a conformance test that builds a real state.vscdb and asserts both
readers agree on it.
To put it on PATH — this drops rabbithole into $(go env GOPATH)/bin:
go install ./cmd/rabbithole
The entry point lives in cmd/rabbithole/ because Go names an installed binary
after its package directory, not the module. That keeps the command rabbithole
while the module and repo stay rabbithole-cli.
Every target cross-compiles from any host with no C toolchain, because
modernc.org/sqlite is a pure-Go SQLite and nothing here uses cgo:
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build ./cmd/rabbithole
That is what lets one Linux runner build all six release binaries; see
.github/workflows/release.yml. Adding a cgo dependency would quietly end it.
Next steps
- Merging the two ids one repo can accumulate, so a rename or a late git remote does not split its history in two.
- The mirror carries the extension's
agentsdata through untouched; nothing reads it yet.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
rabbithole
command
rabbithole-cli — terminal companion for the Rabbit Hole VS Code extension.
|
rabbithole-cli — terminal companion for the Rabbit Hole VS Code extension. |
|
internal
|
|
|
store
Package store reads Rabbit Hole's coding-activity data.
|
Package store reads Rabbit Hole's coding-activity data. |