- Swift 96.6%
- Shell 3.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The cask moved from the dead xicv/tap on GitHub to x/homebrew-tap on x.vibewait.ing. release.sh updates that tap and no longer suggests a GitHub release. |
||
| Resources/AppIcon.icon | ||
| scripts | ||
| Sources | ||
| Tests/EaSpotKitTests | ||
| .gitignore | ||
| .swiftlint.yml | ||
| LICENSE | ||
| Package.swift | ||
| README.md | ||
eaSpot
A tiny, fast LLM quota display for the macOS menu bar. Website: vibewait.ing/eaSpot · Source: x.vibewait.ing/x/eaSpot
This repository holds:
- eaSpot.app (
Sources/EaSpotApp): the menu bar item and menu. AppKit only for everything that stays resident; the SwiftUI Settings window is created on first use. easpot(Sources/easpot): the writers that turn Claude Code and Codex events into snapshot files.EaSpotKit: the shared data contract, merge rules, and the pace/tone judgements (tested).
The app never talks to a provider. Providers conform to eaSpot's data contract instead: an adapter writes a snapshot, and the app renders whatever snapshots exist.
eaSpot is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic or OpenAI. "Claude" and "Codex" are used only to label the limits those tools report.
Requires macOS 14 or later, on Apple silicon or Intel. The layered Liquid Glass icon appears on macOS 26 and later.
Install
-
Install with Homebrew, which puts
eaSpot.appin/Applicationsandeaspoton yourPATH:brew tap x/tap https://x.vibewait.ing/x/homebrew-tap.git brew install --cask x/tap/easpotOr download
eaSpot-<version>.zipfrom the releases page, unzip it, and moveeaSpot.appto/Applications(or~/Applications). Releases are signed with a Developer ID and notarized. -
Open eaSpot. It lives in the menu bar and shows "No usage yet" until a writer reports.
-
Connect the writers. Print the hook snippets with the CLI that ships inside the app:
APP=/Applications/eaSpot.app # or ~/Applications/eaSpot.app, wherever you put it "$APP/Contents/Helpers/easpot" print-configAdd the
Stopgroup tohooksin~/.claude/settings.json, and theStopandPostToolUsegroups to~/.codex/hooks.json(at the end of each list). In Codex, approve the new hooks with/hooks. Optionally put the CLI on yourPATH(any directory on it works):mkdir -p ~/.local/bin && ln -sf "$APP/Contents/Helpers/easpot" ~/.local/bin/easpot -
Use Claude Code or Codex. Numbers appear within seconds (Codex) or a few minutes (Claude).
How data arrives
| Where you work | Writer | Latency | Cost |
|---|---|---|---|
| Claude Code (terminal, desktop app, IDE) | easpot hook claude-stop starts a detached claude -p "/usage" probe when data is older than 3 min or a window has reset |
≤ 3 min + ~3 s | hook ~5–8 ms; probe ~2–3 s, 0 model tokens |
| Codex CLI and desktop app | easpot hook codex (Stop / PostToolUse) reads the newest token_count in rollout logs |
end of each tool call or turn | ~12 ms |
| Anything else | easpot publish with a provider-neutral snapshot on stdin |
adapter-defined | — |
| Menu open with old or reset data | the app runs its bundled easpot refresh all --if-older 120 |
~3 s | as above |
Hooks run outside the model, so none of this uses model tokens.
Data contract (schema 1)
Root: ~/.config/easpot (override with EASPOT_HOME). Readers watch sources/ only.
sources/<source>.json one snapshot per provider, replaced atomically (dot-prefixed temp + rename)
state/ locks, probe stamps, hook traces (hook-*.last), errors.log — never watched
{
"schema": 1,
"source": "claude",
"plan": null,
"updated_at": 1790301234.5,
"windows": [
{
"id": "seven_day",
"label": "Weekly",
"used_percent": 19,
"resets_at": 1790791200,
"resets_at_exact": false,
"window_minutes": 10080,
"observed_at": 1790301230.1,
"via": "usage-probe"
}
]
}
All times are Unix epoch seconds. window_minutes may be null when the length is unknown (for example
a spend limit). resets_at is absent for a limit that never resets, such as a prepaid balance. A reader
should treat a window whose resets_at has passed as reset, and use observed_at for "updated X ago".
A window may also carry absolute amounts, "used": 12, "limit": 200, "unit": "messages", which the menu
shows as "188 of 200 messages left". used_percent is always present and drives the bar and warnings.
Window IDs: Claude five_hour, seven_day, seven_day:<model> (e.g. seven_day:fable), spend_limit;
Codex <limit_id>:<window_minutes> (e.g. codex:10080, codex_bengalfox:10080 for Codex-Spark).
Merge rules
Many sessions report at once and some report stale numbers, so every writer merges under a lock:
- A
resets_atmore than 30 min away from the stored one is a different window, and whichever reading was observed later wins (on a tie, the later reset). So a new window replaces the old one, a stale reading of a finished window is ignored, and a newer reading that moves the reset earlier sticks. - Within a window, a reading at least as new as the stored one replaces it; an older reading can only
raise usage (usage never shrinks within a window). Unknown
viavalues read aspublished. - Windows missing from a reading are kept. Windows that reset more than 8 days ago are dropped.
- The file is rewritten only when a value changes or an observation gets ≥ 60 s fresher.
Publishing from any provider
echo '{"source":"glm","plan":"pro","windows":[
{"id":"five_hour","used_percent":12.5,"resets_at":1790330400,"window_minutes":300}]}' | easpot publish
source must match [a-z0-9][a-z0-9_-]{0,31}. name (how the menu names the source, e.g. "ChatGPT",
up to 32 characters; kept until a later publish replaces it), label, window_minutes, observed_at
(default now) and plan are optional; resets_at may be epoch seconds or ISO-8601.
A tool that counts instead of measuring a percentage sends used and limit (and optionally unit,
up to 24 characters), and used_percent is worked out from them:
echo '{"source":"chatgpt","name":"ChatGPT","windows":[{"id":"pro_weekly","label":"Pro weekly",
"used":12,"limit":200,"unit":"messages","resets_at":"2026-10-02T00:00:00Z","window_minutes":10080}]}' | easpot publish
A source can also choose its icon, drawn next to its number in the menu bar, in the menu and in Settings:
"icon": { "symbol": "bubble.left.fill" }
"icon": { "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\"><path d=\"M2 3h12v8H6l-3 3v-3H2z\"/></svg>" }
symbol is an SF Symbol name; svg is a small SVG (up to 16 KB, no scripts or outside references), for
example one a model generated. Both are drawn as a one-colour shape so they follow the menu bar's appearance
and warning colours; an SVG's own colours are ignored. Like name, the icon is kept until a later publish
replaces it. Without one, eaSpot chooses: its own drawings echoing each app's icon for Claude (a starburst)
and Codex (a cloud with a prompt), and the first letter of the name in a rounded square for anything else.
Leave out resets_at for a limit that never resets; the menu then says "does not reset" and shows no pace.
used must be the running total for the window, not an increment.
Commands
easpot hook claude-stop | hook codex
easpot publish < snapshot.json
easpot refresh [claude|codex|all] [--if-older <seconds>] [--detach]
easpot status [--json]
easpot print-config
The probe fills in USER/LOGNAME when a hook's environment lacks them, since Claude Code needs them to
find its Keychain login.
Environment: EASPOT_HOME, EASPOT_CLAUDE_BIN, EASPOT_CLAUDE_MIN_INTERVAL (default 180),
EASPOT_PROBE_TIMEOUT (default 30), CODEX_HOME.
The menu bar app
One symbol and number per service (or only the tightest limit), showing what's left of the limit that needs attention first. Click for a native menu: each limit as a bar whose fill is what's left, with a tick at even pace — fill past the tick means quota to spare, short of it means running hot — plus a plain-language line such as "Runs out Mon about 2:50 PM · resets Wed 9:15 AM".
- Updates within ~0.1 s of a writer changing
sources/(directory watch, debounced), once a minute for countdowns, exactly at each reset, and after wake. No polling, no network. - Opening the menu when any shown limit is older than the Settings value (default 2 min), or has reset,
runs the bundled
easpot refresh all --if-older <age>. "Refreshing…" lasts until it exits, and a failure is shown with a pointer tostate/errors.log. - Warnings use colour plus a triangle and words; light-mode warning text uses darker tones for contrast.
eaSpot --snapshot <folder> renders the menu bar item, the menu and Settings (light and dark) from the
real data into PNGs, and prints how long building the menu takes.
Build from source
Requires Xcode 26 or later (for actool to compile the Icon Composer icon).
swift test
scripts/bundle.sh # build/eaSpot.app, ad-hoc signed
scripts/bundle.sh --install # also replace the installed app (~/Applications by default) and relaunch it
The icon source is Resources/AppIcon.icon; open it in Icon Composer to edit. bundle.sh compiles it
into Assets.car (layered icon on macOS 26+) and AppIcon.icns (older macOS).
bundle.sh builds a universal app (Apple silicon and Intel), so its binaries land in the folder
swift build -c release --arch arm64 --arch x86_64 --show-bin-path prints, not .build/release.
bundle.sh warns when ~/.local/bin/easpot runs something other than this build. If it is a symlink
to the app's helper (as in Install above), --install updates both. If it is a separate copy, the
warning prints the install command that refreshes it.
Hooks that go through the symlink fail briefly while --install replaces the app; they recover on the
next event.
Releasing
Signed with a Developer ID, notarized and stapled, so Gatekeeper opens it without warnings:
xcrun notarytool store-credentials <profile> --team-id <TEAMID> # once; prompts for credentials
EASPOT_SIGN_IDENTITY="Developer ID Application: <Name> (<TEAMID>)" \
EASPOT_NOTARY_PROFILE=<profile> scripts/release.sh # dist/eaSpot-<version>.zip + SHA-256
Both the app and its helper are signed inside-out with the hardened runtime and a secure timestamp.
Then tag the release and attach the zip and its SHA-256 to a release at x.vibewait.ing/x/eaSpot/releases.
License
MIT. See LICENSE.