Help us shape the future of Go! Take the 2026 Go Developer Survey

rabbithole-cli

module
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 9, 2026 License: MIT

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) against rabbithole-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, and durationMs of 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 [], never null, so .languages | length works on a quiet day.
  • Unset is null, not zero. streak --json reports "targetMinutes": null when 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.
  • scope is null when unfiltered, and an object when --project is in play, so a consumer can tell a filtered document from a whole-history one without inferring it from a single-entry projects array.
  • Project entries carry both projectId and name. 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 today as in week, this run and the next — a colour that moved between runs would be decoration. Common languages are mapped by languageId; anything else is coloured from a hash of its name, which is stable without the map having to know every language. ignore, plaintext, log and properties stay 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 a state.vscdb, overriding the default search. Useful for pointing at an Extension Development Host profile.
  • RABBITHOLE_MIRROR_DIR — path to a mirror directory (the one containing meta.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

  1. Merging the two ids one repo can accumulate, so a rename or a late git remote does not split its history in two.
  2. The mirror carries the extension's agents data 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL