OpenTokenUsage
CLAUDE.md37 starsChanged 8 months agoMITView on GitHub
Read AGENTS.md first — it has the general rules (simplicity, error handling, PR checklist). This file holds the project facts that are easy to get wrong.
- Reads credentials
In this repository7 other files it gives its agents.
- AGENTS.mdAGENTS.md
- release-tagSkill
- tauri-developmentSkill
- tauri-v2Skill
- run-opentokenusageSkill
- tauri-developmentSkill
- tauri-v2Skill
The file
We split this CLAUDE.md into the rules it gives an agent
8 rules. About 0 are common, 3 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 1
Read AGENTS.md first — it has the general rules (simplicity, error handling, PR checklist).
- Very specific: line 16
Every line label a plugin can emit on the overview card must be declared scope: "overview" in plugin.json, or that account type gets a …
- Very specific: line 31
Only rate_limits.five_hour/seven_day are kept (plugins_data/claude/statusline.json).
- Very specific: line 98
i18n: every UI string goes through t("key") (src/lib/i18n.ts).
- Very specific: line 101
Never compare translated text in logic.
- Very specific: line 195
Not Disturb. Every toast needs that call (play_alert_sound): the update-ready one went unnoticed without it.
- Distinctive: line 210
No telemetry: the upstream Aptabase ping (reported to upstream's account) was removed in 0.7.0.
- Distinctive: line 211
Don't add analytics back.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Read AGENTS.md first — it has the general rules (simplicity, error handling, PR checklist). This file holds the project facts that are easy to get wrong.
What this is
- OpenTokenUsage: Windows-only Tauri v2 tray app (React 19 + Tailwind v4 frontend, Rust host).
Derived from
robinebers/openusage(left GitHub's fork network 2026-09-28); there is no macOS build — don't add macOS code paths back. - Upstream (
upstreamremote) is now a Swift macOS app. Nevergit merge upstream/main. Port provider fixes by hand: readgit show upstream/main:CHANGELOG.md, compare the Swift provider inupstream/main:Sources/OpenUsage/Providers/<X>/withplugins/<x>/plugin.js. The last Tauri upstream is branchupstream/tauri-legacy(= v0.6.28).
Plugins (providers)
plugins/<id>/plugin.jsruns in a fresh QuickJS runtime per probe: module-level variables do NOT survive between refreshes (vitest reuses one instance, so tests hide this). Persist anything cross-probe underctx.app.pluginDataDir(see claudelive-plan.json).- Every line label a plugin can emit on the overview card must be declared
scope: "overview"inplugin.json, or that account type gets a blank card (the card filters by manifest labels). - Response bodies are logged (first 500 chars) after
redact_bodyinsrc-tauri/src/plugin_engine/host_api.rs— on any new endpoint, add identity fields (email, name, uuid, ids) to the redaction list with a test. - Host already handles: HTTP 429 backoff per plugin+URL (
plugin_engine/http_gate.rs, saved tohttp-gates.jsonso it outlives a restart), probe timeout (30 s), one probe per plugin at a time, panics → error result. Don't re-implement in JS. Anthropic budgets usage-endpoint reads per login, shared with Claude Code's own reads, and answers one over it with Retry-After 3600: Smart refresh keeps Claude cards at ≥10 min (smartFloorMinutes), and the claude plugin itself reads at most once per 10 min whatever asks (restart, panel, manual refresh), returning the kept answer withstaleSincein between. A 429 waits Retry-After but at least 5/10/20/30 min per 429 in a row (reset by a 2xx). - Claude Code status-line bridge (
claude_statusline.rs, Settings → Claude Code status line): writes a fixedcurl.execommand into Claude'ssettings.json(never over the user's ownstatusLine; the other top-level entries keep their order and text) that posts Claude Code's statusLine JSON toPOST /v1/claude/statusline. Onlyrate_limits.five_hour/seven_dayare kept (plugins_data/claude/statusline.json). The plugin lays a snapshot under 5 min old overlast-usage.jsonand then reads the API only every 30 min; a 429 shows the snapshot instead of the error. The command lives in Rust: the webview can only switch it on or off. Each post'scontext_window.used_percentageis kept in memory persession_idfor the live sessions list. - Live Claude sessions (
claude_sessions.rs,claude-sessions.tsx): Claude cards list<config dir>/sessions/*.jsonwhose pid still runs with the file'sprocStart(FILETIME as a decimal string; guards against a reused pid), polled every 5 s while shown. The config dir comes fromlogin_stamp::cli_dirs(extra accounts use their own; never a WSL copy). - Usage heatmap (
usage_heatmap.rs,usage-heatmap.tsx): a Claude or Codex login's detail page shows tokens per weekday × local hour over 4 weeks (whole weeks: every cell adds up as many days), from the CLI's own logs (projects/**/*.jsonl,sessions/**/*.jsonl) in thecli_dirsfolders. Only usage and timestamps are read. Logs only grow, so each file is read once and then from its last offset (kept in memory: the first read after a start takes seconds on GBs of logs). A file's size comes fromfs::metadata, not the folder listing: Windows' listing keeps the size a file had when last closed, and a CLI can hold its log open. Claude writes a line per content block, each with the message's usage so far: a message counts once, at its largest. Codex countstoken_usage_recordlines (one per response); older logs only havetoken_count, a repeat with an unchanged total skipped. Usage is kept per UTC quarter hour so half-hour time zones land in the right local hour; logs it can't read are counted under the grid. The detail page reuses the component across cards, so its data is kept with the card id it was read for. The grid is an ARIA grid: one tab stop, arrow keys move, the focused cell's tokens show in the header. - Usage by project (
usage_projects.rs,usage-projects.tsx): the same scan (scan_logs, shared cache) keeps each message's tokens by kind with its model, folder (cwd) and git branch (Claude: per line; Codex: the log's firstsession_meta(a sub-agent's log repeats its parent's after its own), model and folder perturn_context, a record going with itsturn_id). Dollars are ccusage's daily report for the month (host_api::ccusage_daily: its own lock, never the probes' guard, one cached report per month for 15 min; pointed where the card's last probe pointed it,last_ccusage_home, since the plugin knows which login folder works), shared out per model (Codex's report has only day totals: pooled) by tokens weighted at the providers' price ratios, so projects add up to ccusage's total. A folder maps to its repository (walk up to.git; a.gitfile'sgitdir, normalized, to the checkout holding it), looked up once per folder; network paths (\\…) are never looked into. Month bounds come from the frontend (local time);HEADmeans no branch. Budgets (project-budgets.ts, settingprojectBudgets: USD per card id and project path, inBACKUP_KEYSviamergeCardMap): the bar turns yellow at 80% and red at 100%;use-project-budget-alerts.tschecks enabled cards with a budget every 15 min, panel open or not, and notifies at 80% and 100% once per project and month (keys insentProjectAlerts, apart fromsentUsageAlerts, whichuse-usage-alerts.tsrewrites from memory; the key holds the budget, so a corrected one alerts again). A typed budget is read in the app language's style (parseAmount:5.000is five thousand in Turkish), and Save re-reads the budgets from disk before writing. Forecast = spend × month length ÷ time elapsed, after the first day. CSV: built in the frontend (it has the currency), written byexport_projects_csvto Downloads under a validated fixed name, with a BOM; formula-like cells start with';;and decimal commas when the language writes decimals with a comma (Excel splits on the system list separator); costs empty when the month is unpriced. - Paths:
~/Library/Application Support/<X>is mapped to%APPDATA%\<X>byexpand_path. SQLite goes through bundledrusqlite(nosqlite3CLI); nops/lsof/securityon Windows. - Release builds run only the bundled plugins (copied to
%APPDATA%\com.poweruserz.opentokenusage\plugins); the repo'splugins/is picked up from the cwd in debug builds only (#[cfg(debug_assertions)]inplugin_engine/mod.rs) — never make that path reachable in release. Plugin ids must match[a-z0-9-];entryandiconmust stay inside the plugin folder. - Host HTTP caps response bodies at 8 MB and honors
dangerouslyIgnoreTlsfor loopback URLs only. - Extra accounts (
accounts.rs, Claude and Codex): each lives in its own login folder (<app data>/accounts/<id>) and runs as a copy of the plugin under its own id (claude-1a2b3c4d), so everything keyed by plugin id works per account. The copy'sAccountBindingoverrides the CLI's home variable (CLAUDE_CONFIG_DIR/CODEX_HOME) inctx.host.envand hidesCLAUDE_CODE_OAUTH_TOKEN; ccusage uses the base id. Never copy the usual login's tokens into an account (a rotated refresh token logs the CLI out); sign-in goes throughstart_account_login(fixed commands, env set in Rust). - WSL logins (
wsl.rs): a plugin whoseplugin.jsonhaswsl.logingets a copy per login found in a running distro (wsl.exe --list --running, 5 s limit; a distro is never started): id<plugin>-wsl-<8 hex>, card<Name> · WSL. ItsAccountBinding.homeis the Linux home (\\wsl.localhost\<distro>\home\<user>:~resolves there,~/Library/Application Support/X→~/.config/X),isolated_envhides the whole Windows environment, and ccusage answersno_runner. Same-account check: each plugin's local-onlyaccountKey(ctx)(the host hashes it); a WSL copy without a key isn't shown, one matching the usual login, an extra account or another WSL copy isn't either. Cards are saved inwsl-accounts.jsonafter their first good probe; while the distro is stopped a card gets its cached data withstaleSince(the UI shows that time). Logins are read and refreshed in place — never copy them. SQLite on the share always answers "database is locked", sosqlite::query_jsonreads it through itsimmutable=1fallback (UNC URIfile:////wsl.localhost/...), which doesn't see the WAL.
UI
- Windows 11 Fluent look: colors are tokens in
src/index.css(--accent-base= Windows accent from the registry,.backdrop= Mica active → translucent layers). Use tokens, not hex colors. - i18n: every UI string goes through
t("key")(src/lib/i18n.ts); plugin line labels throughtLabel().src/locales/en.tsis the source; a new key must be added to all 11 locale files (TypeScript fails otherwise). Keep{placeholders}identical (checked bylocales.test.ts). Never compare translated text in logic. The native tray menu gets its labels from the frontend (set_tray_menu_labels). - Translation rule (always): whenever a feature adds or changes user-facing text, update the
key in all 11 languages in the same change:
src/locales/*.tsfor UI, plussrc/locales/plugin-errors/*.tsfor plugin errors. Rewording the English means rewording every language, and never paste English into another locale.translation-lock.test.tsfails when English text changes; after translating everything, runbun run locales:lock. Never re-lock without translating first. - Errors:
getPluginErrorActionturns known errors into one-click fixes: a login command →run_in_terminal(Rust allow-list insetup_actions.rs, test-synced with the plugin fixtures), a missing API-key env var → the Windows Environment Variables dialog. Env vars are also read from the registry, so a key added while the app is running works on the next refresh. While a card shows a sign-in error (its fix is a login command),use-login-recovery.tschecks the CLI's login file time every 5 s (login_stamp.rs: Claude and Codex in the folders their plugins read, and their extra accounts; never a WSL copy, whose share can start a stopped distro) and probes the card the moment it changes. The plugins rewrite that file when they refresh a token, so every card takes its time afresh after any card's probe (else a refresh that still ends in 401 chains probes, each rotating the login; two cards share a file when an extra account's folder is set as CLAUDE_CONFIG_DIR / CODEX_HOME). A card that worked in this run (not cached data) and loses its sign-in gets one toast per episode. Disabled cards are left alone. - Tray metric: one setting,
trayMetric(auto | Session | Weekly), drives bars, percent and tooltip. Tray providertightest= "Most used" line across providers.trayHiddenPluginskeeps a provider in the nav but out of the tray. The tray icon color follows the taskbar theme, not the app theme:window_style::watch_taskbar_themesendstaskbar:themethe moment Windows switches it. - Tray styles
numbers/logos= one tray icon per provider (use-provider-tray-icons.tsdraws,tray::set_provider_tray_iconsshows them and hides the app icon). Update icons in place, never recreate (Windows keys "show next to clock" on tray-icon's creation-counter uID). A hidden tray icon rejectssetIcon, so the app icon is redrawn after it's shown again. Our icons (the app icon too) are promoted viaHKCU\Control Panel\NotifyIconSettings\*\IsPromotedonly when the user hasn't chosen yet, matched by exe file name (Program Files paths are stored as{GUID}\OpenTokenUsage\...), retried for 30 s because Explorer writes the entry late. Explorer applies the value live. - Taskbar strip (experimental,
taskbar_strip.rs): layered child windows ofShell_TrayWnd(left ofTrayNotifyWnd) and, pertaskbarStripStyle.monitors("primary" | "all" | picked{id,name}), ofShell_SecondaryTrayWnd(no tray window there: left of a fixed 88 px clock width). One thread owns them all (it shares Explorer's input queue: never block, never hold a lock across Win32 calls) and reconciles every 2 s, which also covers Explorer restarts and monitors coming and going. Monitor ids are model + connector UID from the display config (monitor_id; the middle of the device path changes between boots); a picked monitor that isn't connected falls back by model, then to the main taskbar. The frontend draws one image per taskbar DPI; the host asks for missing ones with thetaskbar-strip:dpisevent and meanwhile uses the nearest. Layered child windows need the Win8+<compatibility>insrc-tauri/app.manifest(wired inbuild.rs) — don't drop it. The frontend draws the image (src/lib/taskbar-strip.ts); its 1/255-alpha background keeps the whole strip clickable. Its look lives intaskbarStripStyle(fonts, colors, usage thresholds, and its own provider list and order, max 6, independent of the nav order; per provider both/session/weekly; color scales inTASKBAR_STRIP_COLOR_SCALES, "base" = the text color). The strip and the tray styles that show numbers exclude each other: enabling the strip sets the tray to "icon", picking another style turns it off. A strip click opens the panel above the strip (tray::toggle_panel_at, viarun_on_main_thread). Placement (taskbar_strip/placement.rs): Right = beside the tray; "left" = the free side away from it, the left edge, or the middle whenTaskbarAl= 0 (icons left; Settings then labels it Center). The strip keeps to the gaps between the buttons thattaskbar_strip/uia.rsmeasures every second with UI Automation (Windows 11's Start, Widgets and app buttons are XAML, no windows to measure), on its own thread because those calls wait on Explorer. - Panel: the title bar pin (
panelPinned) keeps it always on top. "Remember position":tray.rsrecords every position it sets itself (place); any other move while visible is the user's drag and is saved aspanelPosition(outer top-left, physical px) in settings.json, then used byanchor_panel/toggle_panel_at, clamped into the work area. Move the panel only throughplace(), or your move is remembered as a drag. - Support: the side-nav coffee cup (
BmcCup, from Buy Me a Coffee's brand kit: outlinecurrentColor, coffee--supportyellow) opensSupportDialog; the About window and the tray menu openSUPPORT_URL(TS ↔ tray.rs, test-synced). A dialog taller than a short panel setsdata-panel-dialog: app-shell'shas-[…]:min-hgrows the panel while it's open (the window follows the content height). SupportDialog also asks for a GitHub star. The star card (star-prompt.tsx) shows once, for a minute: 3 h after a new install, on the next panel open for an existing one. "Existing" means plugin settings were already saved, soinitStarPromptmust run before the bootstrap's firstsavePluginSettings(test-checked), or every update would look like a new install. - Help (the side-nav ?) opens the in-app FAQ (
help-dialog.tsx), with GitHub Issues as its last link. Its answers take setting names as placeholders filled from the settings' own labels: rename a setting and the FAQ follows. - Progress bars hatch the usage spent since the open panel last showed them (
use-unseen-usage.ts): it blinks 3.5 s, then the fill grows over it. Seen values are in memory, per plugin + line and in used terms (switching used/left isn't spending). "Open" is asked from the OS (isVisible): the hidden panel's webview reports itself visible before its first show. - Settings backup (
settings-backup.ts, Settings → Settings backup): takes onlyBACKUP_KEYSfrom the store (no accounts, logins, panel position, sent alerts, update/star state);settings_backup.rswrites it to Downloads (write aside, then swap) and reveals it (no dialog plugin, no webview reveal permission). Import writes those keys raw and removes the ones the backup lacks (they were at their default there), thenrelaunch(): every loader checks its value at startup. Kept from this PC: a custom alert sound (its WAV isn't in the backup), and its account/WSL cards in every setting that names cards (plugins,trayHiddenPlugins,providerLevels, the strip's providers and line modes,trayProvider); the backup's card ids are dropped. A new preference key belongs inBACKUP_KEYStoo, and a new setting that names cards inrestoredValue. - Pace (
pace-status.ts): one elapsed share for everything that shows it (card dot, marker, deficit, tray, pace alert) viapaceElapsedFraction; withpaceWorkdaysa ~7-day window counts Mon–Fri (local time) only. - Currency (
src/lib/currency.ts, settingcurrency): providers report US dollars; the card showsdollarslines viaformatMoneyand rewrites$<number>in plugin text viaconvertUsdText, at a rate the user types (no exchange-rate service: CSP and privacy). USD renders exactly as before. Plugins and the local API stay in USD. - Ring logos are sized by
measureLogoExtent(how far the logo's pixels reach) so square logos stay inside the ring and round ones grow. - Themes:
dark(default, pure black: classes.dark.oled) |light.useSettingsThemealso sets the window theme: Mica takes its tint from the window, so a light page on dark Windows needs it. - Notifications (
src/lib/usage-alerts.ts, pure + tested): the usage levels the user picks (alertSettings.levels, default 80/95, or a provider's own inproviderLevelsviaalertLevelsFor; the card notches its bars at them, flipped when showing what's left), pace, reset; each once per provider+line+window, keys persisted in settings (sentUsageAlerts). Windows are matched by reset time ±1 h, not the exact string: Claude'sresets_atjitters by milliseconds every probe (it caused repeat 80% and fake reset alerts). Toasts are silent (an unpackaged app can't give a toast its own sound):alert_sound.rsplays the chosen sound with PlaySound (theNotification.Defaultalias, bundledsrc-tauri/sounds/*.wavfrom Kenney, CC0, ids test-synced withALERT_SOUNDS, or the user's file, converted to 16-bit PCM WAV insrc/lib/alert-sound.ts) and skips it during Do Not Disturb. Every toast needs that call (play_alert_sound): the update-ready one went unnoticed without it. - Status badge: optional
statusPageUrlin plugin.json (Atlassian Statuspage/api/v2/status.jsononly — verify the URL returns that JSON before adding one). - Local HTTP API (127.0.0.1:6736) is loopback-only: no CORS, Host/Origin must be loopback. Don't add
Access-Control-Allow-Originback (any website could read usage). - Keyboard: Ctrl (not Cmd/Win) shortcuts; show Ctrl/Alt in the UI.
Security
- CSP lives in
tauri.conf.json(script-src 'self';img-srcdata:/blob:;connect-srconly IPC +https://api.github.com). The dev server runs WITHOUT it, so a new remote fetch/image/font source works intauri devand silently breaks in release: add it to the CSP and test a release build (bun tauri build --no-bundle, runsrc-tauri/target/release/opentokenusage.exe). - App-defined Tauri commands are callable by any script in the webview (no ACL): validate every
argument in Rust. Images over IPC go through
tray::decode_rgba(size caps, no u32 overflow);run_in_terminalonly runs its exact allow-list. - No telemetry: the upstream Aptabase ping (reported to upstream's account) was removed in 0.7.0. Don't add analytics back.
Verify before committing
bunx tsc --noEmit·bun run test(src + plugins) ·cd src-tauri && cargo test --lib.- Vitest
testTimeoutis 20 s on purpose (cold transform of a file's first test is slow on Windows under load); don't lower it to "fix" speed — a timeout there is not a logic failure.
Dependencies & releases
bunfig.tomlhasminimumReleaseAge(7 days): don't force newer packages.- Tauri JS packages and Rust crates must share major.minor (
@tauri-apps/api↔tauri,@tauri-apps/plugin-x↔tauri-plugin-x); the Tauri CLI refuses to build otherwise. Adding a crate can silently bumptauriin Cargo.lock: check the lock diff (tauri-plugin-single-instanceis pinned=2.4.5because 2.5 needs tauri 2.12). - A second launch only shows the running app's panel (single-instance plugin), and the app exits at once in session 0 (an installer running as SYSTEM). Both make starting the app after a winget install safe.
- In-app updates run the setup quietly (
plugins.updater.windows.installMode: "quiet"=/S /UPDATE /R):update.install()exits the app (the JSrelaunch()after it never runs) and the setup starts it again.use-app-update.tssaves the version being installed (updateRelaunchVersion); the app that starts as that version reopens its panel. The mode in effect is the installed version's, so a change here reaches users one update later. - Installer: per-user NSIS only (
%LOCALAPPDATA%\OpenTokenUsage, no admin). No MSI: Tauri's MSI is always per-machine, so every update asked for admin. Up to 0.7.7 the MSI shipped; those installs update to the NSIS setup, which uninstalls the MSI once (one admin prompt). Start on login is rewritten only when its Run entry names an exe that no longer exists (autostart_stale, which the removed MSI copy makes true), so a dev or test build never takes it over. WinGet gets the same setup:InstallerType: nullsoft,Scope: user,InstallerSwitches: Custom: /R(Tauri's NSIS starts the app after a silent install with/R, as the MSI'sAUTOLAUNCHAPP=Truedid). komac copies the previous manifest's fields, so the first version after the 0.7.7 MSI needs its winget PR checked and, if komac keptwix/machine/AUTOLAUNCHAPP, fixed on its branch in thePowerUserZ/winget-pkgsfork before it merges. winget won't upgrade an MSI install to the EXE ("install technology is different"): those users move with the in-app update. - Identifier
com.poweruserz.opentokenusage(0.7.8 and older: upstream'scom.sunstory.openusage). It names%APPDATA%\<id>and%LOCALAPPDATA%\<id>;data_dirs.rsmoves the old folders' entries before the builder runs (never overwriting, merging folders both have, but an account folder only moves whole) and leaves a junction at the old path ofaccountsor of each account folder it moved, since Settings tells users to point CLAUDE_CONFIG_DIR / CODEX_HOME at an account's folder. Notifications use it as the AppUserModelID, which must be on the Start menu shortcut: updates don't recreate shortcuts, sosrc-tauri/windows/hooks.nshstamps it on every install.bundle.publisherstays"sunstory"(it was the default from the old identifier) because the NSIS setup only removes an MSI whose Publisher matches it; change it once 0.7.7 MSI installs are gone. - Release: bump the version in
package.json,src-tauri/Cargo.toml,src-tauri/tauri.conf.json(+Cargo.lock), add a## vX.Y.Zsection toCHANGELOG.md(required: it becomes the release body the in-app changelog shows), then push av*tag →publish.yml(windows-latest) builds the NSIS setup and signs updater artifacts with theTAURI_SIGNING_*repo secrets. The updater points at this fork'slatest.json, never upstream's.git fetch upstreammust not bring upstream's Swiftv0.7.xtags (remote.upstream.tagOpt --no-tags). - Workflow actions are pinned to full commit SHAs (version in a comment) and Bun to an exact
version; bump them deliberately. The winget job needs a valid
WINGET_TOKEN(classic PAT,public_repo+workflow): the branch it creates in thePowerUserZ/winget-pkgsfork carries upstream's.github/workflowschanges, and withoutworkflowGitHub refuses it ("does not have the correct permissions to executeCreateRef", 0.7.0). If it fails, submit with komac (see memory). - The owner's hook blocks force pushes (and a few destructive git commands); plain pushes pass.
.claude/skills/tauri-*are real copies (git symlinks don't work on Windows without Developer Mode); keep them in sync with.agents/skills/if you edit one.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

