Merge branch 'main' into feat/omp-support-model-aliases

This commit is contained in:
Chris 2026-04-19 11:36:49 +02:00 committed by GitHub
commit 0c5c6fa655
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 9776 additions and 1298 deletions

View file

@ -0,0 +1,43 @@
name: Block Claude / Anthropic co-author trailers
# Rejects PRs that contain a `Co-authored-by: ... claude ...` or `... anthropic ...`
# trailer in any of their commits. Contributors can still use AI tools to help
# write code, but they must remove the co-author attribution before the PR is
# eligible to merge, per the project's contributor guidelines.
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Scan PR commits for disallowed co-author trailers
run: |
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
FOUND=0
while IFS= read -r c; do
[ -z "$c" ] && continue
SUBJECT=$(git log -1 "$c" --format=%s)
if git log -1 "$c" --format="%B" | grep -qiE 'co-authored-by:.*(claude|anthropic)'; then
echo "::error::Commit $c ($SUBJECT) has a Claude/Anthropic co-author trailer. Please remove it before this PR can merge."
FOUND=1
fi
done < <(git log --format=%H "$BASE_SHA".."$HEAD_SHA")
if [ "$FOUND" != "0" ]; then
echo ""
echo "How to fix:"
echo " 1. Run: git rebase -i $BASE_SHA"
echo " 2. Mark each flagged commit as 'reword'"
echo " 3. Delete the Co-authored-by line from the commit message"
echo " 4. Save, then: git push --force-with-lease"
exit 1
fi
echo "No disallowed co-author trailers found."

27
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Semgrep
run: pip install semgrep
- name: Run Semgrep bracket-assign guard
run: |
set -e
semgrep --config .semgrep/rules/no-bracket-assign-hot-paths.yml \
--strict --json \
src/providers/ src/parser.ts > semgrep-out.json
FINDINGS=$(jq '.results | length' semgrep-out.json)
if [ "$FINDINGS" -gt 0 ]; then
jq -r '.results[] | "::error file=\(.path),line=\(.start.line)::\(.extra.message)"' semgrep-out.json
exit 1
fi

69
.github/workflows/release-menubar.yml vendored Normal file
View file

@ -0,0 +1,69 @@
name: Release macOS Menubar
# Triggers on a `mac-v*` tag push (e.g. `git tag mac-v0.8.0 && git push origin mac-v0.8.0`),
# or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, ad-hoc signs it,
# zips via `ditto`, and uploads the zip to the GitHub Release. `npx codeburn menubar` clears
# the download quarantine flag on install so Gatekeeper stays quiet.
on:
push:
tags:
- 'mac-v*'
workflow_dispatch:
inputs:
version:
description: 'Version label for the bundle (e.g. v0.8.0 or dev-preview)'
required: true
default: 'dev-preview'
permissions:
contents: write # Needed to create the release + upload assets.
jobs:
build:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version label
id: version
run: |
if [[ "${GITHUB_REF}" == refs/tags/mac-v* ]]; then
echo "value=${GITHUB_REF#refs/tags/mac-}" >> "$GITHUB_OUTPUT"
else
echo "value=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
fi
- name: Show Swift toolchain
run: swift --version
- name: Build + bundle + zip
run: mac/Scripts/package-app.sh "${{ steps.version.outputs.value }}"
- name: Upload artifact (for manual runs)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: CodeBurnMenubar-${{ steps.version.outputs.value }}
path: mac/.build/dist/CodeBurnMenubar-*.zip
if-no-files-found: error
- name: Create / update GitHub Release
if: startsWith(github.ref, 'refs/tags/mac-v')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Menubar ${{ steps.version.outputs.value }}
body: |
Install with:
```
npx codeburn menubar
```
That command drops the app into `~/Applications`, clears the download
quarantine, and launches it. If you download the zip from this page directly
and macOS shows "cannot verify developer", right-click the app in Finder and
pick Open to whitelist it once.
files: mac/.build/dist/CodeBurnMenubar-*.zip
fail_on_unmatched_files: true

4
.gitignore vendored
View file

@ -15,6 +15,7 @@ Thumbs.db
# Planning artifacts (internal, not shipped)
docs/superpowers/
.claude/
# Config / secrets
.env
@ -32,3 +33,6 @@ npm-debug.log*
# Build artifacts
*.tsbuildinfo
# Local Discord brand / promo assets not yet ready to publish
assets/discord-*.png

View file

@ -0,0 +1,22 @@
rules:
- id: no-bracket-assign-on-literal-object-map
languages: [typescript]
severity: ERROR
message: >
Bracket-assign on a map created with `{}` allows prototype pollution when
the key comes from external data. Initialize the map with
`Object.create(null)` instead.
patterns:
- pattern-either:
- pattern: $MAP[$KEY] = $MAP[$KEY] ?? $INIT
- pattern: $MAP[$KEY] = $MAP[$KEY] || $INIT
- pattern: $MAP[$KEY] ??= $INIT
- pattern: |
if (!$MAP[$KEY]) $MAP[$KEY] = $INIT
- pattern-not-inside: |
const $MAP = Object.create(null)
...
paths:
include:
- '/src/providers/*.ts'
- '/src/parser.ts'

View file

@ -1,5 +1,117 @@
# Changelog
## Unreleased
### Added
- **`codeburn report --from/--to`.** Filter sessions to an exact `YYYY-MM-DD` date range (local time). Either flag alone is valid: `--from` alone runs from the given date through end-of-today, `--to` alone runs from the earliest data through the given date. Inverted ranges or malformed dates exit with a clear error. In the TUI, pressing `1`-`5` still switches to the predefined periods. Credit: @lfl1337 (PR #80).
- **`avgCostPerSession` in reports.** JSON `projects[]` entries gain an `avgCostPerSession` field and `export -f csv` adds an `Avg/Session (USD)` column to `projects.csv`. Column order in `projects.csv` is now `Project, Cost, Avg/Session, Share, API Calls, Sessions` -- scripts parsing by column position should read by header instead. Credit: @lfl1337 (PR #80).
### Security
- **Semgrep CI guard against prototype pollution regressions.** New `.github/workflows/ci.yml` runs a bracket-assign guard on `src/providers/` and `src/parser.ts` on every push to main and every PR. Blocks re-introducing `$MAP[$KEY] = $MAP[$KEY] ?? $INIT` patterns on `{}`-initialized maps. `categoryBreakdown` in `parser.ts` switched to `Object.create(null)` for consistency with its sibling breakdown maps. Credit: @lfl1337 (PR #78).
## 0.7.3 - 2026-04-18
### Changed
- **Dropped `better-sqlite3` in favor of Node's built-in `node:sqlite`.** Removes the deprecated `prebuild-install` transitive dependency that npm warned about on every install (issue #75, credit @primeminister). End-user install is now 40 packages down from 167 and shows zero deprecation notices. The experimental-SQLite warning Node 22/23 normally prints on module load is silenced for this specific warning; other warnings pass through unchanged.
- **Minimum Node version raised to 22.** Node 20 reached EOL on 2026-04-30; `node:sqlite` lives in 22+. Users on older Node get a clear upgrade message when a SQLite-backed provider (Cursor, OpenCode) is loaded.
## 0.7.2 - 2026-04-17
### Added
- **Native macOS menubar app.** Swift + SwiftUI app under `mac/` replaces the SwiftBar plugin. Agent tabs, Today/7/30/Month/All period switcher, Trend/Forecast/Pulse/Stats/Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, instant currency switching, live 60s refresh.
- **`codeburn menubar`.** One-command install: downloads the latest `.app` from GitHub Releases, strips Gatekeeper quarantine, drops it into `~/Applications`, and launches it. `--force` reinstalls in place.
- **`status --format menubar-json`.** Structured payload consumed by the native menubar app. Current-period totals, per-activity and per-model breakdowns, provider costs, optimize findings, and 365-day history.
- **Release workflow.** `.github/workflows/release-menubar.yml` builds a universal `.app` bundle and zip on `mac-v*` tag push.
### Changed
- **`codeburn export -f csv`** now writes a folder of one-table-per-file CSVs (`summary`, `daily`, `activity`, `models`, `projects`, `sessions`, `tools`, `shell-commands`) plus a `README.txt` index. Each file opens cleanly as a single table in any spreadsheet.
- **`codeburn export -f json`** upgraded to schema `codeburn.export.v2` with currency metadata.
### Fixed
- **`codeburn status` terminal Today/Month** now buckets by local date instead of UTC, so spend shows correctly during the window between local midnight and UTC midnight.
- **FX rate validation.** Frankfurter responses are checked to be finite and within `[0.0001, 1_000_000]` before they affect displayed costs.
### Removed
- **SwiftBar plugin.** `src/menubar.ts`, `codeburn install-menubar`, `codeburn uninstall-menubar`, and `status --format menubar` are gone. The native Swift app is the single menubar surface.
### Security
- **`codeburn export -o` guard.** Writes a `.codeburn-export` marker into every folder it creates and refuses to reuse non-marked directories or overwrite existing files, so a typo like `-o ~/.ssh/id_ed25519` cannot delete a sensitive file.
## 0.7.1 - 2026-04-17
### Security
- **External security audit closed.** 1 HIGH, 2 MEDIUM, and 1 LOW finding fixed. Threat model: a compromised third-party AI CLI with write access to `~/.claude/projects/` dropping malicious session JSONL.
- **Prototype pollution blocked.** Breakdown maps in `parser.ts` (model, tool, MCP, bash) now use `Object.create(null)` so attacker-controlled keys like `__proto__` create own properties instead of mutating `Object.prototype`. Credit: @lfl1337 (PR #67).
- **Bounded session-file reads.** New `src/fs-utils.ts` helper caps reads at 128 MB and switches to stream-based parsing above 8 MB. Applied to 13 reachable read sites across parser, Codex, Copilot, Pi, context-budget, and optimize. Credit: @lfl1337 (PR #67).
- **Menubar label sanitizer.** SwiftBar directive-separator (`|`) and ANSI escape injection via crafted model or category names is now prevented by an allowlist (`[A-Za-z0-9 ._/-]`) plus 14-character truncation. Credit: @lfl1337 (PR #67).
### Added
- **`--verbose` flag.** Global CLI option that prints warnings to stderr on skipped (oversize) or failed session-file reads. Silent by default. Credit: @lfl1337 (PR #67).
- **11 new security tests.** `tests/security/prototype-pollution.test.ts`, `tests/security/menubar-injection.test.ts`, `tests/fs-utils.test.ts`. Total suite: 209 tests.
## 0.7.0 - 2026-04-16
### Added
- **`codeburn optimize` command.** Scans your sessions and your `~/.claude/`
setup for 11 common waste patterns and hands back exact copy-paste fixes.
Detection-only, never writes to user files. Supports `--period` (today,
week, 30days, month, all) and `--provider` (all, claude, codex, cursor).
- **Setup health grade (A-F).** Urgency-weighted rollup of all findings, with
impact scored against observed waste so the most expensive issues rank
first. High findings penalise more, medium less, low least.
- **Trend tracking.** Repeat runs classify each finding as new, improving,
or resolved against a 48-hour recent window, so fixed issues disappear
instead of lingering as noise.
- **11 detectors:** files Claude re-reads across sessions, low Read:Edit
ratio, projects missing `.claudeignore`, uncapped `BASH_MAX_OUTPUT_LENGTH`,
unused MCP servers, ghost agents, ghost skills, ghost slash commands,
bloated `CLAUDE.md` files (with `@-import` expansion counted), cache
creation overhead, and junk directory reads.
- **Copy-paste fixes.** Each finding comes with a ready-to-paste remedy: a
`CLAUDE.md` line, a `.claudeignore` template, an environment variable, or
a `mv` command to archive unused items.
- **In-TUI optimize view.** Press `o` in the dashboard when the status bar
shows a finding count, `b` to return. Same engine as the standalone
command, scoped to the current period and provider.
- **Per-project context budget column.** By Project panel now shows the
estimated per-session context overhead for each project (system prompt +
tools + `CLAUDE.md` + skills).
- **34 filesystem-mocking tests.** Tmpdir fixtures with `os.homedir` mocked
via `vi.mock` cover the detector surface end to end. Total suite: 198
tests across 13 files.
### Performance
- **mtime pre-filter + parallel reads + 60s result cache** cut a cold scan
from 12-17s to 6-7s on a 10k-session history.
## 0.6.1 - 2026-04-16
### Added
- **JSON output on `report`, `today`, `month`.** `--format json` writes the
full dashboard (overview, daily, projects, models, activities, tools, MCP
servers, shell commands, top sessions) to stdout. Contributed by @mallek.
- **Project filters.** `--project <name>` and `--exclude <name>` on all
commands (`report`, `today`, `month`, `status`, `export`). Case-insensitive
substring match against project name and path. Both flags are repeatable.
Contributed by @mallek.
- **claude-opus-4-7 model mapping and pricing.** Displays as `Opus 4.7` with
the same Opus pricing as 4.6 and a 6x fast multiplier. Contributed by @mallek.
- **Unit tests for `filterProjectsByName`** covering include/exclude
semantics, case-insensitivity, path matching, and input immutability.
### Fixed
- **Top Sessions panel truncating the calls column.** Row width filled the
full panel width without leaving room for the border and padding, so Ink
truncated the last 4 characters -- landing exactly on the calls column and
producing rows like `$182.58 ...` with no value.
- **SwiftBar custom plugin directory** now honoured when installing the
menubar widget. Reads the configured path from SwiftBar's defaults before
falling back to the standard location. Contributed by @Galeas.
- **`status --format menubar` per-provider today totals** now respect
`--project`/`--exclude`. The main period blocks already did, the provider
breakdown loop was the one spot that bypassed the filter.
## 0.6.0 - 2026-04-16
### Added

View file

@ -75,3 +75,10 @@ gh pr comment <number> --body "Merged, thanks!"
- NEVER include personal names or usernames in commits
- Small, focused commits. One feature per commit
- Test locally before every commit
### Public-facing language (commits, PRs, release notes, README)
- Commits and release notes are public. Write like you'd publish them.
- NEVER use words like "steal", "stealing", "copy", "rip off", "inspired by" in commit messages
- Describe what the code does, not where ideas came from
- If you must credit prior art, do it in code comments or docs, not commit messages
- No snark, no filler, no self-deprecation. Treat each commit as a product statement

117
README.md
View file

@ -1,5 +1,5 @@
<p align="center">
<img src="https://cdn.jsdelivr.net/gh/AgentSeal/codeburn@main/assets/logo.png" alt="CodeBurn" width="120" />
<img src="https://cdn.jsdelivr.net/gh/getagentseal/codeburn@main/assets/logo.png" alt="CodeBurn" width="120" />
</p>
<h1 align="center">CodeBurn</h1>
@ -11,15 +11,15 @@
<a href="https://www.npmjs.com/package/codeburn"><img src="https://img.shields.io/npm/dt/codeburn.svg" alt="total downloads" /></a>
<a href="https://www.npmjs.com/package/codeburn"><img src="https://img.shields.io/npm/dm/codeburn.svg" alt="monthly downloads" /></a>
<a href="https://bundlephobia.com/package/codeburn"><img src="https://img.shields.io/bundlephobia/min/codeburn" alt="install size" /></a>
<a href="https://github.com/agentseal/codeburn/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/codeburn.svg" alt="license" /></a>
<a href="https://github.com/agentseal/codeburn"><img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg" alt="node version" /></a>
<a href="https://github.com/getagentseal/codeburn/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/codeburn.svg" alt="license" /></a>
<a href="https://github.com/getagentseal/codeburn"><img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg" alt="node version" /></a>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/AgentSeal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
</p>
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, **[OMP](https://github.com/can1357/oh-my-pi)** (Oh My Pi), and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, **[OMP](https://github.com/can1357/oh-my-pi)** (Oh My Pi), and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export.
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
@ -44,20 +44,44 @@ npx codeburn
## Usage
```bash
codeburn # interactive dashboard (default: 7 days)
codeburn today # today's usage
codeburn month # this month's usage
codeburn report -p 30days # rolling 30-day window
codeburn report -p all # every recorded session
codeburn report --refresh 60 # auto-refresh every 60 seconds
codeburn status # compact one-liner (today + month)
codeburn # interactive dashboard (default: 7 days)
codeburn today # today's usage
codeburn month # this month's usage
codeburn report -p 30days # rolling 30-day window
codeburn report -p all # every recorded session
codeburn report --from 2026-04-01 --to 2026-04-10 # exact date range
codeburn report --format json # full dashboard data as JSON
codeburn report --refresh 60 # auto-refresh every 60 seconds
codeburn status # compact one-liner (today + month)
codeburn status --format json
codeburn export # CSV with today, 7 days, 30 days
codeburn export -f json # JSON export
codeburn export # CSV with today, 7 days, 30 days
codeburn export -f json # JSON export
codeburn optimize # find waste, get copy-paste fixes
codeburn optimize -p week # scope the scan to last 7 days
```
Arrow keys switch between Today / 7 Days / 30 Days / Month / All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
### JSON output
`report`, `today`, and `month` support `--format json` to output the full dashboard data as structured JSON to stdout:
```bash
codeburn report --format json # 7-day JSON report
codeburn today --format json # today's data as JSON
codeburn month --format json # this month as JSON
codeburn report -p 30days --format json # 30-day window
```
The JSON includes all dashboard panels: overview (cost, calls, sessions, cache hit %), daily breakdown, projects (with `avgCostPerSession`), models with token counts, activities with one-shot rates, core tools, MCP servers, and shell commands. Pipe to `jq` for filtering:
```bash
codeburn report --format json | jq '.projects'
codeburn today --format json | jq '.overview.cost'
```
For the lighter `status --format json` (today + month totals only) or file-based exports (`export -f json`), see above.
## Providers
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
@ -77,6 +101,33 @@ codeburn export --provider claude # export Claude data only
The `--provider` flag works on all commands: `report`, `today`, `month`, `status`, `export`.
### Project filtering
Filter results by project name (case-insensitive substring match). Both flags are repeatable:
```bash
codeburn report --project myapp # show only projects matching "myapp"
codeburn report --exclude myapp # show everything except "myapp"
codeburn report --exclude myapp --exclude tests # exclude multiple projects
codeburn month --project api --project web # include multiple projects
codeburn export --project inventory # export only "inventory" project data
```
The `--project` and `--exclude` flags work on all commands and can be combined with `--provider`.
### Date range filtering
Beyond the preset periods, specify an exact window with `--from` and `--to` (`YYYY-MM-DD`, local time):
```bash
codeburn report --from 2026-04-01 --to 2026-04-10 # explicit window
codeburn report --from 2026-04-01 # this date through today
codeburn report --to 2026-04-10 # earliest data through this date
codeburn report --from 2026-04-01 --to 2026-04-10 --format json
```
Either flag alone is valid. Inverted or malformed dates exit with a clear error. In the TUI, the custom range sets the initial load only -- pressing `1`-`5` switches back to predefined periods.
### Supported providers
| Provider | Data location | Status |
@ -137,14 +188,13 @@ The menu bar widget includes a currency picker with 17 common currencies. For an
## Menu Bar
<img src="https://cdn.jsdelivr.net/gh/AgentSeal/codeburn@main/assets/menubar.png" alt="CodeBurn SwiftBar menu bar widget" width="260" />
<img src="https://cdn.jsdelivr.net/gh/getagentseal/codeburn@main/assets/menubar-0.7.2.png" alt="CodeBurn macOS menubar app" width="420" />
```bash
codeburn install-menubar # install SwiftBar/xbar plugin
codeburn uninstall-menubar # remove it
npx codeburn menubar
```
Requires [SwiftBar](https://github.com/swiftbar/SwiftBar) (`brew install --cask swiftbar`). Shows today's cost in the menu bar with a flame icon. Dropdown shows activity breakdown, model costs, token stats, per-provider cost breakdown, and a currency picker. Refreshes every 5 minutes.
One command: downloads the latest `.app`, installs into `~/Applications`, and launches it. Re-run with `--force` to reinstall. Native Swift + SwiftUI app lives in `mac/` (see `mac/README.md` for build details). Shows today's cost with a flame icon, opens a popover with agent tabs, period switcher (Today / 7 Days / 30 Days / Month / All), Trend / Forecast / Pulse / Stats / Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes live via FSEvents plus a 60-second poll.
## What it tracks
@ -189,6 +239,35 @@ CodeBurn surfaces the data, you read the story. A few patterns worth knowing:
These are starting points, not verdicts. A 60% cache hit on a single experimental session is fine. A persistent 60% cache hit across weeks of work is a config issue.
## Optimize
Once you know what to look for, `codeburn optimize` scans your sessions and your `~/.claude/` setup for the most common waste patterns and hands back exact, copy-paste fixes. It never writes to your files.
<p align="center">
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/optimize.jpg" alt="CodeBurn optimize output" width="720" />
</p>
```bash
codeburn optimize # scan the last 30 days
codeburn optimize -p today # today only
codeburn optimize -p week # last 7 days
codeburn optimize --provider claude # restrict to one provider
```
**What it detects**
- Files Claude re-reads across sessions (same content, same context, over and over)
- Low Read:Edit ratio (editing without reading leads to retries and wasted tokens)
- Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise)
- Unused MCP servers still paying their tool-schema overhead every session
- Ghost agents, skills, and slash commands defined in `~/.claude/` but never invoked
- Bloated `CLAUDE.md` files (with `@-import` expansion counted)
- Cache creation overhead and junk directory reads
Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A-F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window.
You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return.
## How it reads data
**Claude Code** stores session transcripts as JSONL at `~/.claude/projects/<sanitized-path>/<session-id>.jsonl`. Each assistant entry contains model name, token usage (input, output, cache read, cache write), tool_use blocks, and timestamps.
@ -221,7 +300,7 @@ src/
classifier.ts 13-category task classifier
types.ts Type definitions
format.ts Text rendering (status bar)
menubar.ts SwiftBar plugin generator
menubar-json.ts Payload builder consumed by the native macOS menubar app in mac/
export.ts CSV/JSON multi-period export
config.ts Config file management (~/.config/codeburn/)
currency.ts Currency conversion, exchange rates, Intl formatting

BIN
assets/menubar-0.7.2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

BIN
assets/optimize.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

6
mac/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.build/
.swiftpm/
Package.resolved
*.xcodeproj/
*.xcworkspace/
DerivedData/

26
mac/Package.swift Normal file
View file

@ -0,0 +1,26 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "CodeBurnMenubar",
platforms: [
.macOS(.v14)
],
products: [
.executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"])
],
targets: [
.executableTarget(
name: "CodeBurnMenubar",
path: "Sources/CodeBurnMenubar",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency")
]
),
.testTarget(
name: "CodeBurnMenubarTests",
dependencies: ["CodeBurnMenubar"],
path: "Tests/CodeBurnMenubarTests"
)
]
)

88
mac/README.md Normal file
View file

@ -0,0 +1,88 @@
# CodeBurn Menubar (macOS)
Native Swift + SwiftUI menubar app. The codeburn menubar surface.
## Requirements
- macOS 14+ (Sonoma)
- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
- `codeburn` CLI installed globally (`npm install -g codeburn`) or available at a path you pass via `CODEBURN_BIN`
## Install (end users)
One command:
```bash
npx codeburn menubar
```
That's it. The command downloads the latest `.app` from GitHub Releases, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise.
If you already have the CLI installed globally (`npm install -g codeburn`), `codeburn menubar` works the same way.
### Build from source
For contributors running a local build instead of the packaged release:
```bash
npm install -g codeburn # CLI the app shells out to for data
git clone https://github.com/getagentseal/codeburn.git
cd codeburn/mac
swift build -c release
.build/release/CodeBurnMenubar # launch
```
## Build & run (dev against a local CLI checkout)
```bash
cd mac
swift build
# Point the app at your dev CLI build instead of the globally installed `codeburn`:
npm --prefix .. run build
CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run
```
The app registers itself as a menubar accessory (`LSUIElement = true` at runtime). No Dock icon.
## Data source
On launch and every 60 seconds thereafter, the app spawns `codeburn status --format menubar-json --no-optimize` directly (argv, no shell) via `CodeburnCLI.makeProcess` and decodes the JSON into `MenubarPayload`. The manual refresh button in the footer invokes the same command without `--no-optimize`, which includes optimize findings but takes longer.
Override the binary via the `CODEBURN_BIN` environment variable (default: `codeburn` on PATH). The value is validated against a strict allowlist (alphanumerics plus `._/-` space) before use, so a malicious env var can't inject shell commands.
## Project layout
```
mac/
├── Package.swift SwiftPM manifest
├── Sources/CodeBurnMenubar/
│ ├── CodeBurnApp.swift @main + MenuBarExtra scene
│ ├── AppStore.swift @Observable store + enums
│ ├── Data/MenubarPayload.swift Codable payload types + placeholder
│ ├── Theme/Theme.swift Design tokens (warm terracotta palette)
│ └── Views/MenuBarContent.swift Popover layout + footer action bar
└── README.md This file
```
## Status
Live data wired. Next iterations:
1. FSEvents watch for `~/.claude/projects/` changes (debounced refresh on real edits)
2. Persistent disk cache for optimize findings so the default refresh can include them without the 30-second penalty
3. Currency metadata in the JSON payload + Swift-side formatting
4. Sparkle auto-update
5. DMG packaging + Homebrew Cask tap
## Design tokens
Sourced from `~/codeburn-menubar-mac-swiftui.html`. Warm terracotta-ember palette:
- Accent (light): `#C9521D`
- Accent (dark): `#E8774A`
- Ember deep: `#8B3E13`
- Ember glow: `#F0A070`
- Surface (light): `#FAF7F3`
- Surface (dark): `#1C1816`
SF Mono for currency values; SF Pro Rounded for hero.

103
mac/Scripts/package-app.sh Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Builds a universal CodeBurnMenubar.app bundle from the SwiftPM target and drops a
# distributable zip alongside. Used by the GitHub release workflow; also runnable locally.
#
# Usage:
# mac/Scripts/package-app.sh [<version>]
# Defaults to `dev` if no version is given.
set -euo pipefail
VERSION="${1:-dev}"
BUNDLE_NAME="CodeBurnMenubar.app"
BUNDLE_ID="org.agentseal.codeburn-menubar"
EXECUTABLE_NAME="CodeBurnMenubar"
MIN_MACOS="14.0"
repo_root() {
git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/../.." && pwd)
}
ROOT=$(repo_root)
MAC_DIR="${ROOT}/mac"
DIST_DIR="${MAC_DIR}/.build/dist"
cd "${MAC_DIR}"
echo "▸ Cleaning previous dist..."
rm -rf "${DIST_DIR}"
mkdir -p "${DIST_DIR}"
echo "▸ Building universal binary (arm64 + x86_64)..."
swift build -c release --arch arm64 --arch x86_64
BIN_PATH=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)
BUILT_BINARY="${BIN_PATH}/${EXECUTABLE_NAME}"
if [[ ! -x "${BUILT_BINARY}" ]]; then
echo "Binary not found at ${BUILT_BINARY}" >&2
exit 1
fi
echo "▸ Assembling ${BUNDLE_NAME}..."
BUNDLE="${DIST_DIR}/${BUNDLE_NAME}"
mkdir -p "${BUNDLE}/Contents/MacOS"
mkdir -p "${BUNDLE}/Contents/Resources"
cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}"
cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>CodeBurn Menubar</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_ID}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<key>LSMinimumSystemVersion</key>
<string>${MIN_MACOS}</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© AgentSeal</string>
</dict>
</plist>
PLIST
cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG'
APPL????
PKG
# Ad-hoc sign so macOS treats the bundle as internally consistent. This satisfies the
# minimum bundle-validity checks on macOS 14+ and prevents a class of Gatekeeper edge
# cases on managed Macs. A Developer ID signature (separate setup) would additionally
# surface the publisher name in Finder; not required here.
echo "▸ Ad-hoc signing..."
codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"
ZIP_NAME="CodeBurnMenubar-${VERSION}.zip"
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
echo "▸ Packaging ${ZIP_NAME}..."
(cd "${DIST_DIR}" && /usr/bin/ditto -c -k --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}")
echo ""
echo "✓ Built ${ZIP_PATH}"
ls -la "${DIST_DIR}"

View file

@ -0,0 +1,311 @@
import Foundation
import Observation
private let cacheTTLSeconds: TimeInterval = 300
struct CachedPayload {
let payload: MenubarPayload
let fetchedAt: Date
var isFresh: Bool { Date().timeIntervalSince(fetchedAt) < cacheTTLSeconds }
}
struct PayloadCacheKey: Hashable {
let period: Period
let provider: ProviderFilter
}
@MainActor
@Observable
final class AppStore {
var selectedProvider: ProviderFilter = .all
var selectedPeriod: Period = .today
var selectedInsight: InsightMode = .trend
var currency: String = "USD"
var isLoading: Bool = false
var lastError: String?
var subscription: SubscriptionUsage?
var subscriptionError: String?
var subscriptionLoadState: SubscriptionLoadState = .idle
var capacityEstimates: [String: CapacityEstimate] = [:]
private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var currentKey: PayloadCacheKey {
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
}
var payload: MenubarPayload {
cache[currentKey]?.payload ?? .empty
}
/// Today (across all providers) is pinned for the always-visible menubar icon, independent of
/// the popover's selected period or provider.
var todayPayload: MenubarPayload? {
cache[PayloadCacheKey(period: .today, provider: .all)]?.payload
}
var hasCachedData: Bool {
cache[currentKey] != nil
}
var findingsCount: Int {
payload.optimize.findingCount
}
/// Switch to a period. Uses cached payload if fresh; otherwise fetches.
func switchTo(period: Period) async {
selectedPeriod = period
if let cached = cache[currentKey], cached.isFresh { return }
await refresh(includeOptimize: true)
}
/// Switch to a provider filter. Uses cached payload if fresh; otherwise fetches.
func switchTo(provider: ProviderFilter) async {
selectedProvider = provider
if let cached = cache[currentKey], cached.isFresh { return }
await refresh(includeOptimize: true)
}
private var inFlightKeys: Set<PayloadCacheKey> = []
/// Refresh the currently selected (period, provider) combination. Guards against concurrent
/// fetches for the same key so a slow initial request can't overwrite a newer one that
/// finished first (which would show stale numbers the user has already moved past).
func refresh(includeOptimize: Bool) async {
let key = currentKey
guard !inFlightKeys.contains(key) else { return }
inFlightKeys.insert(key)
isLoading = true
defer {
inFlightKeys.remove(key)
isLoading = false
}
do {
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
lastError = nil
} catch {
lastError = String(describing: error)
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
}
}
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
/// Always uses the .all provider since the menubar badge shows total spend.
func refreshQuietly(period: Period) async {
do {
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: true)
cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date())
} catch {
NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)")
}
}
/// Fetch Claude subscription usage. Sets subscription = nil on missing creds (API users / unauthenticated).
/// Triggered lazily when the user opens the Plan pill, so the Keychain prompt only fires on intent.
func refreshSubscription() async {
subscriptionLoadState = .loading
do {
let usage = try await SubscriptionClient.fetch()
subscription = usage
subscriptionError = nil
subscriptionLoadState = .loaded
await captureSnapshots(for: usage)
} catch SubscriptionError.noCredentials {
subscription = nil
subscriptionError = nil
subscriptionLoadState = .noCredentials
} catch {
subscription = nil
subscriptionError = String(describing: error)
subscriptionLoadState = .failed
NSLog("CodeBurn: subscription fetch failed: \(error)")
}
}
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
/// when the current window has just reset and projection from current data isn't meaningful.
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
/// which the CapacityEstimator uses to derive the absolute token capacity per tier.
private func captureSnapshots(for usage: SubscriptionUsage) async {
let now = Date()
let history = payload.history.daily
let captures: [(key: String, percent: Double?, resetsAt: Date?, effective: Double?)] = [
("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, nil),
("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt,
effectiveTokensInLast7Days(history: history, asOf: now)),
("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, nil),
("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, nil),
]
for capture in captures {
guard let percent = capture.percent, let resetsAt = capture.resetsAt else { continue }
await SubscriptionSnapshotStore.record(SubscriptionSnapshot(
windowKey: capture.key,
percent: percent,
resetsAt: resetsAt,
capturedAt: now,
effectiveTokens: capture.effective
))
}
await refreshCapacityEstimates()
}
/// Sum effective tokens (input + 5*output + cache_creation + 0.1*cache_read) across the
/// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired
/// with the API-reported percent for capacity estimation.
private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double {
let cutoff = ISO8601DateFormatter().string(from: now.addingTimeInterval(-7 * 86400)).prefix(10)
return history
.filter { $0.date >= cutoff }
.reduce(0.0) { $0 + $1.effectiveTokens }
}
/// Run CapacityEstimator over each window's accumulated snapshots. Only snapshots with a
/// non-nil effectiveTokens contribute. Result lives in capacityEstimates dict for UI gating.
private func refreshCapacityEstimates() async {
var next: [String: CapacityEstimate] = [:]
for key in ["seven_day", "seven_day_opus", "seven_day_sonnet"] {
let snaps = await SubscriptionSnapshotStore.snapshots(for: key)
let capacitySnaps = snaps.compactMap { s -> CapacitySnapshot? in
guard let effective = s.effectiveTokens, effective > 0 else { return nil }
return CapacitySnapshot(percent: s.percent, effectiveTokens: effective, capturedAt: s.capturedAt)
}
if let estimate = CapacityEstimator.estimate(capacitySnaps) {
next[key] = estimate
}
}
capacityEstimates = next
}
}
enum SupportedCurrency: String, CaseIterable, Identifiable {
case USD, GBP, EUR, AUD, CAD, NZD, JPY, CHF, INR, BRL, SEK, SGD, HKD, KRW, MXN, ZAR, DKK
var id: String { rawValue }
var displayName: String {
switch self {
case .USD: "US Dollar"
case .GBP: "British Pound"
case .EUR: "Euro"
case .AUD: "Australian Dollar"
case .CAD: "Canadian Dollar"
case .NZD: "New Zealand Dollar"
case .JPY: "Japanese Yen"
case .CHF: "Swiss Franc"
case .INR: "Indian Rupee"
case .BRL: "Brazilian Real"
case .SEK: "Swedish Krona"
case .SGD: "Singapore Dollar"
case .HKD: "Hong Kong Dollar"
case .KRW: "South Korean Won"
case .MXN: "Mexican Peso"
case .ZAR: "South African Rand"
case .DKK: "Danish Krone"
}
}
}
enum ProviderFilter: String, CaseIterable, Identifiable {
case all = "All"
case claude = "Claude"
case codex = "Codex"
case cursor = "Cursor"
case copilot = "Copilot"
case opencode = "OpenCode"
case pi = "Pi"
var id: String { rawValue }
/// Maps to the CLI's `--provider` argument values.
var cliArg: String {
switch self {
case .all: "all"
case .claude: "claude"
case .codex: "codex"
case .cursor: "cursor"
case .copilot: "copilot"
case .opencode: "opencode"
case .pi: "pi"
}
}
}
enum SubscriptionLoadState: Sendable, Equatable {
case idle // never tried, awaiting user intent
case loading // fetch in progress
case loaded // success; subscription is populated
case noCredentials // tried; user has no Claude OAuth (API user / not logged in)
case failed // tried; error occurred
}
enum InsightMode: String, CaseIterable, Identifiable {
case plan = "Plan"
case trend = "Trend"
case forecast = "Forecast"
case pulse = "Pulse"
case stats = "Stats"
var id: String { rawValue }
}
enum Period: String, CaseIterable, Identifiable {
case today = "Today"
case sevenDays = "7 Days"
case thirtyDays = "30 Days"
case month = "Month"
case all = "All"
var id: String { rawValue }
/// Maps to the CLI's `--period` argument values.
var cliArg: String {
switch self {
case .today: "today"
case .sevenDays: "week"
case .thirtyDays: "30days"
case .month: "month"
case .all: "all"
}
}
}
/// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values
/// are formatted dozens of times per popover refresh. These shared instances avoid thousands of
/// allocations per frame while SwiftUI's Observation framework still triggers redraws when
/// CurrencyState.shared mutates.
private let groupedDecimalFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
f.groupingSeparator = ","
f.decimalSeparator = "."
f.maximumFractionDigits = 2
f.minimumFractionDigits = 2
return f
}()
private let thousandsFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
f.groupingSeparator = ","
return f
}()
extension Double {
func asCurrency() -> String {
let state = CurrencyState.shared
let converted = self * state.rate
return state.symbol + (groupedDecimalFormatter.string(from: NSNumber(value: converted)) ?? "\(converted)")
}
func asCompactCurrency() -> String {
let state = CurrencyState.shared
return String(format: "\(state.symbol)%.2f", self * state.rate)
}
}
extension Int {
func asThousandsSeparated() -> String {
thousandsFormatter.string(from: NSNumber(value: self)) ?? "\(self)"
}
}

View file

@ -0,0 +1,185 @@
import SwiftUI
import AppKit
import Observation
private let refreshIntervalSeconds: UInt64 = 60
private let nanosPerSecond: UInt64 = 1_000_000_000
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
/// Fixed so the popover's anchor point doesn't shift each time today's cost changes.
private let statusItemFixedWidth: CGFloat = 130
private let popoverWidth: CGFloat = 360
private let popoverHeight: CGFloat = 660
private let menubarTitleFontSize: CGFloat = 13
@main
struct CodeBurnApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
// SwiftUI App needs at least one scene. Settings is invisible by default.
Settings {
EmptyView()
}
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
private let store = AppStore()
private var refreshTask: Task<Void, Never>?
func applicationDidFinishLaunching(_ notification: Notification) {
// Menubar accessory -- no Dock icon, no app switcher entry.
NSApp.setActivationPolicy(.accessory)
restorePersistedCurrency()
setupStatusItem()
setupPopover()
observeStore()
startRefreshLoop()
// Subscription is fetched lazily when the user opens the Plan pill, so the macOS
// Keychain prompt never fires until the user explicitly asks for it.
}
/// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where
/// the user left off. Rate is resolved from the on-disk FX cache if present, otherwise
/// fetched live in the background.
private func restorePersistedCurrency() {
guard let code = CLICurrencyConfig.loadCode(), code != "USD" else { return }
let symbol = CurrencyState.symbolForCode(code)
store.currency = code
Task {
let cached = await FXRateCache.shared.cachedRate(for: code)
await MainActor.run {
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
}
let fresh = await FXRateCache.shared.rate(for: code)
if let fresh, fresh != cached {
await MainActor.run {
CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
}
}
}
}
func applicationWillTerminate(_ notification: Notification) {
refreshTask?.cancel()
}
private func startRefreshLoop() {
refreshTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
// Always keep the (today, all) payload warm. The menubar title and the
// agent tab strip both read from it, so it has to refresh every cycle
// regardless of whether the user is currently viewing Today or a
// different period / provider.
await self.store.refreshQuietly(period: .today)
// Refresh the currently-viewed payload. Optimize is fast (~1s warm-cache)
// so include findings on every refresh.
await self.store.refresh(includeOptimize: true)
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
}
}
}
private func observeStore() {
withObservationTracking {
_ = store.payload
_ = store.todayPayload
} onChange: { [weak self] in
Task { @MainActor in
self?.refreshStatusButton()
self?.observeStore()
}
}
}
// MARK: - Status Item
private func setupStatusItem() {
// Fixed width so the popover anchor (and thus popover position) doesn't shift
// every time today's cost or findings badge changes.
statusItem = NSStatusBar.system.statusItem(withLength: statusItemFixedWidth)
guard let button = statusItem.button else { return }
button.target = self
button.action = #selector(handleButtonClick(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
refreshStatusButton()
}
/// Composes the menubar title as a single attributed string with the flame as an inline
/// NSTextAttachment. NSStatusItem's separate `image` + `attributedTitle` path leaves a
/// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge
/// of the status item, the title starts at its own baseline), so we inline both so they
/// flow as one typographic unit with a single, controllable gap.
private func refreshStatusButton() {
guard let button = statusItem.button else { return }
// Clear any previously-set image so the attachment is the only glyph rendered.
button.image = nil
button.imagePosition = .noImage
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium)
let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
.withSymbolConfiguration(flameConfig)
flame?.isTemplate = true
let attachment = NSTextAttachment()
attachment.image = flame
if let size = flame?.size {
// Nudge the image down ~2pt so its visual centre sits on the text baseline mid-line
// rather than riding high. Exact value tuned against SF Pro Display 13pt.
attachment.bounds = CGRect(x: 0, y: -2, width: size.width, height: size.height)
}
let hasPayload = store.todayPayload != nil
let valueText = " " + (store.todayPayload?.current.cost.asCompactCurrency() ?? "$—")
let color: NSColor = hasPayload ? .labelColor : .secondaryLabelColor
let composed = NSMutableAttributedString()
composed.append(NSAttributedString(attachment: attachment))
composed.append(NSAttributedString(
string: valueText,
attributes: [.font: font, .foregroundColor: color]
))
button.attributedTitle = composed
}
// MARK: - Popover
private func setupPopover() {
popover = NSPopover()
popover.contentSize = NSSize(width: popoverWidth, height: popoverHeight)
popover.behavior = .transient // auto-close only on explicit outside click
popover.animates = true
popover.delegate = self
let content = MenuBarContent()
.environment(store)
.frame(width: popoverWidth)
popover.contentViewController = NSHostingController(rootView: content)
}
@objc private func handleButtonClick(_ sender: AnyObject?) {
guard let button = statusItem.button else { return }
if popover.isShown {
popover.performClose(sender)
} else {
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
// MARK: - NSPopoverDelegate
func popoverShouldDetach(_ popover: NSPopover) -> Bool {
false
}
}

View file

@ -0,0 +1,209 @@
import Foundation
import Observation
private let fxCacheTTLSeconds: TimeInterval = 24 * 3600
private let frankfurterBaseURL = "https://api.frankfurter.app/latest?from=USD&to="
/// Defensive bounds on any fetched FX rate. Real-world USDX rates sit in [0.0001, 200000]
/// for every ISO 4217 pair; anything outside is either a parser bug or a MITM poisoning
/// attempt. We clamp hard so UI can't render NaN, negative, or astronomical numbers.
private let minValidFXRate: Double = 0.0001
private let maxValidFXRate: Double = 1_000_000
private let fxFetchTimeoutSeconds: TimeInterval = 10
@Observable
final class CurrencyState: @unchecked Sendable {
static let shared = CurrencyState()
var code: String = "USD"
var rate: Double = 1.0
var symbol: String = "$"
private init() {}
/// Applies a new currency context. Callers must invoke on the main actor so @Observable
/// view updates run on the UI thread. Rejects non-finite or out-of-band rates so a
/// poisoned Frankfurter response can't corrupt displayed costs.
func apply(code: String, rate: Double?, symbol: String) {
self.code = code
self.symbol = symbol
if let r = rate, r.isFinite, r >= minValidFXRate, r <= maxValidFXRate {
self.rate = r
}
}
static func symbolForCode(_ code: String) -> String {
// Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the
// plain glyph form everyone recognises.
if let override = symbolOverrides[code] { return override }
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = code
formatter.locale = Locale(identifier: "en_\(code.prefix(2))")
return formatter.currencySymbol ?? code
}
private static let symbolOverrides: [String: String] = [
"USD": "$",
"CAD": "$",
"AUD": "$",
"NZD": "$",
"HKD": "$",
"SGD": "$",
"MXN": "$",
"EUR": "\u{20AC}",
"GBP": "\u{00A3}",
"JPY": "\u{00A5}",
"CNY": "\u{00A5}",
"KRW": "\u{20A9}",
"INR": "\u{20B9}",
"BRL": "R$",
"CHF": "CHF",
"SEK": "kr",
"DKK": "kr",
"ZAR": "R"
]
}
actor FXRateCache {
static let shared = FXRateCache()
private struct Entry: Codable {
let rate: Double
let savedAt: TimeInterval
}
private var entries: [String: Entry] = [:]
private var loaded = false
private var cacheFilePath: String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
return base
.appendingPathComponent("codeburn-mac", isDirectory: true)
.appendingPathComponent("fx-rates.json")
.path
}
private func loadIfNeeded() {
guard !loaded else { return }
loaded = true
do {
let data = try SafeFile.read(from: cacheFilePath)
let decoded = try JSONDecoder().decode([String: Entry].self, from: data)
// Drop any persisted entries whose rate violates the sanity bounds -- covers an
// old cache that was written before the clamp was introduced.
entries = decoded.filter { _, entry in
entry.rate.isFinite && entry.rate >= minValidFXRate && entry.rate <= maxValidFXRate
}
} catch {
entries = [:]
}
}
private func persist() {
guard let data = try? JSONEncoder().encode(entries) else { return }
try? SafeFile.write(data, to: cacheFilePath)
}
/// Returns a cached rate regardless of freshness. Nil if never fetched.
func cachedRate(for code: String) -> Double? {
if code == "USD" { return 1.0 }
loadIfNeeded()
return entries[code]?.rate
}
/// Returns a fresh rate, fetching from Frankfurter when cache is stale or absent. Nil on
/// failure. The returned rate is always finite, positive, and within the sanity bounds.
func rate(for code: String) async -> Double? {
if code == "USD" { return 1.0 }
loadIfNeeded()
if let entry = entries[code],
Date().timeIntervalSince1970 - entry.savedAt < fxCacheTTLSeconds {
return entry.rate
}
guard let url = URL(string: "\(frankfurterBaseURL)\(code)") else { return entries[code]?.rate }
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = fxFetchTimeoutSeconds
config.tlsMinimumSupportedProtocolVersion = .TLSv12
let session = URLSession(configuration: config)
do {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
return entries[code]?.rate
}
struct Response: Decodable { let rates: [String: Double] }
let decoded = try JSONDecoder().decode(Response.self, from: data)
guard let fresh = decoded.rates[code],
fresh.isFinite, fresh >= minValidFXRate, fresh <= maxValidFXRate else {
NSLog("CodeBurn: discarding out-of-band FX rate for \(code)")
return entries[code]?.rate
}
entries[code] = Entry(rate: fresh, savedAt: Date().timeIntervalSince1970)
persist()
return fresh
} catch {
return entries[code]?.rate
}
}
}
/// Reads and writes the CLI's persisted currency config (~/.config/codeburn/config.json).
/// Uses an on-disk flock so a concurrent `codeburn currency ...` invocation from a terminal
/// can't race the menubar and silently drop each other's writes (TOCTOU on config.json).
enum CLICurrencyConfig {
private static var configDir: String {
(NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn")
}
private static var configPath: String {
(configDir as NSString).appendingPathComponent("config.json")
}
private static var lockPath: String {
(configDir as NSString).appendingPathComponent(".config.lock")
}
static func loadCode() -> String? {
guard
let data = try? SafeFile.read(from: configPath),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let currency = json["currency"] as? [String: Any],
let code = currency["code"] as? String
else {
return nil
}
return code.uppercased()
}
static func persist(code: String) {
do {
try SafeFile.withExclusiveLock(at: lockPath) {
var existing: [String: Any] = [:]
if let data = try? SafeFile.read(from: configPath),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
existing = parsed
}
if code == "USD" {
existing.removeValue(forKey: "currency")
} else {
existing["currency"] = [
"code": code,
"symbol": CurrencyState.symbolForCode(code)
]
}
guard let data = try? JSONSerialization.data(
withJSONObject: existing,
options: [.prettyPrinted, .sortedKeys]
) else {
return
}
try SafeFile.write(data, to: configPath, mode: 0o600)
}
} catch {
NSLog("CodeBurn: failed to persist currency config: \(error)")
}
}
}

View file

@ -0,0 +1,127 @@
import Foundation
public struct CapacitySnapshot: Sendable, Equatable {
public let percent: Double // 0..100, Anthropic-reported utilization
public let effectiveTokens: Double // weighted sum of input/output/cache tokens consumed at capture
public let capturedAt: Date
public init(percent: Double, effectiveTokens: Double, capturedAt: Date) {
self.percent = percent
self.effectiveTokens = effectiveTokens
self.capturedAt = capturedAt
}
}
public enum CapacityConfidence: String, Sendable {
case low, medium, solid
}
public struct CapacityEstimate: Sendable, Equatable {
public let capacity: Double // tokens equivalent to 100%
public let confidence: CapacityConfidence
public let sampleSize: Int // post-decorrelation count
public let nonLinearityWarning: Bool
public init(capacity: Double, confidence: CapacityConfidence, sampleSize: Int, nonLinearityWarning: Bool) {
self.capacity = capacity
self.confidence = confidence
self.sampleSize = sampleSize
self.nonLinearityWarning = nonLinearityWarning
}
}
public enum CapacityEstimator {
private static let minSampleSize = 5
private static let minPercentRange = 15.0
private static let recencyHalfLifeSeconds: Double = 30 * 86400
private static let solidR2 = 0.97
private static let mediumR2 = 0.85
private static let solidSampleThreshold = 15
private static let mediumSampleThreshold = 6
private static let nonLinearityRunLengthThreshold = 0.7
public static func estimate(_ snapshots: [CapacitySnapshot], asOf now: Date = Date()) -> CapacityEstimate? {
guard snapshots.count >= minSampleSize else { return nil }
let percents = snapshots.map(\.percent)
let range = (percents.max() ?? 0) - (percents.min() ?? 0)
guard range >= minPercentRange else { return nil }
let weighted = snapshots.map { snap -> (p: Double, t: Double, w: Double) in
let ageSeconds = now.timeIntervalSince(snap.capturedAt)
let weight = pow(0.5, max(0, ageSeconds) / recencyHalfLifeSeconds)
return (snap.percent, snap.effectiveTokens, weight)
}
// Weighted least squares through origin: minimize sum(w * (t - p * cap/100)^2)
// Solution: cap = 100 * sum(w * t * p) / sum(w * p * p)
let numerator = weighted.reduce(0.0) { $0 + $1.w * $1.t * $1.p }
let denominator = weighted.reduce(0.0) { $0 + $1.w * $1.p * $1.p }
guard denominator > 0 else { return nil }
let capacity = 100.0 * numerator / denominator
guard capacity > 0 else { return nil }
// Weighted R^2 against the through-origin fit.
let weightedTokenSum = weighted.reduce(0.0) { $0 + $1.w * $1.t }
let weightSum = weighted.reduce(0.0) { $0 + $1.w }
let weightedMeanT = weightedTokenSum / max(weightSum, .ulpOfOne)
let ssRes = weighted.reduce(0.0) { acc, s in
let predicted = s.p * capacity / 100
let diff = s.t - predicted
return acc + s.w * diff * diff
}
let ssTot = weighted.reduce(0.0) { acc, s in
let diff = s.t - weightedMeanT
return acc + s.w * diff * diff
}
let r2 = ssTot > 0 ? max(0.0, 1.0 - ssRes / ssTot) : 0.0
let n = snapshots.count
let confidence: CapacityConfidence = {
if n >= solidSampleThreshold && r2 >= solidR2 { return .solid }
if n >= mediumSampleThreshold && r2 >= mediumR2 { return .medium }
return .low
}()
let nonLinearityWarning = detectNonLinearity(snapshots: weighted, capacity: capacity)
return CapacityEstimate(
capacity: capacity,
confidence: confidence,
sampleSize: n,
nonLinearityWarning: nonLinearityWarning
)
}
/// Sign-test on residuals across the percent range. If residuals form a long monotonic run
/// (e.g. all-negative in low percents then all-positive at high), the relationship isn't linear.
private static func detectNonLinearity(
snapshots: [(p: Double, t: Double, w: Double)],
capacity: Double
) -> Bool {
let sorted = snapshots.sorted { $0.p < $1.p }
let signs = sorted.map { s -> Int in
let predicted = s.p * capacity / 100
let diff = s.t - predicted
if abs(diff) < .ulpOfOne { return 0 }
return diff > 0 ? 1 : -1
}.filter { $0 != 0 }
guard signs.count >= minSampleSize else { return false }
// Longest single-sign run length / total
var longestRun = 0
var currentRun = 0
var currentSign = 0
for s in signs {
if s == currentSign {
currentRun += 1
} else {
longestRun = max(longestRun, currentRun)
currentSign = s
currentRun = 1
}
}
longestRun = max(longestRun, currentRun)
let runFraction = Double(longestRun) / Double(signs.count)
return runFraction >= nonLinearityRunLengthThreshold
}
}

View file

@ -0,0 +1,107 @@
import Foundation
/// Upper bound on payload + stderr bytes read from the CLI. Real payloads top out near 500 KB
/// (365 days of history with dozens of models); anything larger is pathological and truncating
/// prevents unbounded memory growth. Hard timeout guards against a hung CLI keeping Process and
/// Pipe file descriptors pinned forever.
private let maxPayloadBytes = 20 * 1024 * 1024
private let maxStderrBytes = 256 * 1024
private let spawnTimeoutSeconds: UInt64 = 60
enum DataClientError: Error {
case spawn(String)
case nonZeroExit(code: Int32, stderr: String)
case decode(Error)
case timeout
case outputTooLarge
}
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore.
struct DataClient {
static func fetch(period: Period, provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
var subcommand = [
"status",
"--format", "menubar-json",
"--period", period.cliArg,
"--provider", provider.cliArg,
]
if !includeOptimize {
subcommand.append("--no-optimize")
}
let result = try await runCLI(subcommand: subcommand)
guard result.exitCode == 0 else {
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
}
do {
return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
} catch {
throw DataClientError.decode(error)
}
}
private struct ProcessResult {
let stdout: Data
let stderr: String
let exitCode: Int32
}
private static func runCLI(subcommand: [String]) async throws -> ProcessResult {
let process = CodeburnCLI.makeProcess(subcommand: subcommand)
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
do {
try process.run()
} catch {
throw DataClientError.spawn(error.localizedDescription)
}
// Drain both pipes concurrently so a large stderr can't deadlock stdout (the child
// blocks on write once the pipe buffer fills). `drain` also enforces a byte cap.
async let stdoutData = drain(outPipe.fileHandleForReading, limit: maxPayloadBytes)
async let stderrData = drain(errPipe.fileHandleForReading, limit: maxStderrBytes)
// Wall-clock timeout: if the CLI hangs (parser stuck, disk stall), kill it.
let timeoutTask = Task.detached(priority: .utility) {
try? await Task.sleep(nanoseconds: spawnTimeoutSeconds * 1_000_000_000)
if process.isRunning {
process.terminate()
}
}
defer { timeoutTask.cancel() }
let (out, err) = await (stdoutData, stderrData)
process.waitUntilExit()
if out.count >= maxPayloadBytes {
throw DataClientError.outputTooLarge
}
let stderrString = String(data: err, encoding: .utf8) ?? ""
return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus)
}
/// Pulls bytes off a pipe until EOF or `limit`. Intentionally uses `availableData`, which
/// returns empty on EOF -- no blocking once the child exits.
private static func drain(_ handle: FileHandle, limit: Int) async -> Data {
await Task.detached(priority: .utility) {
var buffer = Data()
while buffer.count < limit {
let chunk = handle.availableData
if chunk.isEmpty { break }
let remaining = limit - buffer.count
if chunk.count > remaining {
buffer.append(chunk.prefix(remaining))
break
}
buffer.append(chunk)
}
return buffer
}.value
}
}

View file

@ -0,0 +1,123 @@
import Foundation
/// Shape of `codeburn status --format menubar-json --period <period>`.
/// `current` is scoped to the requested period; the whole payload reflects that slice.
struct MenubarPayload: Codable, Sendable {
let generated: String
let current: CurrentBlock
let optimize: OptimizeBlock
let history: HistoryBlock
}
struct HistoryBlock: Codable, Sendable {
let daily: [DailyHistoryEntry]
}
struct DailyModelBreakdown: Codable, Sendable {
let name: String
let cost: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
var totalTokens: Int { inputTokens + outputTokens }
}
struct DailyHistoryEntry: Codable, Sendable {
let date: String
let cost: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let topModels: [DailyModelBreakdown]
/// Pricing-ratio prior: input + 5x output + cache_creation + 0.1x cache_read.
/// Matches Anthropic's published per-token pricing on Sonnet/Opus closely enough to be a useful proxy.
var effectiveTokens: Double {
Double(inputTokens) + 5.0 * Double(outputTokens) + Double(cacheWriteTokens) + 0.1 * Double(cacheReadTokens)
}
}
extension DailyHistoryEntry {
/// Required for legacy payloads (no topModels emitted yet).
enum CodingKeys: String, CodingKey {
case date, cost, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
date = try c.decode(String.self, forKey: .date)
cost = try c.decode(Double.self, forKey: .cost)
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
cacheReadTokens = try c.decode(Int.self, forKey: .cacheReadTokens)
cacheWriteTokens = try c.decode(Int.self, forKey: .cacheWriteTokens)
topModels = try c.decodeIfPresent([DailyModelBreakdown].self, forKey: .topModels) ?? []
}
}
struct CurrentBlock: Codable, Sendable {
let label: String
let cost: Double
let calls: Int
let sessions: Int
let oneShotRate: Double?
let inputTokens: Int
let outputTokens: Int
let cacheHitPercent: Double
let topActivities: [ActivityEntry]
let topModels: [ModelEntry]
let providers: [String: Double]
}
struct ActivityEntry: Codable, Sendable {
let name: String
let cost: Double
let turns: Int
let oneShotRate: Double?
}
struct ModelEntry: Codable, Sendable {
let name: String
let cost: Double
let calls: Int
}
struct OptimizeBlock: Codable, Sendable {
let findingCount: Int
let savingsUSD: Double
let topFindings: [FindingEntry]
}
struct FindingEntry: Codable, Sendable {
let title: String
let impact: String
let savingsUSD: Double
}
// MARK: - Empty fallback
extension MenubarPayload {
/// Strictly-empty payload. Used as the fallback before real data arrives, so no
/// plausible-looking fake numbers leak into the UI.
static let empty = MenubarPayload(
generated: "",
current: CurrentBlock(
label: "",
cost: 0,
calls: 0,
sessions: 0,
oneShotRate: nil,
inputTokens: 0,
outputTokens: 0,
cacheHitPercent: 0,
topActivities: [],
topModels: [],
providers: [:]
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])
)
}

View file

@ -0,0 +1,306 @@
import Foundation
import Security
private let credentialsRelativePath = ".claude/.credentials.json"
private let keychainService = "Claude Code-credentials"
private let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")!
private let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
private let betaHeader = "oauth-2025-04-20"
private let userAgent = "claude-code/2.1.0"
private let requestTimeout: TimeInterval = 30
/// Claude Code writes Keychain items with `kSecAttrAccount = "default"`. Filtering on this
/// prevents a planted Keychain item from another app (or a stale install with a mangled
/// account) from being accepted as our source of OAuth credentials.
private let expectedKeychainAccounts: Set<String> = ["default"]
private let maxCredentialBytes = 64 * 1024
enum SubscriptionError: Error, LocalizedError {
case noCredentials
case credentialsInvalid
case refreshFailed(Int, String?)
case usageFetchFailed(Int, String?)
case decodeFailed(Error)
var errorDescription: String? {
switch self {
case .noCredentials: "No Claude OAuth credentials found"
case .credentialsInvalid: "Claude OAuth credentials malformed"
case let .refreshFailed(code, body): "Token refresh failed (\(code))\(body.map { ": \($0)" } ?? "")"
case let .usageFetchFailed(code, body): "Usage fetch failed (\(code))\(body.map { ": \($0)" } ?? "")"
case let .decodeFailed(err): "Decode failed: \(err.localizedDescription)"
}
}
}
struct SubscriptionClient {
static func fetch() async throws -> SubscriptionUsage {
let creds = try loadCredentials()
// Try the usage call with the existing token first. Only refresh on 401.
do {
let response = try await fetchUsage(token: creds.accessToken)
return mapResponse(response, rawTier: creds.rateLimitTier)
} catch SubscriptionError.usageFetchFailed(401, _) {
guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else {
throw SubscriptionError.usageFetchFailed(401, "no refresh token available")
}
let newToken = try await refreshAccessToken(refreshToken: refreshToken)
let response = try await fetchUsage(token: newToken)
return mapResponse(response, rawTier: creds.rateLimitTier)
}
}
// MARK: - Credentials
private static func loadCredentials() throws -> StoredCredentials {
if let data = try readFileCredentials() {
return try parseCredentials(data: sanitizeKeychainData(data))
}
if let creds = try readKeychainCredentials() {
return creds
}
throw SubscriptionError.noCredentials
}
private static func readFileCredentials() throws -> Data? {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
// SafeFile refuses to follow symlinks and caps the read, so a 6 GB /dev/urandom
// masquerading as the creds file can't blow up the app.
return try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
}
/// Two-phase keychain enumeration: (1) list persistent refs + accounts, (2) fetch each
/// item's data by ref. The combination kSecMatchLimitAll + kSecReturnData errors with -50,
/// so the data fetch has to be per-item.
private static func readKeychainCredentials() throws -> StoredCredentials? {
let listQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnPersistentRef as String: true,
]
var listResult: CFTypeRef?
let listStatus = SecItemCopyMatching(listQuery as CFDictionary, &listResult)
if listStatus == errSecItemNotFound {
NSLog("CodeBurn: keychain query found no items for service \(keychainService)")
return nil
}
guard listStatus == errSecSuccess, let rows = listResult as? [[String: Any]] else {
NSLog("CodeBurn: keychain enumerate failed status=\(listStatus)")
return nil
}
var best: StoredCredentials? = nil
for row in rows {
guard let ref = row[kSecValuePersistentRef as String] as? Data else { continue }
let account = (row[kSecAttrAccount as String] as? String) ?? ""
// Ignore rows whose account doesn't match Claude Code's known writer. Stops another
// app's item (or a legacy install with an unexpected account) from being accepted.
guard expectedKeychainAccounts.contains(account) else { continue }
let dataQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecValuePersistentRef as String: ref,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
var dataResult: CFTypeRef?
let dataStatus = SecItemCopyMatching(dataQuery as CFDictionary, &dataResult)
guard dataStatus == errSecSuccess, let data = dataResult as? Data else { continue }
let sanitized = sanitizeKeychainData(data)
guard let parsed = try? parseCredentials(data: sanitized) else { continue }
if let current = best {
if (parsed.expiresAt ?? .distantPast) > (current.expiresAt ?? .distantPast) {
best = parsed
}
} else {
best = parsed
}
}
return best
}
/// Claude Code's keychain writer line-wraps long string values (newline + leading spaces)
/// mid-token, producing JSON with literal control chars and stray spaces inside string
/// values. Replace every newline (CR/LF) plus the run of spaces/tabs that follows it.
/// Drops both the wrapping in tokens AND pretty-print indentation between fields (both
/// produce valid, compact JSON afterward).
private static func sanitizeKeychainData(_ data: Data) -> Data {
guard var s = String(data: data, encoding: .utf8) else { return data }
s = s.replacingOccurrences(of: "\r", with: "")
let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: [])
if let regex {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
s = regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "")
}
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
return s.data(using: .utf8) ?? data
}
/// Decodes the credential JSON blob. Never logs the blob contents or any slice of it --
/// even a partial access token reaching Console.app is a leak, and the byte-window
/// diagnostic that used to live here could overlap the `accessToken` field bytes.
private static func parseCredentials(data: Data) throws -> StoredCredentials {
do {
let root = try JSONDecoder().decode(CredentialsRoot.self, from: data)
guard let oauth = root.claudeAiOauth else { throw SubscriptionError.credentialsInvalid }
let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { throw SubscriptionError.credentialsInvalid }
let expiresAt = oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) }
return StoredCredentials(
accessToken: token,
refreshToken: oauth.refreshToken,
expiresAt: expiresAt,
rateLimitTier: oauth.rateLimitTier
)
} catch let err as SubscriptionError {
throw err
} catch {
throw SubscriptionError.decodeFailed(error)
}
}
// MARK: - Refresh
private static func refreshAccessToken(refreshToken: String) async throws -> String {
var request = URLRequest(url: refreshURL)
request.httpMethod = "POST"
request.timeoutInterval = requestTimeout
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "refresh_token", value: refreshToken),
URLQueryItem(name: "client_id", value: oauthClientID),
]
request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw SubscriptionError.refreshFailed(-1, nil)
}
guard http.statusCode == 200 else {
let body = String(data: data, encoding: .utf8)
throw SubscriptionError.refreshFailed(http.statusCode, body)
}
do {
let decoded = try JSONDecoder().decode(TokenRefreshResponse.self, from: data)
return decoded.accessToken
} catch {
throw SubscriptionError.decodeFailed(error)
}
}
// MARK: - Usage fetch
private static func fetchUsage(token: String) async throws -> UsageResponse {
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = requestTimeout
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw SubscriptionError.usageFetchFailed(-1, nil)
}
guard http.statusCode == 200 else {
let body = String(data: data, encoding: .utf8)
throw SubscriptionError.usageFetchFailed(http.statusCode, body)
}
do {
return try JSONDecoder().decode(UsageResponse.self, from: data)
} catch {
throw SubscriptionError.decodeFailed(error)
}
}
// MARK: - Mapping
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
SubscriptionUsage(
tier: SubscriptionUsage.tier(from: rawTier),
rawTier: rawTier,
fiveHourPercent: r.fiveHour?.utilization,
fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
sevenDayPercent: r.sevenDay?.utilization,
sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
sevenDayOpusPercent: r.sevenDayOpus?.utilization,
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
fetchedAt: Date()
)
}
private static func parseDate(_ s: String?) -> Date? {
guard let s, !s.isEmpty else { return nil }
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = f.date(from: s) { return d }
f.formatOptions = [.withInternetDateTime]
return f.date(from: s)
}
}
// MARK: - Internal models
private struct StoredCredentials {
let accessToken: String
let refreshToken: String?
let expiresAt: Date?
let rateLimitTier: String?
}
private struct CredentialsRoot: Decodable {
let claudeAiOauth: OAuthBlock?
}
private struct OAuthBlock: Decodable {
let accessToken: String?
let refreshToken: String?
let expiresAt: Double?
let rateLimitTier: String?
}
private struct TokenRefreshResponse: Decodable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
}
}
private struct UsageResponse: Decodable {
let fiveHour: Window?
let sevenDay: Window?
let sevenDayOpus: Window?
let sevenDaySonnet: Window?
enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case sevenDayOpus = "seven_day_opus"
case sevenDaySonnet = "seven_day_sonnet"
}
}
private struct Window: Decodable {
let utilization: Double?
let resetsAt: String?
enum CodingKeys: String, CodingKey {
case utilization
case resetsAt = "resets_at"
}
}

View file

@ -0,0 +1,102 @@
import Foundation
/// Persisted snapshot of a single utilization reading. We capture one per window every time
/// SubscriptionClient.fetch() succeeds so we can answer "what did the prior 7-day cycle finish at?"
/// when the current window has no usable data yet (just reset).
struct SubscriptionSnapshot: Codable, Sendable {
let windowKey: String // "five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"
let percent: Double // 0..100
let resetsAt: Date // resets_at active at capture (identifies which window cycle this belongs to)
let capturedAt: Date // when the snapshot was recorded
let effectiveTokens: Double? // tokens consumed in window at capture (nil if not computed)
}
private let snapshotFilename = "subscription-snapshots.json"
private let pruneOlderThanSeconds: TimeInterval = 30 * 24 * 3600
private func snapshotsCacheDir() -> String {
return ProcessInfo.processInfo.environment["CODEBURN_CACHE_DIR"]
?? (NSHomeDirectory() as NSString).appendingPathComponent(".cache/codeburn")
}
private func snapshotsPath() -> String {
return (snapshotsCacheDir() as NSString).appendingPathComponent(snapshotFilename)
}
private actor SnapshotLock {
static let shared = SnapshotLock()
func run<T>(_ fn: () throws -> T) rethrows -> T { try fn() }
}
enum SubscriptionSnapshotStore {
/// Append a snapshot. Auto-prunes entries older than 30 days. Idempotent: if a snapshot
/// with the same windowKey + resetsAt already exists, only update percent if new is higher
/// (so "final" reading near reset is preserved).
static func record(_ snapshot: SubscriptionSnapshot) async {
await SnapshotLock.shared.run {
do {
var all = loadAll()
let key = "\(snapshot.windowKey)|\(snapshot.resetsAt.timeIntervalSince1970)"
if let idx = all.firstIndex(where: { "\($0.windowKey)|\($0.resetsAt.timeIntervalSince1970)" == key }) {
if snapshot.percent > all[idx].percent {
all[idx] = snapshot
}
} else {
all.append(snapshot)
}
let cutoff = Date().addingTimeInterval(-pruneOlderThanSeconds)
all = all.filter { $0.capturedAt >= cutoff }
try save(all)
} catch {
NSLog("CodeBurn: snapshot record failed: \(error)")
}
}
}
/// Returns the final percent of the immediately-prior cycle for this window, or nil if no
/// prior data is available. Logic: among snapshots whose resetsAt < currentResetsAt, pick
/// the group with the largest resetsAt (most recent prior cycle), then return the max
/// percent in that group (the closest-to-final reading we have).
static func previousWindowFinal(windowKey: String, currentResetsAt: Date) async -> Double? {
await SnapshotLock.shared.run {
let all = loadAll()
let priors = all.filter { $0.windowKey == windowKey && $0.resetsAt < currentResetsAt }
guard let mostRecentPriorReset = priors.map({ $0.resetsAt }).max() else { return nil }
let priorWindow = priors.filter { $0.resetsAt == mostRecentPriorReset }
return priorWindow.map(\.percent).max()
}
}
/// Return all snapshots for a given window key, useful for capacity estimation.
static func snapshots(for windowKey: String) async -> [SubscriptionSnapshot] {
await SnapshotLock.shared.run {
loadAll().filter { $0.windowKey == windowKey }
}
}
/// Test seam: clear all snapshots.
static func resetForTesting() async {
await SnapshotLock.shared.run {
try? FileManager.default.removeItem(atPath: snapshotsPath())
}
}
// MARK: - Internals
private static func loadAll() -> [SubscriptionSnapshot] {
let path = snapshotsPath()
guard FileManager.default.fileExists(atPath: path) else { return [] }
guard let data = try? SafeFile.read(from: path) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([SubscriptionSnapshot].self, from: data)) ?? []
}
private static func save(_ snapshots: [SubscriptionSnapshot]) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(snapshots)
// SafeFile.write refuses symlinked targets and does the tmp+rename atomic dance.
try SafeFile.write(data, to: snapshotsPath(), mode: 0o600)
}
}

View file

@ -0,0 +1,46 @@
import Foundation
struct SubscriptionUsage: Sendable, Equatable {
enum Tier: String, Sendable, Equatable {
case pro
case max5x
case max20x
case team
case enterprise
case unknown
var displayName: String {
switch self {
case .pro: "Pro"
case .max5x: "Max 5x"
case .max20x: "Max 20x"
case .team: "Team"
case .enterprise: "Enterprise"
case .unknown: "Subscription"
}
}
}
let tier: Tier
let rawTier: String?
let fiveHourPercent: Double?
let fiveHourResetsAt: Date?
let sevenDayPercent: Double?
let sevenDayResetsAt: Date?
let sevenDayOpusPercent: Double?
let sevenDayOpusResetsAt: Date?
let sevenDaySonnetPercent: Double?
let sevenDaySonnetResetsAt: Date?
let fetchedAt: Date
static func tier(from raw: String?) -> Tier {
guard let raw = raw?.lowercased() else { return .unknown }
if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") { return .max20x }
if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") { return .max5x }
if raw.contains("max") { return .max5x }
if raw.contains("pro") { return .pro }
if raw.contains("team") { return .team }
if raw.contains("enterprise") { return .enterprise }
return .unknown
}
}

View file

@ -0,0 +1,59 @@
import Foundation
/// Single entry point for spawning the `codeburn` CLI. All callers route through here so the
/// binary argv is validated once and no code path ever passes user-influenced strings through
/// a shell (`/bin/zsh -c`, `open --args`, AppleScript). This closes the shell-injection attack
/// surface end-to-end.
enum CodeburnCLI {
/// Matches a plain file path / program name: alphanumerics, dot, underscore, slash, hyphen,
/// space. Deliberately excludes shell metacharacters (`$`, `;`, `&`, `|`, quotes, backticks,
/// newlines) so a malicious `CODEBURN_BIN="codeburn; rm -rf ~"` can't slip through.
private static let safeArgPattern = try! NSRegularExpression(pattern: "^[A-Za-z0-9 ._/\\-]+$")
/// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses
/// Homebrew and npm global installs.
private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"]
/// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only
/// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the
/// plain `codeburn` name (resolved via PATH).
static func baseArgv() -> [String] {
guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else {
return ["codeburn"]
}
let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard parts.allSatisfy(isSafe) else {
NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'")
return ["codeburn"]
}
return parts
}
/// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env`
/// so PATH lookup happens without involving a shell, and augments PATH with Homebrew
/// defaults. Caller sets stdout/stderr pipes and calls `run()`.
static func makeProcess(subcommand: [String]) -> Process {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = augmentedPath(environment["PATH"] ?? "")
process.environment = environment
// `env --` treats everything following as argv, not VAR=val pairs -- guards against an
// argument accidentally resembling an env assignment.
process.arguments = ["--"] + baseArgv() + subcommand
return process
}
static func isSafe(_ s: String) -> Bool {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
return safeArgPattern.firstMatch(in: s, range: range) != nil
}
private static func augmentedPath(_ existing: String) -> String {
var parts = existing.split(separator: ":", omittingEmptySubsequences: true).map(String.init)
for extra in additionalPathEntries where !parts.contains(extra) {
parts.append(extra)
}
return parts.joined(separator: ":")
}
}

View file

@ -0,0 +1,128 @@
import Foundation
/// Symlink-safe file I/O with atomic writes and optional cross-process flock.
///
/// Every cache file we touch (`~/Library/Caches/codeburn-mac/fx-rates.json`,
/// `~/.cache/codeburn/subscription-snapshots.json`, `~/.config/codeburn/config.json`) is a
/// legitimate target for a local-symlink attack: if an attacker plants a symlink from one of
/// those paths to, say, `~/.ssh/config`, a naive `Data.write(to:)` blindly follows the link and
/// clobbers the real file. `O_NOFOLLOW` on the write() refuses the operation instead.
enum SafeFile {
enum Error: Swift.Error {
case symlinkDetected(String)
case openFailed(String, Int32)
case writeFailed(String, Int32)
case renameFailed(String, Int32)
case readFailed(String, Int32)
case sizeLimitExceeded(String, Int)
}
/// Default max bytes when reading untrusted cache files. Prevents a malicious cache file
/// from exhausting memory in the Swift process.
static let defaultReadLimit = 8 * 1024 * 1024
/// Refuses to follow symlinks and writes atomically via a tmp file + rename. `mode` is the
/// final file permission (0o600 by default so cache files stay user-private).
static func write(_ data: Data, to path: String, mode: mode_t = 0o600) throws {
let parent = (path as NSString).deletingLastPathComponent
try FileManager.default.createDirectory(
atPath: parent,
withIntermediateDirectories: true,
attributes: [.posixPermissions: NSNumber(value: 0o700)]
)
// Reject if the existing file is a symlink. We use lstat so the link itself is
// inspected, not its target.
var linkInfo = stat()
if lstat(path, &linkInfo) == 0, (linkInfo.st_mode & S_IFMT) == S_IFLNK {
throw Error.symlinkDetected(path)
}
let tmpPath = parent + "/.codeburn-" + UUID().uuidString + ".tmp"
let flags: Int32 = O_CREAT | O_WRONLY | O_EXCL | O_NOFOLLOW
let fd = Darwin.open(tmpPath, flags, mode)
guard fd >= 0 else {
throw Error.openFailed(tmpPath, errno)
}
let writeResult: Int = data.withUnsafeBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return 0 }
return Darwin.write(fd, base, buffer.count)
}
let writeErrno = errno
fsync(fd)
Darwin.close(fd)
guard writeResult == data.count else {
unlink(tmpPath)
throw Error.writeFailed(tmpPath, writeErrno)
}
if rename(tmpPath, path) != 0 {
let renameErrno = errno
unlink(tmpPath)
throw Error.renameFailed(path, renameErrno)
}
}
/// Refuses to read through a symlink. `maxBytes` bounds the read so a tampered cache file
/// can't balloon the process.
static func read(from path: String, maxBytes: Int = defaultReadLimit) throws -> Data {
var linkInfo = stat()
guard lstat(path, &linkInfo) == 0 else {
throw Error.readFailed(path, errno)
}
if (linkInfo.st_mode & S_IFMT) == S_IFLNK {
throw Error.symlinkDetected(path)
}
let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW)
guard fd >= 0 else {
throw Error.readFailed(path, errno)
}
defer { Darwin.close(fd) }
let size = Int(linkInfo.st_size)
if size > maxBytes {
throw Error.sizeLimitExceeded(path, size)
}
var data = Data(count: size)
let readBytes: Int = data.withUnsafeMutableBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return 0 }
return Darwin.read(fd, base, buffer.count)
}
guard readBytes >= 0 else {
throw Error.readFailed(path, errno)
}
if readBytes < size {
data = data.prefix(readBytes)
}
return data
}
/// Runs `body` while holding an exclusive POSIX advisory lock on `path`. The lock file is
/// created if missing (with 0o600 permissions) and released on scope exit, so other
/// codeburn processes (the CLI running in a terminal, say) block on the same file instead
/// of racing on a shared config.
static func withExclusiveLock<T>(at path: String, body: () throws -> T) throws -> T {
let parent = (path as NSString).deletingLastPathComponent
try FileManager.default.createDirectory(
atPath: parent,
withIntermediateDirectories: true,
attributes: [.posixPermissions: NSNumber(value: 0o700)]
)
let fd = Darwin.open(path, O_CREAT | O_RDWR | O_NOFOLLOW, 0o600)
guard fd >= 0 else {
throw Error.openFailed(path, errno)
}
defer { Darwin.close(fd) }
guard flock(fd, LOCK_EX) == 0 else {
throw Error.openFailed(path, errno)
}
defer { _ = flock(fd, LOCK_UN) }
return try body()
}
}

View file

@ -0,0 +1,65 @@
import AppKit
import Foundation
/// Runs commands in the user's Terminal. Every string that reaches AppleScript `do script`
/// must be whitespace-joined argv where each token passes `CodeburnCLI.isSafe` (regex allowlist
/// that excludes shell metacharacters), OR a hardcoded literal defined here. The private
/// `runInTerminal` re-validates any non-literal input defensively so a future caller can't
/// bypass the invariant.
/// Falls back to a detached headless spawn on machines without Terminal.app (iTerm/Ghostty/Warp
/// users) so the subcommand still runs.
enum TerminalLauncher {
private static let terminalPaths = [
"/System/Applications/Utilities/Terminal.app",
"/Applications/Utilities/Terminal.app",
]
static func open(subcommand: [String]) {
let argv = CodeburnCLI.baseArgv() + subcommand
guard argv.allSatisfy(CodeburnCLI.isSafe) else {
NSLog("CodeBurn: refusing to open terminal with unsafe argv")
return
}
let command = argv.joined(separator: " ")
if terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) {
runInTerminal(command: command, preValidated: true)
return
}
let headless = CodeburnCLI.makeProcess(subcommand: subcommand)
try? headless.run()
}
/// Launches `claude login` in Terminal.app so the user can complete the OAuth flow
/// without leaving CodeBurn. The command is a hardcoded literal -- no user input is
/// interpolated, so there's no injection surface.
static func openClaudeLogin() -> Bool {
guard terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) else {
NSLog("CodeBurn: Terminal.app not present; user must run `claude login` manually")
return false
}
runInTerminal(command: "claude login", preValidated: true)
return true
}
private static func runInTerminal(command: String, preValidated: Bool) {
if !preValidated {
let tokens = command.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard tokens.allSatisfy(CodeburnCLI.isSafe) else {
NSLog("CodeBurn: refusing to run unvalidated command in Terminal")
return
}
}
let script = """
tell application "Terminal"
activate
do script "\(command)"
end tell
"""
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
try? process.run()
}
}

View file

@ -0,0 +1,32 @@
import SwiftUI
/// Design tokens. Warm terracotta-ember palette, not generic orange.
enum Theme {
static let brandAccent = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
static let brandAccentDark = Color(red: 0xE8/255.0, green: 0x77/255.0, blue: 0x4A/255.0)
static let brandEmberDeep = Color(red: 0x8B/255.0, green: 0x3E/255.0, blue: 0x13/255.0)
static let brandEmberGlow = Color(red: 0xF0/255.0, green: 0xA0/255.0, blue: 0x70/255.0)
static let warmSurface = Color(red: 0xFA/255.0, green: 0xF7/255.0, blue: 0xF3/255.0)
static let warmSurfaceDark = Color(red: 0x1C/255.0, green: 0x18/255.0, blue: 0x16/255.0)
static let categoricalClaude = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
static let categoricalCursor = Color(red: 0x3F/255.0, green: 0x6B/255.0, blue: 0x8C/255.0)
static let categoricalCodex = Color(red: 0x4A/255.0, green: 0x7D/255.0, blue: 0x5C/255.0)
static let oneShotGood = Color(red: 0x30/255.0, green: 0xD1/255.0, blue: 0x58/255.0)
static let oneShotMid = Color(red: 0xFF/255.0, green: 0x9F/255.0, blue: 0x0A/255.0)
static let oneShotLow = Color(red: 0xFF/255.0, green: 0x45/255.0, blue: 0x3A/255.0)
// Semantic colors -- tuned to sit alongside the terracotta accent without clashing.
static let semanticDanger = Color(red: 0xC8/255.0, green: 0x3F/255.0, blue: 0x2C/255.0) // brick-red, terracotta-leaning
static let semanticWarning = Color(red: 0xD9/255.0, green: 0x8F/255.0, blue: 0x29/255.0) // amber, warmer than vanilla
static let semanticSuccess = Color(red: 0x4E/255.0, green: 0xA8/255.0, blue: 0x65/255.0) // muted green that holds against terracotta
}
extension Font {
/// SF Mono for currency values -- developer-tool identity.
static func codeMono(size: CGFloat, weight: Font.Weight = .regular) -> Font {
.system(size: size, weight: weight, design: .monospaced)
}
}

View file

@ -0,0 +1,87 @@
import SwiftUI
struct ActivitySection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = true
var body: some View {
CollapsibleSection(
caption: "Activity",
isExpanded: $isExpanded,
trailing: {
HStack(spacing: 8) {
Text("Cost").frame(minWidth: 54, alignment: .trailing)
Text("Turns").frame(minWidth: 52, alignment: .trailing)
Text("1-shot").frame(minWidth: 44, alignment: .trailing)
}
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
.tracking(-0.05)
}
) {
VStack(alignment: .leading, spacing: 7) {
let maxCost = store.payload.current.topActivities.map(\.cost).max() ?? 1
ForEach(store.payload.current.topActivities, id: \.name) { activity in
ActivityRow(activity: activity, maxCost: maxCost)
}
}
}
}
}
struct ActivityRow: View {
let activity: ActivityEntry
let maxCost: Double
var body: some View {
HStack(spacing: 8) {
FixedBar(fraction: activity.cost / maxCost)
.frame(width: 56, height: 6)
Text(activity.name)
.font(.system(size: 12.5, weight: .medium))
.frame(maxWidth: .infinity, alignment: .leading)
Text(activity.cost.asCompactCurrency())
.font(.codeMono(size: 12, weight: .medium))
.tracking(-0.2)
.frame(minWidth: 54, alignment: .trailing)
Text("\(activity.turns)")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 52, alignment: .trailing)
Text(oneShotText)
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 44, alignment: .trailing)
}
.padding(.horizontal, 2)
.padding(.vertical, 1)
}
private var oneShotText: String {
guard let rate = activity.oneShotRate else { return "" }
return "\(Int(rate * 100))%"
}
}
/// Fixed-width horizontal bar that shows a fill fraction.
struct FixedBar: View {
let fraction: Double
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(.secondary.opacity(0.15))
RoundedRectangle(cornerRadius: 2)
.fill(Theme.brandAccent)
.frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(fraction))))
}
}
}
}

View file

@ -0,0 +1,100 @@
import SwiftUI
struct AgentTabStrip: View {
@Environment(AppStore.self) private var store
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(visibleFilters) { filter in
Button {
Task { await store.switchTo(provider: filter) }
} label: {
AgentTab(
filter: filter,
cost: cost(for: filter),
isActive: store.selectedProvider == filter
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
}
}
/// Drive tab visibility and per-tab cost labels from the *all-provider* payload (today),
/// not the currently selected provider's payload. Without this, switching to Codex (which
/// has no data) would hide every other tab including Claude.
private var allProvidersToday: MenubarPayload {
store.todayPayload ?? store.payload
}
private var visibleFilters: [ProviderFilter] {
// Show a tab for every provider detected on this machine. The CLI decides what
// to include in the providers map based on session dirs / credential files it
// finds, so zero-cost-today is still "installed" and the user expects to see
// it. Only providers that aren't installed at all are absent from the map.
let detectedKeys = Set(
allProvidersToday.current.providers.keys.map { $0.lowercased() }
)
return ProviderFilter.allCases.filter { filter in
if filter == .all { return true }
return detectedKeys.contains(filter.rawValue.lowercased())
}
}
private func cost(for filter: ProviderFilter) -> Double? {
switch filter {
case .all:
return allProvidersToday.current.cost
default:
let key = filter.rawValue.lowercased()
return allProvidersToday.current.providers[key]
}
}
}
private struct AgentTab: View {
let filter: ProviderFilter
let cost: Double?
let isActive: Bool
var body: some View {
HStack(spacing: 5) {
Text(filter.rawValue)
.font(.system(size: 11.5, weight: .medium))
.tracking(-0.05)
if let cost, cost > 0 {
Text(cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
.tracking(-0.2)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08)))
)
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.contentShape(Rectangle())
}
}
extension ProviderFilter {
var color: Color {
switch self {
case .all: return Theme.brandAccent
case .claude: return Theme.categoricalClaude
case .codex: return Theme.categoricalCodex
case .cursor: return Theme.categoricalCursor
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
}
}
}

View file

@ -0,0 +1,290 @@
import SwiftUI
private let winColor = Theme.brandAccent
private let riskColor = Theme.brandAccent
private let improveColor = Theme.brandAccent
/// Three-category insights panel: wins, improvements, risks.
/// Wins/risks are derived from current + history; improvements come from the optimize findings.
struct FindingsSection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = true
var body: some View {
let groups = computeTipGroups(payload: store.payload)
if groups.allSatisfy({ $0.items.isEmpty }) { return AnyView(EmptyView()) }
return AnyView(
VStack(alignment: .leading, spacing: 8) {
Button {
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
} label: {
HStack(alignment: .firstTextBaseline) {
HStack(spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Text("Tips for you")
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(.primary)
}
Spacer()
Text("\(groups.flatMap { $0.items }.count) signals")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.opacity(0.55)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
VStack(alignment: .leading, spacing: 10) {
ForEach(groups) { group in
if !group.items.isEmpty {
TipsGroup(group: group)
}
}
if store.payload.optimize.findingCount > 0 {
Button {
openOptimize()
} label: {
HStack(spacing: 4) {
Text("Open Full Optimize")
.font(.system(size: 11.5, weight: .semibold))
Image(systemName: "arrow.forward")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Theme.brandAccent)
}
.buttonStyle(.plain)
}
}
.transition(.opacity)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(0.06))
)
.padding(.horizontal, 14)
.padding(.vertical, 8)
)
}
private func openOptimize() {
TerminalLauncher.open(subcommand: ["optimize"])
}
}
private struct TipsGroup: View {
let group: TipGroup
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
Image(systemName: group.icon)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(group.color)
Text(group.label)
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(group.color)
.textCase(.uppercase)
.tracking(0.4)
}
VStack(alignment: .leading, spacing: 4) {
ForEach(group.items) { item in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Circle().fill(group.color).frame(width: 3, height: 3).padding(.top, 4)
Text(item.text)
.font(.system(size: 11.5))
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
if let trailing = item.trailing {
Text(trailing)
.font(.codeMono(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.tracking(-0.2)
}
}
}
}
}
}
}
private struct TipGroup: Identifiable {
let id = UUID()
let label: String
let icon: String
let color: Color
let items: [TipItem]
}
private struct TipItem: Identifiable {
let id = UUID()
let text: String
let trailing: String?
}
private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
let stats = computeHistoryStats(history: payload.history.daily)
// What's working
var wins: [TipItem] = []
let cacheHit = payload.current.cacheHitPercent
if cacheHit >= 80 {
wins.append(TipItem(
text: "Cache hit at \(Int(cacheHit))% — most prompts reuse cache",
trailing: nil
))
}
if let oneShot = payload.current.oneShotRate, oneShot >= 0.75 {
wins.append(TipItem(
text: "\(Int(oneShot * 100))% one-shot — edits landing first try",
trailing: nil
))
}
if let delta = stats.weekDeltaPercent, delta < -10 {
wins.append(TipItem(
text: "Spend down \(Int(abs(delta)))% vs last 7 days",
trailing: nil
))
}
if stats.activeStreakDays >= 5 {
wins.append(TipItem(
text: "\(stats.activeStreakDays)-day usage streak",
trailing: nil
))
}
// What to improve (existing optimize findings)
var improvements: [TipItem] = []
for finding in payload.optimize.topFindings.prefix(3) {
improvements.append(TipItem(
text: finding.title,
trailing: finding.savingsUSD.asCompactCurrency()
))
}
// Risks
var risks: [TipItem] = []
if let delta = stats.weekDeltaPercent, delta > 25 {
risks.append(TipItem(
text: "Spend up \(Int(delta))% vs prior 7 days",
trailing: nil
))
}
if cacheHit > 0 && cacheHit < 50 {
risks.append(TipItem(
text: "Cache hit only \(Int(cacheHit))% — paying for cold prompts",
trailing: nil
))
}
if let oneShot = payload.current.oneShotRate, oneShot < 0.5 {
risks.append(TipItem(
text: "\(Int(oneShot * 100))% one-shot — lots of iteration",
trailing: nil
))
}
if let projected = stats.projectedMonth, let prevMonth = stats.previousMonthTotal, projected > prevMonth * 1.3 {
risks.append(TipItem(
text: "On pace for \(projected.asCompactCurrency()) this month (+\(Int(((projected - prevMonth) / prevMonth) * 100))% vs last)",
trailing: nil
))
}
return [
TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: winColor, items: wins),
TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: improveColor, items: improvements),
TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: riskColor, items: risks),
]
}
private struct HistoryStats {
let weekDeltaPercent: Double?
let activeStreakDays: Int
let projectedMonth: Double?
let previousMonthTotal: Double?
}
private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "UTC")
return f
}()
let now = Date()
let today = calendar.startOfDay(for: now)
let costByDate = Dictionary(uniqueKeysWithValues: history.map { ($0.date, $0.cost) })
let lastWeekStart = calendar.date(byAdding: .day, value: -6, to: today)
let priorWeekStart = calendar.date(byAdding: .day, value: -13, to: today)
let priorWeekEnd = calendar.date(byAdding: .day, value: -7, to: today)
var weekDeltaPercent: Double? = nil
if let lws = lastWeekStart, let pws = priorWeekStart, let pwe = priorWeekEnd {
let lwsStr = formatter.string(from: lws)
let pwsStr = formatter.string(from: pws)
let pweStr = formatter.string(from: pwe)
let thisWeek = history.filter { $0.date >= lwsStr }.reduce(0.0) { $0 + $1.cost }
let prior = history.filter { $0.date >= pwsStr && $0.date <= pweStr }.reduce(0.0) { $0 + $1.cost }
if prior > 0 {
weekDeltaPercent = ((thisWeek - prior) / prior) * 100
}
}
var streak = 0
for offset in 0..<60 {
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
let key = formatter.string(from: d)
if (costByDate[key] ?? 0) > 0 { streak += 1 } else { break }
}
var projectedMonth: Double? = nil
var previousMonthTotal: Double? = nil
let comps = calendar.dateComponents([.year, .month, .day], from: now)
if
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
{
let firstStr = formatter.string(from: firstOfMonth)
let mtd = history.filter { $0.date >= firstStr }.reduce(0.0) { $0 + $1.cost }
let dayOfMonth = comps.day ?? 1
if dayOfMonth > 0 {
projectedMonth = (mtd / Double(dayOfMonth)) * Double(rangeOfMonth.count)
}
if
let prevMonth = calendar.date(byAdding: .month, value: -1, to: firstOfMonth),
let prevRange = calendar.range(of: .day, in: .month, for: prevMonth),
let prevFirst = calendar.date(from: DateComponents(
year: calendar.component(.year, from: prevMonth),
month: calendar.component(.month, from: prevMonth),
day: 1
)),
let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst)
{
let prevFirstStr = formatter.string(from: prevFirst)
let prevLastStr = formatter.string(from: prevLast)
let prevTotal = history.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr }
.reduce(0.0) { $0 + $1.cost }
if prevTotal > 0 { previousMonthTotal = prevTotal }
}
}
return HistoryStats(
weekDeltaPercent: weekDeltaPercent,
activeStreakDays: streak,
projectedMonth: projectedMonth,
previousMonthTotal: previousMonthTotal
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
import SwiftUI
struct HeroSection: View {
@Environment(AppStore.self) private var store
var body: some View {
VStack(alignment: .leading, spacing: 8) {
SectionCaption(text: caption)
HStack(alignment: .firstTextBaseline) {
Text(store.payload.current.cost.asCurrency())
.font(.system(size: 32, weight: .semibold, design: .rounded))
.monospacedDigit()
.tracking(-1)
.foregroundStyle(
LinearGradient(
colors: [Theme.brandAccent, Theme.brandEmberDeep],
startPoint: .top,
endPoint: .bottom
)
)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
Text("\(store.payload.current.sessions) sessions")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
}
}
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 12)
}
private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if store.selectedPeriod == .today {
return "\(label) · \(todayDate)"
}
return label
}
private var todayDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE MMM d"
return formatter.string(from: Date())
}
}

View file

@ -0,0 +1,410 @@
import AppKit
import SwiftUI
/// Popover root. Assembles all sections matching the HTML design spec.
struct MenuBarContent: View {
@Environment(AppStore.self) private var store
var body: some View {
VStack(spacing: 0) {
Header()
Divider()
if showAgentTabs {
AgentTabStrip()
Divider()
}
ZStack {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
HeroSection()
Divider().opacity(0.5)
PeriodSegmentedControl()
Divider().opacity(0.5)
if isFilteredEmpty {
EmptyProviderState(provider: store.selectedProvider, period: store.selectedPeriod)
} else {
HeatmapSection()
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 10)
.zIndex(10)
Divider().opacity(0.5)
ActivitySection()
Divider().opacity(0.5)
ModelsSection()
Divider().opacity(0.5)
FindingsSection()
}
}
}
if store.isLoading {
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
.transition(.opacity)
}
}
.frame(height: 520)
.animation(.easeInOut(duration: 0.2), value: store.isLoading)
Divider()
FooterBar()
StarBanner()
}
}
/// True when a specific provider tab is selected and that provider has no spend in the
/// currently selected period. The .all tab is exempt -- it always shows aggregated data.
private var isFilteredEmpty: Bool {
guard store.selectedProvider != .all else { return false }
return store.payload.current.cost <= 0 && store.payload.current.calls == 0
}
/// Show the tab row whenever the CLI detected at least one AI coding tool installed
/// on this machine. Hidden only when nothing is detected, which means there's
/// nothing to filter by anyway.
private var showAgentTabs: Bool {
let payload = store.todayPayload ?? store.payload
return !payload.current.providers.isEmpty
}
}
private struct EmptyProviderState: View {
let provider: ProviderFilter
let period: Period
var body: some View {
VStack(spacing: 10) {
Image(systemName: "tray")
.font(.system(size: 26))
.foregroundStyle(.tertiary)
Text("No \(provider.rawValue) data for \(periodPhrase)")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
}
private var periodPhrase: String {
switch period {
case .today: "today"
case .sevenDays: "the last 7 days"
case .thirtyDays: "the last 30 days"
case .month: "this month"
case .all: "all time"
}
}
}
/// Translucent overlay that blurs whatever's behind it (the previous tab/period content)
/// and centers an animated burning flame -- the brand mark filling up bottom-to-top in
/// yelloworangered, looping.
private struct BurnLoadingOverlay: View {
let periodLabel: String
@State private var fillProgress: CGFloat = 0
@State private var glowing: Bool = false
private let flameSize: CGFloat = 64
var body: some View {
ZStack {
// Blur backdrop -- ultraThinMaterial uses live blur of underlying content.
Rectangle()
.fill(.ultraThinMaterial)
VStack(spacing: 14) {
BurnFlame(size: flameSize, fillProgress: fillProgress, glowing: glowing)
Text("Loading \(periodLabel)")
.font(.system(size: 11.5, weight: .medium))
.foregroundStyle(.secondary)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
fillProgress = 1.0
}
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
glowing = true
}
}
}
}
private struct BurnFlame: View {
let size: CGFloat
let fillProgress: CGFloat
let glowing: Bool
var body: some View {
ZStack {
// Soft outer glow that pulses, matching the brand terracotta palette.
Image(systemName: "flame.fill")
.font(.system(size: size, weight: .regular))
.foregroundStyle(Theme.brandEmberGlow.opacity(glowing ? 0.55 : 0.20))
.blur(radius: glowing ? 14 : 6)
// Empty (cool) flame as base
Image(systemName: "flame")
.font(.system(size: size, weight: .regular))
.foregroundStyle(Theme.brandAccent.opacity(0.25))
// Burning gradient (brand orange) masked by an animated bottom-up rectangle
Image(systemName: "flame.fill")
.font(.system(size: size, weight: .regular))
.foregroundStyle(
LinearGradient(
colors: [
Theme.brandEmberGlow,
Theme.brandAccentDark,
Theme.brandAccent,
Theme.brandEmberDeep
],
startPoint: .bottom,
endPoint: .top
)
)
.mask(
GeometryReader { geo in
Rectangle()
.frame(height: geo.size.height * fillProgress)
.frame(maxHeight: .infinity, alignment: .bottom)
}
)
}
.frame(width: size, height: size)
}
}
private struct Header: View {
var body: some View {
VStack(alignment: .leading, spacing: 1) {
(
Text("Code").foregroundStyle(.primary)
+ Text("Burn").foregroundStyle(Theme.brandAccent)
)
.font(.system(size: 13, weight: .semibold))
.tracking(-0.15)
Text("AI Coding Cost Tracker")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 8)
}
}
struct FlameMark: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 5)
.fill(
LinearGradient(
colors: [Theme.brandAccentDark, Theme.brandEmberDeep],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.shadow(color: .black.opacity(0.2), radius: 1, y: 0.5)
Image(systemName: "flame.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
}
}
}
private let starBannerGitHubURL = URL(string: "https://github.com/getagentseal/codeburn")!
/// Shown at the very bottom on first launch. A small terracotta strip nudges users to star the
/// repo; clicking opens GitHub, clicking the close icon hides it forever (persisted to
/// UserDefaults so it never returns across launches).
struct StarBanner: View {
@AppStorage("codeburn.starBannerDismissed") private var dismissed: Bool = false
var body: some View {
if !dismissed {
HStack(spacing: 8) {
Image(systemName: "star.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Button {
NSWorkspace.shared.open(starBannerGitHubURL)
} label: {
HStack(spacing: 4) {
Text("Enjoying CodeBurn?")
.foregroundStyle(.primary)
Text("Star us on GitHub")
.foregroundStyle(Theme.brandAccent)
.underline(true, pattern: .solid)
}
.font(.system(size: 10.5, weight: .medium))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer()
Button {
dismissed = true
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.padding(4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Hide this banner")
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.brandAccent.opacity(0.08))
.overlay(alignment: .top) {
Rectangle()
.fill(Color.secondary.opacity(0.18))
.frame(height: 0.5)
}
}
}
}
struct FooterBar: View {
@Environment(AppStore.self) private var store
var body: some View {
HStack(spacing: 6) {
Menu {
ForEach(SupportedCurrency.allCases) { currency in
Button {
applyCurrency(code: currency.rawValue)
} label: {
if currency.rawValue == store.currency {
Label("\(currency.displayName) (\(currency.rawValue))", systemImage: "checkmark")
} else {
Text("\(currency.displayName) (\(currency.rawValue))")
}
}
}
} label: {
Label(store.currency, systemImage: "dollarsign.circle")
.font(.system(size: 11, weight: .medium))
.labelStyle(.titleAndIcon)
}
.menuStyle(.button)
.menuIndicator(.hidden)
.buttonStyle(.bordered)
.controlSize(.small)
.fixedSize()
Button {
Task { await store.refresh(includeOptimize: true) }
} label: {
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
.font(.system(size: 11, weight: .medium))
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(store.isLoading)
Menu {
Button("CSV (folder)") { runExport(format: .csv) }
Button("JSON") { runExport(format: .json) }
} label: {
Label("Export", systemImage: "square.and.arrow.down")
.font(.system(size: 11, weight: .medium))
.labelStyle(.titleAndIcon)
}
.menuStyle(.button)
.menuIndicator(.hidden)
.buttonStyle(.bordered)
.controlSize(.small)
.fixedSize()
Spacer()
Button { openReport() } label: {
Label("Open Full Report", systemImage: "terminal")
.font(.system(size: 11, weight: .semibold))
.labelStyle(.titleAndIcon)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(Theme.brandAccent)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
private func openReport() {
TerminalLauncher.open(subcommand: ["report"])
}
private enum ExportFormat {
case csv, json
var cliName: String { self == .csv ? "csv" : "json" }
var suffix: String { self == .csv ? "" : ".json" }
}
/// Runs `codeburn export` directly into ~/Downloads and reveals the result in Finder. CSV
/// produces a folder of clean one-table-per-file CSVs; JSON produces a single structured
/// file. The CLI is spawned with argv (no shell interpretation), so the output path cannot
/// be abused to inject shell commands even if a pathological value slips through.
private func runExport(format: ExportFormat) {
Task {
let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads")
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let base = "codeburn-\(formatter.string(from: Date()))"
let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix)
let process = CodeburnCLI.makeProcess(subcommand: [
"export", "-f", format.cliName, "-o", outputPath
])
do {
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
} else {
NSLog("CodeBurn: \(format.cliName.uppercased()) export exited with status \(process.terminationStatus)")
}
} catch {
NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)")
}
}
}
/// Instant-feeling currency switch. Updates the symbol and any cached FX rate on the main
/// thread right away so the UI redraws the next frame, then fetches a fresh rate in the
/// background. CLI config is persisted so other codeburn commands stay in sync.
private func applyCurrency(code: String) {
store.currency = code
let symbol = CurrencyState.symbolForCode(code)
Task {
let cached = await FXRateCache.shared.cachedRate(for: code)
await MainActor.run {
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
}
let fresh = await FXRateCache.shared.rate(for: code)
if let fresh, fresh != cached {
await MainActor.run {
CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
}
}
}
CLICurrencyConfig.persist(code: code)
}
}

View file

@ -0,0 +1,97 @@
import SwiftUI
struct ModelsSection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = true
var body: some View {
CollapsibleSection(
caption: "Models",
isExpanded: $isExpanded,
trailing: {
HStack(spacing: 8) {
Text("Cost").frame(minWidth: 54, alignment: .trailing)
Text("Calls").frame(minWidth: 52, alignment: .trailing)
}
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
.tracking(-0.05)
}
) {
VStack(alignment: .leading, spacing: 7) {
let maxCost = store.payload.current.topModels.map(\.cost).max() ?? 1
ForEach(store.payload.current.topModels, id: \.name) { model in
ModelRow(model: model, maxCost: maxCost)
}
TokensLine()
.padding(.top, 5)
}
}
}
}
private struct ModelRow: View {
let model: ModelEntry
let maxCost: Double
var body: some View {
HStack(spacing: 8) {
FixedBar(fraction: model.cost / maxCost)
.frame(width: 56, height: 6)
Text(model.name)
.font(.system(size: 12.5, weight: .medium))
.frame(maxWidth: .infinity, alignment: .leading)
Text(model.cost.asCompactCurrency())
.font(.codeMono(size: 12, weight: .medium))
.tracking(-0.2)
.frame(minWidth: 54, alignment: .trailing)
Text("\(model.calls)")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 52, alignment: .trailing)
}
.padding(.horizontal, 2)
.padding(.vertical, 1)
}
}
private struct TokensLine: View {
@Environment(AppStore.self) private var store
var body: some View {
let t = store.payload.current
let cacheHit = String(format: "%.0f", t.cacheHitPercent)
HStack(spacing: 4) {
Text("Tokens")
.foregroundStyle(.tertiary)
Text(formatTokens(t.inputTokens) + " in")
.foregroundStyle(.secondary)
Text("·")
.foregroundStyle(.tertiary)
Text(formatTokens(t.outputTokens) + " out")
.foregroundStyle(.secondary)
Text("·")
.foregroundStyle(.tertiary)
Text(cacheHit + "% cache hit")
.foregroundStyle(.secondary)
Spacer()
}
.font(.system(size: 10.5))
.monospacedDigit()
}
private func formatTokens(_ n: Int) -> String {
if n >= 1_000_000 {
return String(format: "%.1fM", Double(n) / 1_000_000)
} else if n >= 1_000 {
return String(format: "%.1fK", Double(n) / 1_000)
}
return "\(n)"
}
}

View file

@ -0,0 +1,36 @@
import SwiftUI
struct PeriodSegmentedControl: View {
@Environment(AppStore.self) private var store
var body: some View {
HStack(spacing: 1) {
ForEach(Period.allCases) { period in
Button {
Task { await store.switchTo(period: period) }
} label: {
Text(period.rawValue)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(store.selectedPeriod == period ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(store.selectedPeriod == period ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(store.selectedPeriod == period ? 0.06 : 0), radius: 1, y: 0.5)
)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color.secondary.opacity(0.08))
)
.padding(.horizontal, 12)
.padding(.top, 6)
.padding(.bottom, 10)
}
}

View file

@ -0,0 +1,85 @@
import SwiftUI
struct SectionCaption: View {
let text: String
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(Theme.brandAccent.opacity(0.7))
.frame(width: 3, height: 3)
Text(text)
.font(.system(size: 11.5, weight: .medium))
.foregroundStyle(.secondary)
.tracking(-0.1)
}
}
}
/// Collapsible section shell with a clickable caption, optional inline trailing
/// view (e.g. column headers), and a chevron.
struct CollapsibleSection<Trailing: View, Content: View>: View {
let caption: String
@Binding var isExpanded: Bool
let trailing: Trailing
let content: Content
init(
caption: String,
isExpanded: Binding<Bool>,
@ViewBuilder trailing: () -> Trailing,
@ViewBuilder content: () -> Content
) {
self.caption = caption
self._isExpanded = isExpanded
self.trailing = trailing()
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 7) {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 8) {
HStack(spacing: 5) {
Circle()
.fill(Theme.brandAccent.opacity(0.7))
.frame(width: 3, height: 3)
Text(caption)
.font(.system(size: 11.5, weight: .medium))
.tracking(-0.1)
}
Spacer()
trailing
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.opacity(0.55)
}
.foregroundStyle(.secondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
content
.transition(.opacity)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
}
}
extension CollapsibleSection where Trailing == EmptyView {
init(
caption: String,
isExpanded: Binding<Bool>,
@ViewBuilder content: () -> Content
) {
self.init(caption: caption, isExpanded: isExpanded, trailing: { EmptyView() }, content: content)
}
}

View file

@ -0,0 +1,99 @@
import SwiftUI
struct SparklineView: View {
let points: [Double]
var body: some View {
GeometryReader { geo in
let cgPoints = makePoints(in: geo.size)
let smooth = smoothPath(cgPoints)
ZStack {
// Gradient fill under the curve
let fill = closedPath(smooth, width: geo.size.width, height: geo.size.height)
fill.fill(
LinearGradient(
colors: [Theme.brandAccent.opacity(0.25), .clear],
startPoint: .top,
endPoint: .bottom
)
)
// Smooth accent stroke
smooth.stroke(
Theme.brandAccent.opacity(0.85),
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round)
)
// Highlighted current-day point
if let last = cgPoints.last {
Circle()
.fill(Theme.brandAccent)
.frame(width: 6, height: 6)
.overlay(
Circle()
.stroke(Color(NSColor.windowBackgroundColor).opacity(0.9), lineWidth: 1.3)
)
.position(last)
}
}
}
}
// MARK: - Geometry
private func makePoints(in size: CGSize) -> [CGPoint] {
guard !points.isEmpty else { return [] }
let w = size.width
let h = size.height
let maxV = points.max() ?? 1
let minV = points.min() ?? 0
let range = max(maxV - minV, 1)
let count = max(points.count - 1, 1)
let topPad: CGFloat = 5
let bottomPad: CGFloat = 5
let usable = max(h - topPad - bottomPad, 1)
return points.enumerated().map { idx, v in
CGPoint(
x: w * CGFloat(idx) / CGFloat(count),
y: h - bottomPad - usable * CGFloat(v - minV) / CGFloat(range)
)
}
}
/// Catmull-Rom cubic bezier. Standard smooth interpolation, no overshoot.
private func smoothPath(_ pts: [CGPoint]) -> Path {
var path = Path()
guard pts.count >= 2 else { return path }
path.move(to: pts[0])
let tension: CGFloat = 0.5
for i in 0..<(pts.count - 1) {
let p0 = i > 0 ? pts[i - 1] : pts[i]
let p1 = pts[i]
let p2 = pts[i + 1]
let p3 = i + 2 < pts.count ? pts[i + 2] : p2
let cp1 = CGPoint(
x: p1.x + (p2.x - p0.x) * tension / 3,
y: p1.y + (p2.y - p0.y) * tension / 3
)
let cp2 = CGPoint(
x: p2.x - (p3.x - p1.x) * tension / 3,
y: p2.y - (p3.y - p1.y) * tension / 3
)
path.addCurve(to: p2, control1: cp1, control2: cp2)
}
return path
}
/// Close the path along the bottom to form a fill region.
private func closedPath(_ line: Path, width: CGFloat, height: CGFloat) -> Path {
var p = line
p.addLine(to: CGPoint(x: width, y: height))
p.addLine(to: CGPoint(x: 0, y: height))
p.closeSubpath()
return p
}
}

View file

@ -0,0 +1,158 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
private let now = Date(timeIntervalSince1970: 1_734_000_000)
private func snap(_ percent: Double, _ tokens: Double, ageDays: Double = 0) -> CapacitySnapshot {
CapacitySnapshot(
percent: percent,
effectiveTokens: tokens,
capturedAt: now.addingTimeInterval(-ageDays * 86400)
)
}
@Suite("CapacityEstimator -- gating")
struct CapacityEstimatorGatingTests {
@Test("returns nil with no snapshots")
func emptyReturnsNil() {
#expect(CapacityEstimator.estimate([], asOf: now) == nil)
}
@Test("returns nil with fewer than 5 snapshots")
func tooFewReturnsNil() {
let snaps = (1...4).map { snap(Double($0 * 10), Double($0) * 100_000) }
#expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
}
@Test("returns nil when percent range is below 15 points")
func tooNarrowReturnsNil() {
let snaps = [
snap(40, 4_000_000),
snap(42, 4_200_000),
snap(44, 4_400_000),
snap(46, 4_600_000),
snap(48, 4_800_000),
snap(50, 5_000_000),
]
#expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
}
}
@Suite("CapacityEstimator -- recovery")
struct CapacityEstimatorRecoveryTests {
@Test("recovers capacity from 10 noise-free snapshots within 0.5%")
func recoverFromCleanData() {
let trueCapacity: Double = 10_000_000
let percents = [5.0, 12, 20, 28, 35, 47, 55, 68, 80, 92]
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.capacity > trueCapacity * 0.995)
#expect(est!.capacity < trueCapacity * 1.005)
// 10 perfect samples is below the solid sample threshold (15) but easily medium.
#expect(est!.confidence == .medium || est!.confidence == .solid)
}
@Test("recovers capacity within 5% from 30 noisy snapshots")
func recoverFromNoisyData() {
let trueCapacity: Double = 8_000_000
var rng = LinearCongruentialGenerator(seed: 42)
let snaps: [CapacitySnapshot] = (0..<30).map { i in
let p = 5.0 + Double(i) * 3.0 // 5..92, spanning enough
let noise = (rng.nextDouble() - 0.5) * 0.10 // ±5%
let tokens = (p / 100) * trueCapacity * (1 + noise)
return snap(p, tokens)
}
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
let ratio = est!.capacity / trueCapacity
#expect(ratio > 0.95 && ratio < 1.05)
#expect(est!.confidence == .solid || est!.confidence == .medium)
}
}
@Suite("CapacityEstimator -- confidence tiers")
struct CapacityEstimatorConfidenceTests {
@Test("six clean snapshots span sufficient range -> at least medium")
func sixCleanSnapshotsMedium() {
let trueCapacity: Double = 5_000_000
let percents = [5.0, 18, 32, 51, 70, 88]
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.confidence == .medium || est!.confidence == .solid)
}
@Test("noisy small-sample data falls to low confidence")
func noisySmallSampleLow() {
let trueCapacity: Double = 5_000_000
var rng = LinearCongruentialGenerator(seed: 7)
let percents = [5.0, 22, 40, 60, 80, 95]
let snaps: [CapacitySnapshot] = percents.map { p in
let noise = (rng.nextDouble() - 0.5) * 1.6 // ±80% noise -> drops R^2 below medium gate
return snap(p, p / 100 * trueCapacity * (1 + noise))
}
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.confidence == .low)
}
}
@Suite("CapacityEstimator -- recency weighting")
struct CapacityEstimatorRecencyTests {
@Test("recent snapshots dominate over old ones with different capacity")
func recencyShiftsEstimate() {
// Old data: capacity = 5M (45 days ago)
// New data: capacity = 10M (today)
// With 30-day half-life, recent data should win.
let oldSnaps = (0..<10).map { i -> CapacitySnapshot in
let p = 10.0 + Double(i) * 8
return snap(p, p / 100 * 5_000_000, ageDays: 45)
}
let newSnaps = (0..<10).map { i -> CapacitySnapshot in
let p = 10.0 + Double(i) * 8
return snap(p, p / 100 * 10_000_000, ageDays: 1)
}
let est = CapacityEstimator.estimate(oldSnaps + newSnaps, asOf: now)
#expect(est != nil)
// Recent capacity is 10M; estimate should be closer to 10M than 5M.
#expect(est!.capacity > 7_500_000)
}
}
@Suite("CapacityEstimator -- non-linearity")
struct CapacityEstimatorNonLinearityTests {
@Test("flags non-linearity when residuals show systematic sign pattern")
func detectsKneePattern() {
// Data follows a knee: linear up to 60%, then flatter (Anthropic capping).
let snaps: [CapacitySnapshot] = (0..<20).map { i in
let p = 5.0 + Double(i) * 5
let tokens: Double = p < 60 ? p / 100 * 8_000_000 : 0.6 * 8_000_000 + (p - 60) / 100 * 4_000_000
return snap(p, tokens)
}
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.nonLinearityWarning == true)
}
@Test("does not flag clean linear data")
func cleanLinearNoFlag() {
let trueCapacity: Double = 6_000_000
let percents = stride(from: 5.0, to: 95.0, by: 5.0).map { $0 }
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
let est = CapacityEstimator.estimate(snaps, asOf: now)
#expect(est != nil)
#expect(est!.nonLinearityWarning == false)
}
}
// Lightweight deterministic RNG for reproducible noise in tests.
struct LinearCongruentialGenerator {
private var state: UInt64
init(seed: UInt64) { self.state = seed }
mutating func nextDouble() -> Double {
state = state &* 6364136223846793005 &+ 1442695040888963407
return Double(state >> 11) / Double(1 << 53)
}
}

525
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "codeburn",
"version": "0.6.0",
"version": "0.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeburn",
"version": "0.6.0",
"version": "0.7.3",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
@ -18,7 +18,7 @@
"codeburn": "dist/cli.js"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
@ -26,10 +26,7 @@
"vitest": "^3.1.0"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"better-sqlite3": "^12.0.0"
"node": ">=22"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
@ -876,16 +873,6 @@
"win32"
]
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -912,13 +899,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
@ -1127,89 +1114,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/better-sqlite3": {
"version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
"integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"optional": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bundle-require": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
@ -1291,13 +1195,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC",
"optional": true
},
"node_modules/cli-boxes": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz",
@ -1413,22 +1310,6 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -1439,36 +1320,6 @@
"node": ">=6"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
@ -1559,16 +1410,6 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@ -1597,13 +1438,6 @@
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT",
"optional": true
},
"node_modules/fix-dts-default-cjs-exports": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
@ -1616,13 +1450,6 @@
"rollup": "^4.34.8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT",
"optional": true
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1663,34 +1490,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT",
"optional": true
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/indent-string": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
@ -1703,20 +1502,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"optional": true
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC",
"optional": true
},
"node_modules/ink": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ink/-/ink-7.0.0.tgz",
@ -1869,36 +1654,6 @@
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT",
"optional": true
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@ -1950,26 +1705,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT",
"optional": true
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -1980,16 +1715,6 @@
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@ -2145,61 +1870,6 @@
}
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@ -2224,21 +1894,6 @@
"react": "^19.2.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -2334,46 +1989,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@ -2387,53 +2008,6 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/slice-ansi": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
@ -2496,16 +2070,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
@ -2537,16 +2101,6 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
@ -2605,36 +2159,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/terminal-size": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz",
@ -2821,19 +2345,6 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-fest": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
@ -2871,19 +2382,12 @@
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"optional": true
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
@ -3104,13 +2608,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"optional": true
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "codeburn",
"version": "0.6.0",
"version": "0.7.3",
"description": "See where your AI coding tokens go - by task, tool, model, and project",
"type": "module",
"main": "./dist/cli.js",
@ -29,21 +29,26 @@
"developer-tools"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"author": "AgentSeal <hello@agentseal.org>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/getagentseal/codeburn.git"
},
"bugs": {
"url": "https://github.com/getagentseal/codeburn/issues"
},
"homepage": "https://github.com/getagentseal/codeburn#readme",
"dependencies": {
"chalk": "^5.4.1",
"commander": "^13.1.0",
"ink": "^7.0.0",
"react": "^19.2.5"
},
"optionalDependencies": {
"better-sqlite3": "^12.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"tsup": "^8.4.0",
"tsx": "^4.19.0",

39
src/cli-date.ts Normal file
View file

@ -0,0 +1,39 @@
import type { DateRange } from './types.js'
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const END_OF_DAY_HOURS = 23
const END_OF_DAY_MINUTES = 59
const END_OF_DAY_SECONDS = 59
const END_OF_DAY_MS = 999
function parseLocalDate(s: string): Date {
if (!ISO_DATE_RE.test(s)) {
throw new Error(`Invalid date format "${s}": expected YYYY-MM-DD`)
}
const [y, m, d] = s.split('-').map(Number) as [number, number, number]
return new Date(y, m - 1, d)
}
export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null {
if (from === undefined && to === undefined) return null
const now = new Date()
const start = from !== undefined ? parseLocalDate(from) : new Date(0)
const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
const end = new Date(
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate(),
END_OF_DAY_HOURS,
END_OF_DAY_MINUTES,
END_OF_DAY_SECONDS,
END_OF_DAY_MS,
)
if (start > end) {
throw new Error(`--from must not be after --to (got ${from} > ${to})`)
}
return { start, end }
}

View file

@ -1,11 +1,18 @@
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases } from './models.js'
import { parseAllSessions } from './parser.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
import { type PeriodData, type ProviderCost } from './menubar-json.js'
import { buildMenubarPayload } from './menubar-json.js'
import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { renderDashboard } from './dashboard.js'
import { parseDateRangeFlags } from './cli-date.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { getAllProviders } from './providers/index.js'
import { readConfig, saveConfig, getConfigFilePath } from './config.js'
import { createRequire } from 'node:module'
@ -14,6 +21,13 @@ const require = createRequire(import.meta.url)
const { version } = require('../package.json')
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
const MS_PER_DAY = 24 * 60 * 60 * 1000
const BACKFILL_DAYS = 365
function toDateString(date: Date): string {
return date.toISOString().slice(0, 10)
}
function getDateRange(period: string): { range: DateRange; label: string } {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
@ -41,7 +55,11 @@ function getDateRange(period: string): { range: DateRange; label: string } {
return { range: { start, end }, label: 'Last 30 Days' }
}
case 'all': {
return { range: { start: new Date(0), end }, label: 'All Time' }
// Cap "All Time" to the last 6 months. Older data is rarely actionable for a cost
// tracker and keeps the parse path bounded so providers like Codex/Cursor with sparse
// data still load in seconds.
const start = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate())
return { range: { start, end }, label: 'Last 6 months' }
}
default: {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
@ -50,7 +68,9 @@ function getDateRange(period: string): { range: DateRange; label: string } {
}
}
function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
function toPeriod(s: string): Period {
if (s === 'today') return 'today'
if (s === 'month') return 'month'
if (s === '30days') return '30days'
@ -58,25 +78,204 @@ function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
return 'week'
}
function collect(val: string, acc: string[]): string[] {
acc.push(val)
return acc
}
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
}
const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
.version(version)
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
program.hook('preAction', async () => {
program.hook('preAction', async (thisCommand) => {
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
}
await loadCurrency()
})
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
const sessions = projects.flatMap(p => p.sessions)
const { code } = getCurrency()
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
// Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
// counts tokens being stored, not served, so it doesn't belong in the denominator.
const cacheHitDenom = totalInput + totalCacheRead
const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
const dailyMap: Record<string, { cost: number; calls: number }> = {}
for (const sess of sessions) {
for (const turn of sess.turns) {
if (!turn.timestamp) { continue }
const day = turn.timestamp.slice(0, 10)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } }
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
dailyMap[day].calls += 1
}
}
}
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
date,
cost: convertCost(d.cost),
calls: d.calls,
}))
const projectList = projects.map(p => ({
name: p.project,
path: p.projectPath,
cost: convertCost(p.totalCostUSD),
avgCostPerSession: p.sessions.length > 0
? convertCost(p.totalCostUSD / p.sessions.length)
: null,
calls: p.totalApiCalls,
sessions: p.sessions.length,
}))
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
for (const sess of sessions) {
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
modelMap[model].calls += d.calls
modelMap[model].cost += d.costUSD
modelMap[model].inputTokens += d.tokens.inputTokens
modelMap[model].outputTokens += d.tokens.outputTokens
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
}
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) }))
const catMap: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
catMap[cat].turns += d.turns
catMap[cat].cost += d.costUSD
catMap[cat].editTurns += d.editTurns
catMap[cat].oneShotTurns += d.oneShotTurns
}
}
const activities = Object.entries(catMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
cost: convertCost(d.cost),
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
}))
const toolMap: Record<string, number> = {}
const mcpMap: Record<string, number> = {}
const bashMap: Record<string, number> = {}
for (const sess of sessions) {
for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
}
for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
}
for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
}
}
const sortedMap = (m: Record<string, number>) =>
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
const topSessions = projects
.flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp?.slice(0, 10) ?? null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
.sort((a, b) => b.cost - a.cost)
.slice(0, 5)
return {
generated: new Date().toISOString(),
currency: code,
period,
periodKey,
overview: {
cost: convertCost(totalCostUSD),
calls: totalCalls,
sessions: totalSessions,
cacheHitPercent,
tokens: {
input: totalInput,
output: totalOutput,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
},
},
daily,
projects: projectList,
models,
activities,
tools: sortedMap(toolMap),
mcpServers: sortedMap(mcpMap),
shellCommands: sortedMap(bashMap),
topSessions,
}
}
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
await renderDashboard(toPeriod(opts.period), opts.provider, opts.refresh)
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}
const period = toPeriod(opts.period)
if (opts.format === 'json') {
await loadPricing()
if (customRange) {
const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}`
const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider),
opts.project,
opts.exclude,
)
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
}
return
}
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
@ -108,6 +307,7 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
label,
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
categories: Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
@ -121,30 +321,154 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
program
.command('status')
.description('Compact status output (today + week + month)')
.option('--format <format>', 'Output format: terminal, menubar, json', 'terminal')
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
await loadPricing()
const pf = opts.provider
if (opts.format === 'menubar') {
const todayRange = getDateRange('today').range
const todayData = buildPeriodData('Today', await parseAllSessions(todayRange, pf))
const weekData = buildPeriodData('7 Days', await parseAllSessions(getDateRange('week').range, pf))
const thirtyDayData = buildPeriodData('30 Days', await parseAllSessions(getDateRange('30days').range, pf))
const monthData = buildPeriodData('Month', await parseAllSessions(getDateRange('month').range, pf))
const todayProviders: ProviderCost[] = []
for (const p of await getAllProviders()) {
const data = await parseAllSessions(todayRange, p.name)
const cost = data.reduce((s, proj) => s + proj.totalCostUSD, 0)
if (cost > 0) todayProviders.push({ name: p.displayName, cost })
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar-json') {
const periodInfo = getDateRange(opts.period)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayEnd = new Date(todayStart.getTime() - 1)
const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY))
const isAllProviders = pf === 'all'
// The daily cache is provider-agnostic: always backfill it from .all so subsequent
// provider-filtered reads can derive per-provider cost+calls from DailyEntry.providers.
const cache = await withDailyCacheLock(async () => {
let c = await loadDailyCache()
const gapStart = c.lastComputedDate
? new Date(new Date(`${c.lastComputedDate}T00:00:00.000Z`).getTime() + MS_PER_DAY)
: new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY)
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all'), opts.project, opts.exclude)
const gapDays = aggregateProjectsIntoDays(gapProjects)
c = addNewDays(c, gapDays, yesterdayStr)
await saveDailyCache(c)
}
return c
})
// CURRENT PERIOD DATA
// - .all provider: assemble from cache + today (fast)
// - specific provider: parse the period range with provider filter (correct, but slower)
let currentData: PeriodData
let scanProjects: ProjectSummary[]
let scanRange: DateRange
if (isAllProviders) {
const todayRange: DateRange = { start: todayStart, end: now }
const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
const todayDays = aggregateProjectsIntoDays(todayProjects)
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
scanProjects = todayProjects
scanRange = todayRange
} else {
const projects = fp(await parseAllSessions(periodInfo.range, pf))
currentData = buildPeriodData(periodInfo.label, projects)
scanProjects = projects
scanRange = periodInfo.range
}
console.log(renderMenubarFormat(todayData, weekData, thirtyDayData, monthData, todayProviders))
// PROVIDERS
// For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
// For specific: just this single provider with its scoped cost.
const allProviders = await getAllProviders()
const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
const providers: ProviderCost[] = []
if (isAllProviders) {
const todayRangeForProviders: DateRange = { start: todayStart, end: now }
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
const rangeStartStr = toDateString(periodInfo.range.start)
const allDaysForProviders = [
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
...todayDaysForProviders.filter(d => d.date >= rangeStartStr),
]
const providerTotals: Record<string, number> = {}
for (const d of allDaysForProviders) {
for (const [name, p] of Object.entries(d.providers)) {
providerTotals[name] = (providerTotals[name] ?? 0) + p.cost
}
}
for (const [name, cost] of Object.entries(providerTotals)) {
providers.push({ name: displayNameByName.get(name) ?? name, cost })
}
for (const p of allProviders) {
if (providers.some(pc => pc.name === p.displayName)) continue
const sources = await p.discoverSessions()
if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 })
}
} else {
const display = displayNameByName.get(pf) ?? pf
providers.push({ name: display, cost: currentData.cost })
}
// DAILY HISTORY (last 365 days)
// Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
// a provider-filtered history without re-parsing. Tokens aren't broken down per provider
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
const historyStartStr = toDateString(new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions({ start: todayStart, end: now }, 'all')))
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory]
const dailyHistory = fullHistory.map(d => {
if (isAllProviders) {
const topModels = Object.entries(d.models)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
.slice(0, 5)
.map(([name, m]) => ({
name,
cost: m.cost,
calls: m.calls,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
}))
return {
date: d.date,
cost: d.cost,
calls: d.calls,
inputTokens: d.inputTokens,
outputTokens: d.outputTokens,
cacheReadTokens: d.cacheReadTokens,
cacheWriteTokens: d.cacheWriteTokens,
topModels,
}
}
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
})
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
return
}
if (opts.format === 'json') {
const todayData = buildPeriodData('today', await parseAllSessions(getDateRange('today').range, pf))
const monthData = buildPeriodData('month', await parseAllSessions(getDateRange('month').range, pf))
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const { code, rate } = getCurrency()
console.log(JSON.stringify({
currency: code,
@ -154,7 +478,7 @@ program
return
}
const monthProjects = await parseAllSessions(getDateRange('month').range, pf)
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
console.log(renderStatusBar(monthProjects))
})
@ -162,18 +486,32 @@ program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
await renderDashboard('today', opts.provider, opts.refresh)
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
return
}
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
await renderDashboard('month', opts.provider, opts.refresh)
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
return
}
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
@ -182,13 +520,16 @@ program
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
.option('-o, --output <path>', 'Output file path')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
const periods: PeriodExport[] = [
{ label: 'Today', projects: await parseAllSessions(getDateRange('today').range, pf) },
{ label: '7 Days', projects: await parseAllSessions(getDateRange('week').range, pf) },
{ label: '30 Days', projects: await parseAllSessions(getDateRange('30days').range, pf) },
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
]
if (periods.every(p => p.projects.length === 0)) {
@ -200,29 +541,37 @@ program
const outputPath = opts.output ?? `${defaultName}.${opts.format}`
let savedPath: string
if (opts.format === 'json') {
savedPath = await exportJson(periods, outputPath)
} else {
savedPath = await exportCsv(periods, outputPath)
try {
if (opts.format === 'json') {
savedPath = await exportJson(periods, outputPath)
} else {
savedPath = await exportCsv(periods, outputPath)
}
} catch (err) {
// Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.)
// throw with a user-readable message. Print just the message, not the stack, so the CLI
// doesn't spray its internals at the user.
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Export failed: ${message}\n`)
process.exit(1)
}
console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`)
})
program
.command('install-menubar')
.description('Install macOS menu bar plugin (SwiftBar/xbar)')
.action(async () => {
const result = await installMenubar()
console.log(result)
})
program
.command('uninstall-menubar')
.description('Remove macOS menu bar plugin')
.action(async () => {
const result = await uninstallMenubar()
console.log(result)
.command('menubar')
.description('Install and launch the macOS menubar app (one command, no clone)')
.option('--force', 'Reinstall even if an older copy is already in ~/Applications')
.action(async (opts: { force?: boolean }) => {
try {
const result = await installMenubarApp({ force: opts.force })
console.log(`\n Ready. ${result.installedPath}\n`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Menubar install failed: ${message}\n`)
process.exit(1)
}
})
program
@ -326,4 +675,16 @@ program
console.log(` Config: ${getConfigFilePath()}\n`)
})
program.parse()
program
.command('optimize')
.description('Find token waste and get exact fixes')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.action(async (opts) => {
await loadPricing()
const { range, label } = getDateRange(opts.period)
const projects = await parseAllSessions(range, opts.provider)
await runOptimize(projects, label, range)
})
program.parse()

149
src/context-budget.ts Normal file
View file

@ -0,0 +1,149 @@
import { readdir } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from './fs-utils.js'
const CHARS_PER_TOKEN = 4
const SYSTEM_BASE_TOKENS = 10400
const TOOL_TOKENS_OVERHEAD = 400
const SKILL_FRONTMATTER_TOKENS = 80
export type ContextBudget = {
systemBase: number
mcpTools: { count: number; tokens: number }
skills: { count: number; tokens: number }
memory: { count: number; tokens: number; files: Array<{ name: string; tokens: number }> }
total: number
modelContext: number
}
function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN)
}
async function readConfigFile(path: string): Promise<Record<string, unknown> | null> {
if (!existsSync(path)) return null
const raw = await readSessionFile(path)
if (raw === null) return null
try { return JSON.parse(raw) } catch { return null }
}
async function countMcpTools(projectPath?: string): Promise<number> {
const home = homedir()
const configPaths = [
join(home, '.claude', 'settings.json'),
join(home, '.claude', 'settings.local.json'),
]
if (projectPath) {
configPaths.push(join(projectPath, '.mcp.json'))
configPaths.push(join(projectPath, '.claude', 'settings.json'))
configPaths.push(join(projectPath, '.claude', 'settings.local.json'))
}
const servers = new Set<string>()
let toolCount = 0
for (const p of configPaths) {
const config = await readConfigFile(p)
if (!config) continue
const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>
for (const name of Object.keys(mcpServers)) {
if (servers.has(name)) continue
servers.add(name)
toolCount += 5
}
}
return toolCount
}
async function countSkills(projectPath?: string): Promise<number> {
const dirs = [join(homedir(), '.claude', 'skills')]
if (projectPath) dirs.push(join(projectPath, '.claude', 'skills'))
let count = 0
for (const dir of dirs) {
if (!existsSync(dir)) continue
try {
const entries = await readdir(dir)
for (const entry of entries) {
const skillFile = join(dir, entry, 'SKILL.md')
if (existsSync(skillFile)) count++
}
} catch { continue }
}
return count
}
async function scanMemoryFiles(projectPath?: string): Promise<Array<{ name: string; tokens: number }>> {
const home = homedir()
const files: Array<{ name: string; tokens: number }> = []
const paths: Array<{ path: string; name: string }> = [
{ path: join(home, '.claude', 'CLAUDE.md'), name: '~/.claude/CLAUDE.md' },
]
if (projectPath) {
paths.push({ path: join(projectPath, 'CLAUDE.md'), name: 'CLAUDE.md' })
paths.push({ path: join(projectPath, '.claude', 'CLAUDE.md'), name: '.claude/CLAUDE.md' })
paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' })
}
for (const { path, name } of paths) {
if (!existsSync(path)) continue
const content = await readSessionFile(path)
if (content === null) continue
files.push({ name, tokens: estimateTokens(content) })
}
return files
}
export async function estimateContextBudget(projectPath?: string, modelContext = 1_000_000): Promise<ContextBudget> {
const mcpToolCount = await countMcpTools(projectPath)
const skillCount = await countSkills(projectPath)
const memoryFiles = await scanMemoryFiles(projectPath)
const mcpTokens = mcpToolCount * TOOL_TOKENS_OVERHEAD
const skillTokens = skillCount * SKILL_FRONTMATTER_TOKENS
const memoryTokens = memoryFiles.reduce((s, f) => s + f.tokens, 0)
const total = SYSTEM_BASE_TOKENS + mcpTokens + skillTokens + memoryTokens
return {
systemBase: SYSTEM_BASE_TOKENS,
mcpTools: { count: mcpToolCount, tokens: mcpTokens },
skills: { count: skillCount, tokens: skillTokens },
memory: { count: memoryFiles.length, tokens: memoryTokens, files: memoryFiles },
total,
modelContext,
}
}
export async function estimateBudgetsByProject(projectPaths: Map<string, string>): Promise<Map<string, ContextBudget>> {
const results = new Map<string, ContextBudget>()
for (const [project, cwd] of projectPaths) {
const budget = await estimateContextBudget(cwd)
results.set(project, budget)
}
return results
}
export async function discoverProjectCwd(sessionDir: string): Promise<string | null> {
let files: string[]
try {
files = (await readdir(sessionDir)).filter(f => f.endsWith('.jsonl'))
} catch { return null }
if (files.length === 0) return null
const content = await readSessionFile(join(sessionDir, files[0]))
if (content === null) return null
for (const line of content.split('\n')) {
if (!line.trim()) continue
try {
const entry = JSON.parse(line)
if (entry.cwd && typeof entry.cwd === 'string') return entry.cwd
} catch { continue }
}
return null
}

View file

@ -12,6 +12,17 @@ type CurrencyState = {
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to='
// Defensive bounds on any fetched FX rate. Outside this band the rate is either a parser bug
// or a tampered Frankfurter response, and we refuse to multiply it into displayed costs.
const MIN_VALID_FX_RATE = 0.0001
const MAX_VALID_FX_RATE = 1_000_000
function isValidRate(value: unknown): value is number {
return typeof value === 'number'
&& Number.isFinite(value)
&& value >= MIN_VALID_FX_RATE
&& value <= MAX_VALID_FX_RATE
}
let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
@ -54,18 +65,22 @@ function getRateCachePath(): string {
async function fetchRate(code: string): Promise<number> {
const response = await fetch(`${FRANKFURTER_URL}${code}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json() as { rates: Record<string, number> }
const rate = data.rates[code]
if (!rate) throw new Error(`No rate returned for ${code}`)
const data = await response.json() as { rates?: Record<string, unknown> }
const rate = data.rates?.[code]
if (!isValidRate(rate)) throw new Error(`Invalid rate returned for ${code}`)
return rate
}
async function loadCachedRate(code: string): Promise<number | null> {
try {
const raw = await readFile(getRateCachePath(), 'utf-8')
const cached = JSON.parse(raw) as { timestamp: number; code: string; rate: number }
if (cached.code !== code) return null
const cached = JSON.parse(raw) as Partial<{ timestamp: number; code: string; rate: number }>
// Validate every field -- a tampered cache file could set rate to a string, null, or
// Infinity and break downstream math silently.
if (typeof cached.code !== 'string' || cached.code !== code) return null
if (typeof cached.timestamp !== 'number' || !Number.isFinite(cached.timestamp)) return null
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
if (!isValidRate(cached.rate)) return null
return cached.rate
} catch {
return null

118
src/daily-cache.ts Normal file
View file

@ -0,0 +1,118 @@
import { randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
export const DAILY_CACHE_VERSION = 2
const DAILY_CACHE_FILENAME = 'daily-cache.json'
export type DailyEntry = {
date: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
editTurns: number
oneShotTurns: number
models: Record<string, {
calls: number
cost: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}>
categories: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }>
providers: Record<string, { calls: number; cost: number }>
}
export type DailyCache = {
version: number
lastComputedDate: string | null
days: DailyEntry[]
}
function getCacheDir(): string {
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
}
function getCachePath(): string {
return join(getCacheDir(), DAILY_CACHE_FILENAME)
}
function emptyCache(): DailyCache {
return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] }
}
function isValidCache(parsed: unknown): parsed is DailyCache {
if (!parsed || typeof parsed !== 'object') return false
const c = parsed as Partial<DailyCache>
if (c.version !== DAILY_CACHE_VERSION) return false
if (!Array.isArray(c.days)) return false
return true
}
export async function loadDailyCache(): Promise<DailyCache> {
const path = getCachePath()
if (!existsSync(path)) return emptyCache()
try {
const raw = await readFile(path, 'utf-8')
const parsed: unknown = JSON.parse(raw)
if (!isValidCache(parsed)) return emptyCache()
return parsed
} catch {
return emptyCache()
}
}
export async function saveDailyCache(cache: DailyCache): Promise<void> {
const dir = getCacheDir()
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
const finalPath = getCachePath()
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
const payload = JSON.stringify(cache)
const handle = await open(tempPath, 'w', 0o600)
try {
await handle.writeFile(payload, { encoding: 'utf-8' })
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(tempPath, finalPath)
} catch (err) {
try { await unlink(tempPath) } catch { /* ignore */ }
throw err
}
}
export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate: string): DailyCache {
const seen = new Set(cache.days.map(d => d.date))
const merged = [...cache.days]
for (const day of incoming) {
if (seen.has(day.date)) continue
seen.add(day.date)
merged.push(day)
}
merged.sort((a, b) => a.date.localeCompare(b.date))
const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate
? cache.lastComputedDate
: newestDate
return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: merged }
}
export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
return cache.days.filter(d => d.date >= start && d.date <= end)
}
let lockChain: Promise<unknown> = Promise.resolve()
export function withDailyCacheLock<T>(fn: () => Promise<T>): Promise<T> {
const next = lockChain.then(() => fn())
lockChain = next.catch(() => undefined)
return next
}

View file

@ -1,14 +1,18 @@
import { homedir } from 'os'
import React, { useState, useCallback, useEffect } from 'react'
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost, formatTokens } from './format.js'
import { parseAllSessions } from './parser.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
import { loadPricing } from './models.js'
import { getAllProviders } from './providers/index.js'
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
import { join } from 'path'
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
type View = 'dashboard' | 'optimize'
const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
const PERIOD_LABELS: Record<Period, string> = {
@ -71,6 +75,8 @@ const CATEGORY_COLORS: Record<TaskCategory, string> = {
general: '#666666',
}
const IMPACT_PANEL_COLORS: Record<string, string> = { high: '#F55B5B', medium: ORANGE, low: DIM }
function toHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
}
@ -79,7 +85,6 @@ function lerp(a: number, b: number, t: number): number {
return a + t * (b - a)
}
// Blue -> amber -> orange gradient across the bar width
function gradientColor(pct: number): string {
if (pct <= 0.33) {
const t = pct / 0.33
@ -132,6 +137,8 @@ function HBar({ value, max, width }: { value: number; max: number; width: number
)
}
const PANEL_CHROME = 4
function Panel({ title, color, children, width }: { title: string; color: string; children: React.ReactNode; width: number }) {
return (
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width} overflowX="hidden">
@ -216,32 +223,31 @@ const _homeEncoded = homedir().replace(/\//g, '-')
function shortProject(encoded: string): string {
let path = encoded.replace(/^-/, '')
if (path.startsWith(_homeEncoded.replace(/^-/, ''))) {
path = path.slice(_homeEncoded.replace(/^-/, '').length).replace(/^-/, '')
}
path = path
.replace(/^private-tmp-[^-]+-[^-]+-/, '') // /private/tmp/<org>/<env>/
.replace(/^private-tmp-/, '')
.replace(/^tmp-/, '')
path = path.replace(/^private-tmp-[^-]+-[^-]+-/, '').replace(/^private-tmp-/, '').replace(/^tmp-/, '')
if (!path) return 'home'
const parts = path.split('-').filter(Boolean)
if (parts.length <= 3) return parts.join('/')
return parts.slice(-3).join('/')
}
const PROJECT_COL_AVG = 7
const PROJECT_COL_BASE_WIDTH = 30
const PROJECT_COL_WITH_OVERHEAD_WIDTH = 40
function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map<string, ContextBudget> }) {
const maxCost = Math.max(...projects.map(p => p.totalCostUSD))
const nw = Math.max(8, pw - bw - 30)
const hasBudgets = budgets && budgets.size > 0
const nw = Math.max(8, pw - bw - (hasBudgets ? PROJECT_COL_WITH_OVERHEAD_WIDTH : PROJECT_COL_BASE_WIDTH))
return (
<Panel title="By Project" color={PANEL_COLORS.project} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}</Text>
<Text dimColor wrap="truncate-end">
{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''}
</Text>
{projects.slice(0, 8).map((project, i) => {
const budget = budgets?.get(project.project)
const avgCost = project.sessions.length > 0
? formatCost(project.totalCostUSD / project.sessions.length)
: '-'
@ -252,6 +258,7 @@ function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
<Text>{String(project.sessions.length).padStart(6)}</Text>
{hasBudgets && <Text color="#7B9EF5">{(budget ? formatTokens(budget.total) : '-').padStart(10)}</Text>}
</Text>
)
})}
@ -317,7 +324,6 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p
}
const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
return (
<Panel title="By Activity" color={PANEL_COLORS.activity} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)}</Text>
@ -326,9 +332,7 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p
return (
<Text key={cat} wrap="truncate-end">
<HBar value={data.costUSD} max={maxCost} width={bw} />
<Text color={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}>
{' '}{fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)}
</Text>
<Text color={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}> {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)}</Text>
<Text color={GOLD}>{formatCost(data.costUSD).padStart(8)}</Text>
<Text>{String(data.turns).padStart(6)}</Text>
<Text color={data.editTurns === 0 ? DIM : oneShotPct === '100%' ? '#5BF58C' : ORANGE}>{String(oneShotPct).padStart(7)}</Text>
@ -344,11 +348,7 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
for (const project of projects) {
for (const session of project.sessions) {
for (const [tool, data] of Object.entries(session.toolBreakdown)) {
if (filterPrefix) {
if (!tool.startsWith(filterPrefix)) continue
} else {
if (tool.startsWith('lang:')) continue
}
if (filterPrefix) { if (!tool.startsWith(filterPrefix)) continue } else { if (tool.startsWith('lang:')) continue }
toolTotals[tool] = (toolTotals[tool] ?? 0) + data.calls
}
}
@ -356,7 +356,6 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
const sorted = Object.entries(toolTotals).sort(([, a], [, b]) => b - a)
const maxCalls = sorted[0]?.[1] ?? 0
const nw = Math.max(6, pw - bw - 15)
return (
<Panel title={title ?? 'Core Tools'} color={PANEL_COLORS.tools} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
@ -390,7 +389,7 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
}
const maxCost = top[0].totalCostUSD
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1)
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME)
return (
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
@ -415,29 +414,16 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const mcpTotals: Record<string, number> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const [server, data] of Object.entries(session.mcpBreakdown)) {
mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls
}
}
}
for (const project of projects) { for (const session of project.sessions) { for (const [server, data] of Object.entries(session.mcpBreakdown)) { mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls } } }
const sorted = Object.entries(mcpTotals).sort(([, a], [, b]) => b - a)
if (sorted.length === 0) {
return <Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}><Text dimColor>No MCP usage</Text></Panel>
}
if (sorted.length === 0) return <Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}><Text dimColor>No MCP usage</Text></Panel>
const maxCalls = sorted[0]?.[1] ?? 0
const nw = Math.max(6, pw - bw - 15)
return (
<Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}</Text>
{sorted.slice(0, 8).map(([server, calls]) => (
<Text key={server} wrap="truncate-end">
<HBar value={calls} max={maxCalls} width={bw} />
<Text> {fit(server, nw)}</Text>
<Text>{String(calls).padStart(6)}</Text>
</Text>
<Text key={server} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(server, nw)}</Text><Text>{String(calls).padStart(6)}</Text></Text>
))}
</Panel>
)
@ -445,29 +431,16 @@ function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: nu
function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const bashTotals: Record<string, number> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const [cmd, data] of Object.entries(session.bashBreakdown)) {
bashTotals[cmd] = (bashTotals[cmd] ?? 0) + data.calls
}
}
}
for (const project of projects) { for (const session of project.sessions) { for (const [cmd, data] of Object.entries(session.bashBreakdown)) { bashTotals[cmd] = (bashTotals[cmd] ?? 0) + data.calls } } }
const sorted = Object.entries(bashTotals).sort(([, a], [, b]) => b - a)
if (sorted.length === 0) {
return <Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}><Text dimColor>No shell commands</Text></Panel>
}
if (sorted.length === 0) return <Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}><Text dimColor>No shell commands</Text></Panel>
const maxCalls = sorted[0]?.[1] ?? 0
const nw = Math.max(6, pw - bw - 15)
return (
<Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
{sorted.slice(0, 10).map(([cmd, calls]) => (
<Text key={cmd} wrap="truncate-end">
<HBar value={calls} max={maxCalls} width={bw} />
<Text> {fit(cmd, nw)}</Text>
<Text>{String(calls).padStart(7)}</Text>
</Text>
<Text key={cmd} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(cmd, nw)}</Text><Text>{String(calls).padStart(7)}</Text></Text>
))}
</Panel>
)
@ -481,16 +454,9 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
opencode: 'OpenCode',
pi: 'Pi',
}
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }
function getProviderDisplayName(name: string): string {
return PROVIDER_DISPLAY_NAMES[name] ?? name
}
function PeriodTabs({ active, providerName, showProvider }: {
active: Period
providerName?: string
showProvider?: boolean
}) {
function PeriodTabs({ active, providerName, showProvider }: { active: Period; providerName?: string; showProvider?: boolean }) {
return (
<Box justifyContent="space-between" paddingX={1}>
<Box gap={1}>
@ -501,41 +467,82 @@ function PeriodTabs({ active, providerName, showProvider }: {
))}
</Box>
{showProvider && providerName && (
<Box>
<Text color={DIM}>| </Text>
<Text color={ORANGE} bold>[p]</Text>
<Text bold color={PROVIDER_COLORS[providerName] ?? ORANGE}> {getProviderDisplayName(providerName)}</Text>
</Box>
<Box><Text color={DIM}>| </Text><Text color={ORANGE} bold>[p]</Text><Text bold color={PROVIDER_COLORS[providerName] ?? ORANGE}> {getProviderDisplayName(providerName)}</Text></Box>
)}
</Box>
)
}
function StatusBar({ width, showProvider }: { width: number; showProvider?: boolean }) {
function FindingAction({ action }: { action: WasteAction }) {
const lines = action.type === 'file-content' ? action.content.split('\n') : action.type === 'command' ? action.text.split('\n') : [action.text]
return (<><Text dimColor>{action.label}</Text>{lines.map((line, i) => <Text key={i} color="#5BF5E0"> {line}</Text>)}</>)
}
function FindingPanel({ index, finding, costRate, width }: { index: number; finding: WasteFinding; costRate: number; width: number }) {
const costSaved = finding.tokensSaved * costRate
const color = IMPACT_PANEL_COLORS[finding.impact] ?? DIM
const label = finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1)
const trendBadge = finding.trend === 'improving' ? ' improving \u2193' : ''
return (
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width}>
<Text wrap="truncate-end">
<Text bold>{index}. {finding.title}</Text>
<Text> </Text>
<Text color={color}>{label}</Text>
{trendBadge && <Text color="#5BF5A0">{trendBadge}</Text>}
</Text>
<Text dimColor wrap="wrap">{finding.explanation}</Text>
<Text color={GOLD}>Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)})</Text>
<Text> </Text>
<FindingAction action={finding.fix} />
</Box>
)
}
const GRADE_COLORS: Record<string, string> = { A: '#5BF5A0', B: '#5BF5A0', C: GOLD, D: ORANGE, F: '#F55B5B' }
function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string }) {
const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0)
const totalCost = totalTokens * costRate
const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0
const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1)
const gradeColor = GRADE_COLORS[healthGrade] ?? DIM
return (
<Box flexDirection="column" width={width}>
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={width}>
<Text wrap="truncate-end">
<Text bold color={ORANGE}>CodeBurn Optimize</Text>
<Text dimColor> {label} Setup: </Text>
<Text bold color={gradeColor}>{healthGrade}</Text>
<Text dimColor> ({healthScore}/100)</Text>
</Text>
<Text color="#5BF5A0" wrap="truncate-end">Savings: ~{formatTokens(totalTokens)} tokens (~{formatCost(totalCost)}, ~{pct}% of spend)</Text>
</Box>
{findings.map((f, i) => <FindingPanel key={i} index={i + 1} finding={f} costRate={costRate} width={width} />)}
<Box paddingX={1} width={width}><Text dimColor>Token estimates are approximate.</Text></Box>
</Box>
)
}
function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean }) {
const isOptimize = view === 'optimize'
return (
<Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}>
<Text>
<Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text>
<Text dimColor> switch </Text>
<Text color={ORANGE} bold>q</Text>
<Text dimColor> quit </Text>
<Text color={ORANGE} bold>1</Text>
<Text dimColor> today </Text>
<Text color={ORANGE} bold>2</Text>
<Text dimColor> week </Text>
<Text color={ORANGE} bold>3</Text>
<Text dimColor> 30 days </Text>
<Text color={ORANGE} bold>4</Text>
<Text dimColor> month </Text>
<Text color={ORANGE} bold>5</Text>
<Text dimColor> all time</Text>
{showProvider && (
<>
<Text dimColor> </Text>
<Text color={ORANGE} bold>p</Text>
<Text dimColor> provider</Text>
</>
{isOptimize
? <><Text color={ORANGE} bold>b</Text><Text dimColor> back </Text></>
: <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> switch </Text></>}
<Text color={ORANGE} bold>q</Text><Text dimColor> quit </Text>
<Text color={ORANGE} bold>1</Text><Text dimColor> today </Text>
<Text color={ORANGE} bold>2</Text><Text dimColor> week </Text>
<Text color={ORANGE} bold>3</Text><Text dimColor> 30 days </Text>
<Text color={ORANGE} bold>4</Text><Text dimColor> month </Text>
<Text color={ORANGE} bold>5</Text><Text dimColor> all time</Text>
{!isOptimize && optimizeAvailable && findingCount != null && findingCount > 0 && (
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text><Text color="#F55B5B"> ({findingCount})</Text></>
)}
{showProvider && (<><Text dimColor> </Text><Text color={ORANGE} bold>p</Text><Text dimColor> provider</Text></>)}
</Text>
</Box>
)
@ -546,58 +553,34 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
return <>{children}</>
}
function DashboardContent({ projects, period, columns, activeProvider }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string }) {
function DashboardContent({ projects, period, columns, activeProvider, budgets }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget> }) {
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
const isCursor = activeProvider === 'cursor'
if (projects.length === 0) {
return (
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}>
<Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text>
</Panel>
)
}
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
const pw = wide ? halfWidth : dashWidth
// undefined = no cutoff (show all days); 31 for month/30-day ranges; 14 for shorter periods
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
return (
<Box flexDirection="column" width={dashWidth}>
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} />
<Row wide={wide} width={dashWidth}>
<DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} />
<ProjectBreakdown projects={projects} pw={pw} bw={barWidth} />
</Row>
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
<Row wide={wide} width={dashWidth}>
<ActivityBreakdown projects={projects} pw={pw} bw={barWidth} />
<ModelBreakdown projects={projects} pw={pw} bw={barWidth} />
</Row>
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
{isCursor ? (
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
) : (
<>
<Row wide={wide} width={dashWidth}>
<ToolBreakdown projects={projects} pw={pw} bw={barWidth} />
<BashBreakdown projects={projects} pw={pw} bw={barWidth} />
</Row>
<McpBreakdown projects={projects} pw={dashWidth} bw={barWidth} />
</>
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><McpBreakdown projects={projects} pw={dashWidth} bw={barWidth} /></>
)}
</Box>
)
}
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds }: {
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds, projectFilter, excludeFilter }: {
initialProjects: ProjectSummary[]
initialPeriod: Period
initialProvider: string
refreshSeconds?: number
projectFilter?: string[]
excludeFilter?: string[]
}) {
const { exit } = useApp()
const [period, setPeriod] = useState<Period>(initialPeriod)
@ -605,34 +588,64 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const [loading, setLoading] = useState(false)
const [activeProvider, setActiveProvider] = useState(initialProvider)
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
const [view, setView] = useState<View>('dashboard')
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
const multipleProviders = detectedProviders.length > 1
const optimizeAvailable = activeProvider === 'all' || activeProvider === 'claude'
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const findingCount = optimizeResult?.findings.length ?? 0
useEffect(() => {
let cancelled = false
async function detect() {
const found: string[] = []
const allProviders = await getAllProviders()
for (const p of allProviders) {
const sessions = await p.discoverSessions()
if (sessions.length > 0) found.push(p.name)
}
if (!cancelled) {
setDetectedProviders(found)
}
for (const p of await getAllProviders()) { const s = await p.discoverSessions(); if (s.length > 0) found.push(p.name) }
if (!cancelled) setDetectedProviders(found)
}
detect()
return () => { cancelled = true }
}, [])
useEffect(() => {
let cancelled = false
async function loadBudgets() {
const claudeDir = join(homedir(), '.claude', 'projects')
const budgets = new Map<string, ContextBudget>()
for (const project of projects.slice(0, 8)) {
if (cancelled) return
const cwd = await discoverProjectCwd(join(claudeDir, project.project))
if (!cwd) continue
budgets.set(project.project, await estimateContextBudget(cwd))
}
if (!cancelled) setProjectBudgets(budgets)
}
loadBudgets()
return () => { cancelled = true }
}, [projects])
useEffect(() => {
if (!optimizeAvailable) { setOptimizeResult(null); return }
let cancelled = false
async function scan() {
if (projects.length === 0) { setOptimizeResult(null); return }
const result = await scanAndDetect(projects, getDateRange(period))
if (!cancelled) setOptimizeResult(result)
}
scan()
return () => { cancelled = true }
}, [projects, period, optimizeAvailable])
const reloadData = useCallback(async (p: Period, prov: string) => {
setLoading(true)
setOptimizeResult(null)
const range = getDateRange(p)
const data = await parseAllSessions(range, prov)
const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter)
setProjects(data)
setLoading(false)
}, [])
}, [projectFilter, excludeFilter])
useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return
@ -640,46 +653,34 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
return () => clearInterval(id)
}, [refreshSeconds, period, activeProvider, reloadData])
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const switchPeriod = useCallback((newPeriod: Period) => {
if (newPeriod === period) return
setPeriod(newPeriod)
const switchPeriod = useCallback((np: Period) => {
if (np === period) return
setPeriod(np); setView('dashboard')
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
reloadData(newPeriod, activeProvider)
}, 600)
debounceRef.current = setTimeout(() => { reloadData(np, activeProvider) }, 600)
}, [period, activeProvider, reloadData])
const switchPeriodImmediate = useCallback(async (newPeriod: Period) => {
if (newPeriod === period) return
setPeriod(newPeriod)
const switchPeriodImmediate = useCallback(async (np: Period) => {
if (np === period) return
setPeriod(np); setView('dashboard')
if (debounceRef.current) clearTimeout(debounceRef.current)
await reloadData(newPeriod, activeProvider)
await reloadData(np, activeProvider)
}, [period, activeProvider, reloadData])
useInput((input, key) => {
if (input === 'q') {
exit()
return
}
if (input === 'q') { exit(); return }
if (input === 'o' && findingCount > 0 && view === 'dashboard' && optimizeAvailable) { setView('optimize'); return }
if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); return }
if (input === 'p' && multipleProviders) {
const options = ['all', ...detectedProviders]
const idx = options.indexOf(activeProvider)
const next = options[(idx + 1) % options.length]
setActiveProvider(next)
const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length]
setActiveProvider(next); setView('dashboard')
if (debounceRef.current) clearTimeout(debounceRef.current)
reloadData(period, next)
return
reloadData(period, next); return
}
const idx = PERIODS.indexOf(period)
if (key.leftArrow) {
switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length])
} else if (key.rightArrow || key.tab) {
switchPeriod(PERIODS[(idx + 1) % PERIODS.length])
} else if (input === '1') switchPeriodImmediate('today')
if (key.leftArrow && view === 'dashboard') switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length])
else if ((key.rightArrow || key.tab) && view === 'dashboard') switchPeriod(PERIODS[(idx + 1) % PERIODS.length])
else if (input === '1') switchPeriodImmediate('today')
else if (input === '2') switchPeriodImmediate('week')
else if (input === '3') switchPeriodImmediate('30days')
else if (input === '4') switchPeriodImmediate('month')
@ -690,10 +691,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
return (
<Box flexDirection="column" width={dashWidth}>
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}>
<Text dimColor>Loading {PERIOD_LABELS[period]}...</Text>
</Panel>
<StatusBar width={dashWidth} showProvider={multipleProviders} />
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {PERIOD_LABELS[period]}...</Text></Panel>
<StatusBar width={dashWidth} showProvider={multipleProviders} view="dashboard" findingCount={0} optimizeAvailable={false} />
</Box>
)
}
@ -701,8 +700,10 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
return (
<Box flexDirection="column" width={dashWidth}>
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} />
<StatusBar width={dashWidth} showProvider={multipleProviders} />
{view === 'optimize' && optimizeResult
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} />
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} />}
<StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} />
</Box>
)
}
@ -718,23 +719,18 @@ function StaticDashboard({ projects, period, activeProvider }: { projects: Proje
)
}
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number): Promise<void> {
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null): Promise<void> {
await loadPricing()
const range = getDateRange(period)
const projects = await parseAllSessions(range, provider)
const range = customRange ?? getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const isTTY = process.stdin.isTTY && process.stdout.isTTY
if (isTTY) {
const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} />
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
)
await waitUntilExit()
} else {
const { unmount } = render(
<StaticDashboard projects={projects} period={period} activeProvider={provider} />,
{ patchConsole: false }
)
const { unmount } = render(<StaticDashboard projects={projects} period={period} activeProvider={provider} />, { patchConsole: false })
unmount()
}
}

142
src/day-aggregator.ts Normal file
View file

@ -0,0 +1,142 @@
import type { DailyEntry } from './daily-cache.js'
import type { PeriodData } from './menubar-json.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
function emptyEntry(date: string): DailyEntry {
return {
date,
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: {},
categories: {},
providers: {},
}
}
function dateKey(iso: string): string {
return iso.slice(0, 10)
}
export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {
const byDate = new Map<string, DailyEntry>()
const ensure = (date: string): DailyEntry => {
let d = byDate.get(date)
if (!d) { d = emptyEntry(date); byDate.set(date, d) }
return d
}
for (const project of projects) {
for (const session of project.sessions) {
const sessionDate = dateKey(session.firstTimestamp)
ensure(sessionDate).sessions += 1
for (const turn of session.turns) {
if (turn.assistantCalls.length === 0) continue
const turnDate = dateKey(turn.assistantCalls[0]!.timestamp)
const turnDay = ensure(turnDate)
const editTurns = turn.hasEdits ? 1 : 0
const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
turnDay.editTurns += editTurns
turnDay.oneShotTurns += oneShotTurns
const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
cat.turns += 1
cat.cost += turnCost
cat.editTurns += editTurns
cat.oneShotTurns += oneShotTurns
turnDay.categories[turn.category] = cat
for (const call of turn.assistantCalls) {
const callDate = dateKey(call.timestamp)
const callDay = ensure(callDate)
callDay.cost += call.costUSD
callDay.calls += 1
callDay.inputTokens += call.usage.inputTokens
callDay.outputTokens += call.usage.outputTokens
callDay.cacheReadTokens += call.usage.cacheReadInputTokens
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
const model = callDay.models[call.model] ?? {
calls: 0, cost: 0,
inputTokens: 0, outputTokens: 0,
cacheReadTokens: 0, cacheWriteTokens: 0,
}
model.calls += 1
model.cost += call.costUSD
model.inputTokens += call.usage.inputTokens
model.outputTokens += call.usage.outputTokens
model.cacheReadTokens += call.usage.cacheReadInputTokens
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
callDay.models[call.model] = model
const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0 }
provider.calls += 1
provider.cost += call.costUSD
callDay.providers[call.provider] = provider
}
}
}
}
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date))
}
export function buildPeriodDataFromDays(days: DailyEntry[], label: string): PeriodData {
let cost = 0, calls = 0, sessions = 0
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
for (const d of days) {
cost += d.cost
calls += d.calls
sessions += d.sessions
inputTokens += d.inputTokens
outputTokens += d.outputTokens
cacheReadTokens += d.cacheReadTokens
cacheWriteTokens += d.cacheWriteTokens
for (const [name, m] of Object.entries(d.models)) {
const acc = modelTotals[name] ?? { calls: 0, cost: 0 }
acc.calls += m.calls
acc.cost += m.cost
modelTotals[name] = acc
}
for (const [cat, c] of Object.entries(d.categories)) {
const acc = catTotals[cat] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
acc.turns += c.turns
acc.cost += c.cost
acc.editTurns += c.editTurns
acc.oneShotTurns += c.oneShotTurns
catTotals[cat] = acc
}
}
return {
label,
cost,
calls,
sessions,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
categories: Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
models: Object.entries(modelTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, d]) => ({ name, ...d })),
}
}

View file

@ -1,8 +1,8 @@
import { writeFile } from 'fs/promises'
import { resolve } from 'path'
import { writeFile, mkdir, readdir, stat, rm } from 'fs/promises'
import { dirname, join, resolve } from 'path'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { getCostColumnHeader, convertCost } from './currency.js'
import { getCurrency, convertCost } from './currency.js'
function escCsv(s: string): string {
const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s
@ -12,15 +12,47 @@ function escCsv(s: string): string {
return sanitized
}
function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
const daily: Record<string, { cost: number; calls: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
type Row = Record<string, string | number>
function rowsToCsv(rows: Row[]): string {
if (rows.length === 0) return ''
const headers = Object.keys(rows[0])
const lines = [headers.map(escCsv).join(',')]
for (const row of rows) {
lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(','))
}
return lines.join('\n') + '\n'
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
function pct(n: number, total: number): number {
return total > 0 ? round2((n / total) * 100) : 0
}
type DailyAgg = {
cost: number
calls: number
input: number
output: number
cacheRead: number
cacheWrite: number
sessions: Set<string>
}
function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
const daily: Record<string, DailyAgg> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
const day = turn.timestamp.slice(0, 10)
if (!daily[day]) daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
if (!daily[day]) {
daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() }
}
daily[day].sessions.add(session.sessionId)
for (const call of turn.assistantCalls) {
daily[day].cost += call.costUSD
daily[day].calls++
@ -32,11 +64,13 @@ function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string
}
}
}
const { code } = getCurrency()
return Object.entries(daily).sort().map(([date, d]) => ({
Period: period,
Date: date,
[getCostColumnHeader()]: convertCost(d.cost),
[`Cost (${code})`]: round2(convertCost(d.cost)),
'API Calls': d.calls,
Sessions: d.sessions.size,
'Input Tokens': d.input,
'Output Tokens': d.output,
'Cache Read Tokens': d.cacheRead,
@ -44,7 +78,7 @@ function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string
}))
}
function buildActivityRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
function buildActivityRows(projects: ProjectSummary[], period: string): Row[] {
const catTotals: Record<string, { turns: number; cost: number }> = {}
for (const project of projects) {
for (const session of project.sessions) {
@ -55,40 +89,53 @@ function buildActivityRows(projects: ProjectSummary[]): Array<Record<string, str
}
}
}
const totalCost = Object.values(catTotals).reduce((s, d) => s + d.cost, 0)
const { code } = getCurrency()
return Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({
Period: period,
Activity: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
[getCostColumnHeader()]: convertCost(d.cost),
[`Cost (${code})`]: round2(convertCost(d.cost)),
'Share (%)': pct(d.cost, totalCost),
Turns: d.turns,
}))
}
function buildModelRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
const modelTotals: Record<string, { calls: number; cost: number; input: number; output: number }> = {}
function buildModelRows(projects: ProjectSummary[], period: string): Row[] {
const modelTotals: Record<string, { calls: number; cost: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const [model, d] of Object.entries(session.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0 }
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
modelTotals[model].input += d.tokens.inputTokens
modelTotals[model].output += d.tokens.outputTokens
modelTotals[model].cacheRead += d.tokens.cacheReadInputTokens ?? 0
modelTotals[model].cacheWrite += d.tokens.cacheCreationInputTokens ?? 0
}
}
}
const totalCost = Object.values(modelTotals).reduce((s, d) => s + d.cost, 0)
const { code } = getCurrency()
return Object.entries(modelTotals)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([model, d]) => ({
Period: period,
Model: model,
[getCostColumnHeader()]: convertCost(d.cost),
[`Cost (${code})`]: round2(convertCost(d.cost)),
'Share (%)': pct(d.cost, totalCost),
'API Calls': d.calls,
'Input Tokens': d.input,
'Output Tokens': d.output,
'Cache Read Tokens': d.cacheRead,
'Cache Write Tokens': d.cacheWrite,
}))
}
function buildToolRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
function buildToolRows(projects: ProjectSummary[]): Row[] {
const toolTotals: Record<string, number> = {}
for (const project of projects) {
for (const session of project.sessions) {
@ -97,12 +144,17 @@ function buildToolRows(projects: ProjectSummary[]): Array<Record<string, string
}
}
}
const total = Object.values(toolTotals).reduce((s, n) => s + n, 0)
return Object.entries(toolTotals)
.sort(([, a], [, b]) => b - a)
.map(([tool, calls]) => ({ Tool: tool, Calls: calls }))
.map(([tool, calls]) => ({
Tool: tool,
Calls: calls,
'Share (%)': pct(calls, total),
}))
}
function buildBashRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
function buildBashRows(projects: ProjectSummary[]): Row[] {
const bashTotals: Record<string, number> = {}
for (const project of projects) {
for (const session of project.sessions) {
@ -111,28 +163,48 @@ function buildBashRows(projects: ProjectSummary[]): Array<Record<string, string
}
}
}
const total = Object.values(bashTotals).reduce((s, n) => s + n, 0)
return Object.entries(bashTotals)
.sort(([, a], [, b]) => b - a)
.map(([cmd, calls]) => ({ Command: cmd, Calls: calls }))
.map(([cmd, calls]) => ({
Command: cmd,
Calls: calls,
'Share (%)': pct(calls, total),
}))
}
function buildProjectRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
return projects.map(p => ({
Project: p.projectPath,
[getCostColumnHeader()]: convertCost(p.totalCostUSD),
'API Calls': p.totalApiCalls,
Sessions: p.sessions.length,
}))
function buildProjectRows(projects: ProjectSummary[]): Row[] {
const { code } = getCurrency()
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
return projects
.slice()
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
.map(p => ({
Project: p.projectPath,
[`Cost (${code})`]: round2(convertCost(p.totalCostUSD)),
[`Avg/Session (${code})`]: p.sessions.length > 0 ? round2(convertCost(p.totalCostUSD / p.sessions.length)) : '',
'Share (%)': pct(p.totalCostUSD, total),
'API Calls': p.totalApiCalls,
Sessions: p.sessions.length,
}))
}
function rowsToCsv(rows: Array<Record<string, string | number>>): string {
if (rows.length === 0) return ''
const headers = Object.keys(rows[0])
const lines = [headers.map(escCsv).join(',')]
for (const row of rows) {
lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(','))
function buildSessionRows(projects: ProjectSummary[]): Row[] {
const { code } = getCurrency()
const rows: Row[] = []
for (const p of projects) {
for (const s of p.sessions) {
rows.push({
Project: p.projectPath,
'Session ID': s.sessionId,
'Started At': s.firstTimestamp ?? '',
[`Cost (${code})`]: round2(convertCost(s.totalCostUSD)),
'API Calls': s.apiCalls,
Turns: s.turns.length,
})
}
}
return lines.join('\n')
return rows.sort((a, b) => (b[`Cost (${code})`] as number) - (a[`Cost (${code})`] as number))
}
export type PeriodExport = {
@ -140,77 +212,140 @@ export type PeriodExport = {
projects: ProjectSummary[]
}
function buildSummaryRow(period: PeriodExport): Record<string, string | number> {
const cost = period.projects.reduce((s, p) => s + p.totalCostUSD, 0)
const calls = period.projects.reduce((s, p) => s + p.totalApiCalls, 0)
const sessions = period.projects.reduce((s, p) => s + p.sessions.length, 0)
return { Period: period.label, [getCostColumnHeader()]: convertCost(cost), 'API Calls': calls, Sessions: sessions }
function buildSummaryRows(periods: PeriodExport[]): Row[] {
const { code } = getCurrency()
return periods.map(p => {
const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0)
const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0)
const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0)
const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0).length
return {
Period: p.label,
[`Cost (${code})`]: round2(convertCost(cost)),
'API Calls': calls,
Sessions: sessions,
Projects: projectCount,
}
})
}
function buildReadme(periods: PeriodExport[]): string {
const { code } = getCurrency()
const generated = new Date().toISOString()
const lines = [
'CodeBurn Usage Export',
'====================',
'',
`Generated: ${generated}`,
`Currency: ${code}`,
`Periods: ${periods.map(p => p.label).join(', ')}`,
'',
'Files',
'-----',
' summary.csv One row per period. Headline totals.',
' daily.csv Day-by-day breakdown, Period column distinguishes the window.',
' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).',
' models.csv Spend per model with token totals and cache usage.',
' projects.csv Spend per project folder (30-day window).',
' sessions.csv One row per session (30-day window) with session IDs and costs.',
' tools.csv Tool invocations and share (30-day window).',
' shell-commands.csv Shell commands executed via Bash tool (30-day window).',
'',
'Notes',
'-----',
' Every cost column is already converted to the active currency. Tokens are raw integer',
' counts from provider telemetry. Share (%) is relative to the period/table total.',
'',
]
return lines.join('\n')
}
/// Sentinel file dropped into every folder we create so we can safely overwrite an older
/// codeburn export without ever deleting a user's unrelated files by accident.
const EXPORT_MARKER_FILE = '.codeburn-export'
async function isCodeburnExportFolder(path: string): Promise<boolean> {
const markerStat = await stat(join(path, EXPORT_MARKER_FILE)).catch(() => null)
return markerStat?.isFile() ?? false
}
async function clearCodeburnExportFolder(path: string): Promise<void> {
const entries = await readdir(path)
for (const entry of entries) {
await rm(join(path, entry), { recursive: true, force: true })
}
}
/// Writes a folder of one-table-per-file CSVs. The outputPath is treated as a directory. If it
/// ends in `.csv` the extension is stripped to form the folder name. Refuses to delete a
/// pre-existing file or a non-codeburn folder, so a typo like `-o ~/.ssh/id_ed25519` can't
/// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally).
export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise<string> {
const allProjects = periods.find(p => p.label === '30 Days')?.projects
?? periods[periods.length - 1].projects
const thirtyDays = periods.find(p => p.label === '30 Days')
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
const parts: string[] = []
parts.push('# Summary')
parts.push(rowsToCsv(periods.map(buildSummaryRow)))
parts.push('')
for (const period of periods) {
parts.push(`# Daily - ${period.label}`)
parts.push(rowsToCsv(buildDailyRows(period.projects)))
parts.push('')
parts.push(`# Activity - ${period.label}`)
parts.push(rowsToCsv(buildActivityRows(period.projects)))
parts.push('')
parts.push(`# Models - ${period.label}`)
parts.push(rowsToCsv(buildModelRows(period.projects)))
parts.push('')
let folder = resolve(outputPath)
if (folder.toLowerCase().endsWith('.csv')) {
folder = folder.slice(0, -4)
}
parts.push('# Tools - All')
parts.push(rowsToCsv(buildToolRows(allProjects)))
parts.push('')
const existingStat = await stat(folder).catch(() => null)
if (existingStat?.isFile()) {
throw new Error(`Refusing to overwrite existing file at ${folder}. Pass a directory path instead.`)
}
if (existingStat?.isDirectory()) {
if (!(await isCodeburnExportFolder(folder))) {
throw new Error(
`Refusing to reuse non-empty directory ${folder}: no ${EXPORT_MARKER_FILE} marker. ` +
`Delete it manually or pick a different -o path.`
)
}
await clearCodeburnExportFolder(folder)
}
await mkdir(folder, { recursive: true })
await writeFile(join(folder, EXPORT_MARKER_FILE), '', 'utf-8')
parts.push('# Shell Commands - All')
parts.push(rowsToCsv(buildBashRows(allProjects)))
parts.push('')
const dailyRows = periods.flatMap(p => buildDailyRows(p.projects, p.label))
const activityRows = periods.flatMap(p => buildActivityRows(p.projects, p.label))
const modelRows = periods.flatMap(p => buildModelRows(p.projects, p.label))
parts.push('# Projects - All')
parts.push(rowsToCsv(buildProjectRows(allProjects)))
parts.push('')
await writeFile(join(folder, 'README.txt'), buildReadme(periods), 'utf-8')
await writeFile(join(folder, 'summary.csv'), rowsToCsv(buildSummaryRows(periods)), 'utf-8')
await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8')
await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8')
await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8')
await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'shell-commands.csv'), rowsToCsv(buildBashRows(thirtyDayProjects)), 'utf-8')
const fullPath = resolve(outputPath)
await writeFile(fullPath, parts.join('\n'), 'utf-8')
return fullPath
return folder
}
export async function exportJson(periods: PeriodExport[], outputPath: string): Promise<string> {
const allProjects = periods.find(p => p.label === '30 Days')?.projects
?? periods[periods.length - 1].projects
const periodData: Record<string, unknown> = {}
for (const period of periods) {
periodData[period.label] = {
summary: buildSummaryRow(period),
daily: buildDailyRows(period.projects),
activity: buildActivityRows(period.projects),
models: buildModelRows(period.projects),
}
}
const thirtyDays = periods.find(p => p.label === '30 Days')
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
const { code, rate, symbol } = getCurrency()
const data = {
schema: 'codeburn.export.v2',
generated: new Date().toISOString(),
periods: periodData,
tools: buildToolRows(allProjects),
shellCommands: buildBashRows(allProjects),
projects: buildProjectRows(allProjects),
currency: { code, rate, symbol },
summary: buildSummaryRows(periods),
periods: periods.map(p => ({
label: p.label,
daily: buildDailyRows(p.projects, p.label),
activity: buildActivityRows(p.projects, p.label),
models: buildModelRows(p.projects, p.label),
})),
projects: buildProjectRows(thirtyDayProjects),
sessions: buildSessionRows(thirtyDayProjects),
tools: buildToolRows(thirtyDayProjects),
shellCommands: buildBashRows(thirtyDayProjects),
}
const fullPath = resolve(outputPath)
await writeFile(fullPath, JSON.stringify(data, null, 2), 'utf-8')
return fullPath
const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`)
await mkdir(dirname(target), { recursive: true })
await writeFile(target, JSON.stringify(data, null, 2), 'utf-8')
return target
}

View file

@ -13,9 +13,20 @@ export function formatTokens(n: number): string {
return n.toString()
}
/// Returns YYYY-MM-DD for the given date in the process-local timezone. Cheaper than shelling
/// out to Intl.DateTimeFormat for every turn in a loop and avoids the UTC drift that bites
/// `Date.toISOString().slice(0,10)` whenever the user runs this between local midnight and
/// UTC midnight.
function localDateString(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function renderStatusBar(projects: ProjectSummary[]): string {
const now = new Date()
const today = now.toISOString().slice(0, 10)
const today = localDateString(now)
const monthStart = `${today.slice(0, 7)}-01`
let todayCost = 0, todayCalls = 0, monthCost = 0, monthCalls = 0
@ -24,7 +35,11 @@ export function renderStatusBar(projects: ProjectSummary[]): string {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
const day = turn.timestamp.slice(0, 10)
// Bucket by the session timestamp's local date so the user's "today" and "this month"
// match the wall clock on their machine. Session timestamps are stored as UTC ISO
// strings; naively slicing `timestamp.slice(0,10)` bucketed them by UTC date, which
// showed `Today $0` during the UTC-midnight-to-local-midnight window.
const day = localDateString(new Date(turn.timestamp))
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
const turnCalls = turn.assistantCalls.length
if (day === today) { todayCost += turnCost; todayCalls += turnCalls }

93
src/fs-utils.ts Normal file
View file

@ -0,0 +1,93 @@
import { readFile, stat } from 'fs/promises'
import { readFileSync, statSync, createReadStream } from 'fs'
import { createInterface } from 'readline'
// Hard cap well below V8's 512 MB string limit even with split('\n') doubling.
// Stream threshold chosen as empirical breakeven between readFile+split peak
// memory and createReadStream+readline overhead for typical session files.
export const MAX_SESSION_FILE_BYTES = 128 * 1024 * 1024
export const STREAM_THRESHOLD_BYTES = 8 * 1024 * 1024
function verbose(): boolean {
return process.env.CODEBURN_VERBOSE === '1'
}
function warn(msg: string): void {
if (verbose()) process.stderr.write(`codeburn: ${msg}\n`)
}
async function readViaStream(filePath: string): Promise<string> {
const chunks: string[] = []
const stream = createReadStream(filePath, { encoding: 'utf-8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
for await (const line of rl) chunks.push(line)
return chunks.join('\n')
}
export async function readSessionFile(filePath: string): Promise<string | null> {
let size: number
try {
size = (await stat(filePath)).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
if (size > MAX_SESSION_FILE_BYTES) {
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
return null
}
try {
if (size >= STREAM_THRESHOLD_BYTES) return await readViaStream(filePath)
return await readFile(filePath, 'utf-8')
} catch (err) {
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
}
export function readSessionFileSync(filePath: string): string | null {
let size: number
try {
size = statSync(filePath).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
if (size > MAX_SESSION_FILE_BYTES) {
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
return null
}
try {
return readFileSync(filePath, 'utf-8')
} catch (err) {
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
}
export async function* readSessionLines(filePath: string): AsyncGenerator<string> {
let size: number
try {
size = (await stat(filePath)).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return
}
if (size > MAX_SESSION_FILE_BYTES) {
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
return
}
const stream = createReadStream(filePath, { encoding: 'utf-8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
try {
for await (const line of rl) yield line
} catch (err) {
warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
}
}

173
src/menubar-installer.ts Normal file
View file

@ -0,0 +1,173 @@
import { spawn } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises'
import { homedir, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Readable } from 'node:stream'
/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the
/// newest tagged release; we filter its assets list for our zipped .app bundle.
const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases/latest'
const APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
const ASSET_PATTERN = /^CodeBurnMenubar-.*\.zip$/
const APP_PROCESS_NAME = 'CodeBurnMenubar'
const SUPPORTED_OS = 'darwin'
const MIN_MACOS_MAJOR = 14
export type InstallResult = { installedPath: string; launched: boolean }
type ReleaseAsset = { name: string; browser_download_url: string }
type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
function userApplicationsDir(): string {
return join(homedir(), 'Applications')
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
async function ensureSupportedPlatform(): Promise<void> {
if (platform() !== SUPPORTED_OS) {
throw new Error(`The menubar app is macOS only (detected: ${platform()}).`)
}
const major = Number((process.env.CODEBURN_FORCE_MACOS_MAJOR ?? '')
|| (await sysProductVersion()).split('.')[0])
if (!Number.isFinite(major) || major < MIN_MACOS_MAJOR) {
throw new Error(`macOS ${MIN_MACOS_MAJOR}+ required (detected ${major}).`)
}
}
async function sysProductVersion(): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('/usr/bin/sw_vers', ['-productVersion'])
let out = ''
proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
proc.on('error', reject)
proc.on('close', (code) => {
if (code !== 0) reject(new Error(`sw_vers exited with ${code}`))
else resolve(out.trim())
})
})
}
async function fetchLatestReleaseAsset(): Promise<ReleaseAsset> {
const response = await fetch(RELEASE_API, {
headers: {
// Identify the installer so GitHub's abuse heuristics treat us as a known client.
'User-Agent': 'codeburn-menubar-installer',
Accept: 'application/vnd.github+json',
},
})
if (!response.ok) {
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
}
const body = await response.json() as ReleaseResponse
const asset = body.assets.find(a => ASSET_PATTERN.test(a.name))
if (!asset) {
throw new Error(
`No ${APP_BUNDLE_NAME} zip found in release ${body.tag_name}. ` +
`Check https://github.com/getagentseal/codeburn/releases.`
)
}
return asset
}
async function downloadToFile(url: string, destPath: string): Promise<void> {
const response = await fetch(url, {
headers: { 'User-Agent': 'codeburn-menubar-installer' },
redirect: 'follow',
})
if (!response.ok || response.body === null) {
throw new Error(`Download failed: HTTP ${response.status}`)
}
// fetch's ReadableStream needs to be wrapped for Node streams.
const nodeStream = Readable.fromWeb(response.body as never)
await pipeline(nodeStream, createWriteStream(destPath))
}
async function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: 'inherit' })
proc.on('error', reject)
proc.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with status ${code}`))
})
})
}
async function isAppRunning(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
proc.on('close', (code) => resolve(code === 0))
proc.on('error', () => resolve(false))
})
}
async function killRunningApp(): Promise<void> {
await new Promise<void>((resolve) => {
const proc = spawn('/usr/bin/pkill', ['-f', APP_PROCESS_NAME])
proc.on('close', () => resolve())
proc.on('error', () => resolve())
})
}
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
await ensureSupportedPlatform()
const appsDir = userApplicationsDir()
const targetPath = join(appsDir, APP_BUNDLE_NAME)
const alreadyInstalled = await exists(targetPath)
if (alreadyInstalled && !options.force) {
if (!(await isAppRunning())) {
await runCommand('/usr/bin/open', [targetPath])
}
return { installedPath: targetPath, launched: true }
}
console.log('Looking up the latest CodeBurn Menubar release...')
const asset = await fetchLatestReleaseAsset()
const stagingDir = await mkdtemp(join(tmpdir(), 'codeburn-menubar-'))
try {
const archivePath = join(stagingDir, asset.name)
console.log(`Downloading ${asset.name}...`)
await downloadToFile(asset.browser_download_url, archivePath)
console.log('Unpacking...')
await runCommand('/usr/bin/unzip', ['-q', archivePath, '-d', stagingDir])
const unpackedApp = join(stagingDir, APP_BUNDLE_NAME)
if (!(await exists(unpackedApp))) {
throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`)
}
// Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the
// "cannot verify developer" prompt even for a signed + notarized app when the bundle
// was delivered via curl/fetch instead of the Mac App Store.
await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', unpackedApp]).catch(() => {})
await mkdir(appsDir, { recursive: true })
if (alreadyInstalled) {
// Kill the running copy before replacing its bundle so `mv` can proceed cleanly and the
// user ends up on the new version.
await killRunningApp()
await rm(targetPath, { recursive: true, force: true })
}
await rename(unpackedApp, targetPath)
console.log('Launching CodeBurn Menubar...')
await runCommand('/usr/bin/open', [targetPath])
return { installedPath: targetPath, launched: true }
} finally {
await rm(stagingDir, { recursive: true, force: true })
}
}

182
src/menubar-json.ts Normal file
View file

@ -0,0 +1,182 @@
/// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical
/// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator
/// when hydrating per-day cache entries.
export type PeriodData = {
label: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: number }>
}
export type ProviderCost = {
name: string
cost: number
}
import type { OptimizeResult } from './optimize.js'
const TOP_ACTIVITIES_LIMIT = 20
const TOP_MODELS_LIMIT = 20
const TOP_FINDINGS_LIMIT = 10
const HISTORY_DAYS_LIMIT = 365
const SYNTHETIC_MODEL_NAME = '<synthetic>'
export type DailyModelBreakdown = {
name: string
cost: number
calls: number
inputTokens: number
outputTokens: number
}
export type DailyHistoryEntry = {
date: string
cost: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
topModels: DailyModelBreakdown[]
}
export type MenubarPayload = {
generated: string
current: {
label: string
cost: number
calls: number
sessions: number
oneShotRate: number | null
inputTokens: number
outputTokens: number
cacheHitPercent: number
topActivities: Array<{
name: string
cost: number
turns: number
oneShotRate: number | null
}>
topModels: Array<{
name: string
cost: number
calls: number
}>
providers: Record<string, number>
}
optimize: {
findingCount: number
savingsUSD: number
topFindings: Array<{
title: string
impact: 'high' | 'medium' | 'low'
savingsUSD: number
}>
}
history: {
daily: DailyHistoryEntry[]
}
}
function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
if (editTurns === 0) return null
return oneShotTurns / editTurns
}
function aggregateOneShotRate(categories: PeriodData['categories']): number | null {
let edits = 0
let oneShots = 0
for (const cat of categories) {
edits += cat.editTurns
oneShots += cat.oneShotTurns
}
if (edits === 0) return null
return oneShots / edits
}
function cacheHitPercent(inputTokens: number, cacheReadTokens: number): number {
const denom = inputTokens + cacheReadTokens
if (denom === 0) return 0
return (cacheReadTokens / denom) * 100
}
function buildTopActivities(categories: PeriodData['categories']): MenubarPayload['current']['topActivities'] {
return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({
name: cat.name,
cost: cat.cost,
turns: cat.turns,
oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns),
}))
}
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
return models
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
.slice(0, TOP_MODELS_LIMIT)
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls }))
}
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
if (!optimize || optimize.findings.length === 0) {
return { findingCount: 0, savingsUSD: 0, topFindings: [] }
}
const { findings, costRate } = optimize
const totalSavingsUSD = findings.reduce((s, f) => s + f.tokensSaved * costRate, 0)
const topFindings = findings.slice(0, TOP_FINDINGS_LIMIT).map(f => ({
title: f.title,
impact: f.impact,
savingsUSD: f.tokensSaved * costRate,
}))
return {
findingCount: findings.length,
savingsUSD: totalSavingsUSD,
topFindings,
}
}
function buildProviders(providers: ProviderCost[]): Record<string, number> {
const map: Record<string, number> = {}
for (const p of providers) {
if (p.cost < 0) continue
map[p.name.toLowerCase()] = p.cost
}
return map
}
function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['history'] {
if (!daily || daily.length === 0) return { daily: [] }
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
const trimmed = sorted.slice(-HISTORY_DAYS_LIMIT)
return { daily: trimmed }
}
export function buildMenubarPayload(
current: PeriodData,
providers: ProviderCost[],
optimize: OptimizeResult | null,
dailyHistory?: DailyHistoryEntry[],
): MenubarPayload {
return {
generated: new Date().toISOString(),
current: {
label: current.label,
cost: current.cost,
calls: current.calls,
sessions: current.sessions,
oneShotRate: aggregateOneShotRate(current.categories),
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
providers: buildProviders(providers),
},
optimize: buildOptimize(optimize),
history: buildHistory(dailyHistory),
}
}

View file

@ -1,318 +0,0 @@
import { execFileSync, execSync } from 'child_process'
import { existsSync } from 'fs'
import { chmod, mkdir, unlink, writeFile } from 'fs/promises'
import { homedir, platform } from 'os'
import { join } from 'path'
import { formatCost, formatTokens } from './format.js'
import { getCurrency } from './currency.js'
const PLUGIN_REFRESH = '5m'
const SWIFTBAR_PREFERENCES_DOMAIN = 'com.ameba.SwiftBar'
const SWIFTBAR_PLUGIN_DIRECTORY_KEY = 'PluginDirectory'
function getSwiftBarPluginDir(): string {
return join(homedir(), 'Library', 'Application Support', 'SwiftBar', 'plugins')
}
function getXbarPluginDir(): string {
return join(homedir(), 'Library', 'Application Support', 'xbar', 'plugins')
}
export function parsePluginDirectoryPreference(value: string): string | undefined {
const pluginDir = value.trim()
if (!pluginDir) return undefined
if (pluginDir === '~') return homedir()
if (pluginDir.startsWith('~/')) return join(homedir(), pluginDir.slice(2))
return pluginDir
}
function getConfiguredSwiftBarPluginDir(): string | undefined {
if (platform() !== 'darwin') return undefined
try {
return parsePluginDirectoryPreference(execFileSync('defaults', [
'read',
SWIFTBAR_PREFERENCES_DOMAIN,
SWIFTBAR_PLUGIN_DIRECTORY_KEY,
], { encoding: 'utf-8' }))
} catch {
return undefined
}
}
function getSwiftBarPluginDirs(): string[] {
const dirs = [getConfiguredSwiftBarPluginDir(), getSwiftBarPluginDir()]
return dirs.filter((dir, index): dir is string => dir !== undefined && dirs.indexOf(dir) === index)
}
export function chooseMenubarPluginDir(
swiftBarPluginDirs: string[],
xbarPluginDir: string,
pathExists: (path: string) => boolean,
): { pluginDir: string; appName: string } {
const preferredSwiftBarDir = swiftBarPluginDirs[0] ?? getSwiftBarPluginDir()
for (const pluginDir of swiftBarPluginDirs) {
if (pathExists(pluginDir)) return { pluginDir, appName: 'SwiftBar' }
}
if (pathExists(xbarPluginDir)) return { pluginDir: xbarPluginDir, appName: 'xbar' }
return { pluginDir: preferredSwiftBarDir, appName: 'SwiftBar' }
}
function getCodeburnBin(): string {
try {
return execSync('which codeburn', { encoding: 'utf-8' }).trim()
} catch {
return 'npx --yes codeburn'
}
}
function generatePlugin(bin: string): string {
const home = homedir()
return `#!/bin/bash
# <xbar.title>CodeBurn</xbar.title>
# <xbar.version>v0.1.0</xbar.version>
# <xbar.author>AgentSeal</xbar.author>
# <xbar.author.github>agentseal</xbar.author.github>
# <xbar.desc>See where your AI coding tokens burn. Tracks cost, activity, and model usage across Claude Code, Cursor, and Codex by task type, tool, MCP server, and project.</xbar.desc>
# <xbar.image>file://${home}/codeburn/assets/logo.png</xbar.image>
# <xbar.abouturl>https://github.com/agentseal/codeburn</xbar.abouturl>
# <xbar.dependencies>node</xbar.dependencies>
export PATH="/usr/local/bin:/opt/homebrew/bin:$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
${bin} status --format menubar 2>/dev/null || echo "-- | sfimage=flame.fill"
`
}
function miniBar(value: number, max: number, width: number = 10): string {
if (max === 0) return '·'.repeat(width)
const filled = Math.round((value / max) * width)
return '█'.repeat(Math.min(filled, width)) + '·'.repeat(Math.max(width - filled, 0))
}
export type PeriodData = {
label: string
cost: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: number }>
}
export type ProviderCost = {
name: string
cost: number
}
export function renderMenubarFormat(
today: PeriodData,
week: PeriodData,
thirtyDays: PeriodData,
month: PeriodData,
todayProviders?: ProviderCost[],
): string {
const lines: string[] = []
lines.push(`${formatCost(today.cost)} | sfimage=flame.fill color=#FF8C42`)
lines.push('---')
lines.push(`CodeBurn | size=15 color=#FF8C42`)
lines.push(`AI Coding Cost Tracker | size=11`)
if (todayProviders && todayProviders.length > 1) {
for (const p of todayProviders) {
lines.push(` ${p.name.padEnd(10)} ${formatCost(p.cost).padStart(10)} | font=Menlo size=11`)
}
}
lines.push('---')
lines.push(`Today ${formatCost(today.cost)} ${today.calls.toLocaleString()} calls | size=14`)
lines.push('---')
const maxCat = Math.max(...today.categories.map(c => c.cost), 0.01)
lines.push(`Activity - Today | size=12 color=#FF8C42`)
for (const cat of today.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, maxCat)
const name = cat.name.padEnd(14)
lines.push(`${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push('---')
const maxModel = Math.max(...today.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
lines.push(`Models - Today | size=12 color=#FF8C42`)
for (const model of today.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, maxModel)
const name = model.name.padEnd(14)
lines.push(`${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
const cacheHit = today.inputTokens + today.cacheReadTokens > 0
? ((today.cacheReadTokens / (today.inputTokens + today.cacheReadTokens)) * 100).toFixed(0)
: '0'
lines.push(`Tokens: ${formatTokens(today.inputTokens)} in · ${formatTokens(today.outputTokens)} out · ${cacheHit}% cache hit | font=Menlo size=10`)
lines.push('---')
lines.push(`7 Days ${formatCost(week.cost)} ${week.calls.toLocaleString()} calls | size=14`)
const weekMaxCat = Math.max(...week.categories.map(c => c.cost), 0.01)
const weekMaxModel = Math.max(...week.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of week.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, weekMaxCat)
const name = cat.name.padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
lines.push(`--Models | size=12 color=#FF8C42`)
for (const model of week.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, weekMaxModel)
const name = model.name.padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
lines.push(`30 Days ${formatCost(thirtyDays.cost)} ${thirtyDays.calls.toLocaleString()} calls | size=14`)
const tdMaxCat = Math.max(...thirtyDays.categories.map(c => c.cost), 0.01)
const tdMaxModel = Math.max(...thirtyDays.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of thirtyDays.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, tdMaxCat)
const name = cat.name.padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
lines.push(`--Models | size=12 color=#FF8C42`)
for (const model of thirtyDays.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, tdMaxModel)
const name = model.name.padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
lines.push(`Month ${formatCost(month.cost)} ${month.calls.toLocaleString()} calls | size=14`)
const monthMaxCat = Math.max(...month.categories.map(c => c.cost), 0.01)
const monthMaxModel = Math.max(...month.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of month.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, monthMaxCat)
const name = cat.name.padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
lines.push(`--Models | size=12 color=#FF8C42`)
for (const model of month.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, monthMaxModel)
const name = model.name.padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
lines.push('---')
const home = process.env.HOME ?? '~'
const bin = getCodeburnBin()
// Invoke the resolved `codeburn` binary directly. SwiftBar/xbar deliver
// each `paramN=` value as its own argv entry, so there's no shell
// quoting involved — and we don't ship the user to a `~/codeburn`
// checkout that only exists when running from a dev clone (#32).
lines.push(`Open Full Report | terminal=true shell=${bin} param1=report`)
lines.push(`Export CSV to Desktop | terminal=false shell=${bin} param1=export param2=-o param3=${home}/Desktop/codeburn-report.csv`)
// Currency submenu -- common currencies as clickable items.
// Clicking one runs 'codeburn currency XXX' and refreshes the plugin.
const activeCurrency = getCurrency().code
const currencies = [
{ code: 'USD', name: 'US Dollar' },
{ code: 'GBP', name: 'British Pound' },
{ code: 'EUR', name: 'Euro' },
{ code: 'AUD', name: 'Australian Dollar' },
{ code: 'CAD', name: 'Canadian Dollar' },
{ code: 'NZD', name: 'New Zealand Dollar' },
{ code: 'JPY', name: 'Japanese Yen' },
{ code: 'CHF', name: 'Swiss Franc' },
{ code: 'INR', name: 'Indian Rupee' },
{ code: 'BRL', name: 'Brazilian Real' },
{ code: 'SEK', name: 'Swedish Krona' },
{ code: 'SGD', name: 'Singapore Dollar' },
{ code: 'HKD', name: 'Hong Kong Dollar' },
{ code: 'KRW', name: 'South Korean Won' },
{ code: 'MXN', name: 'Mexican Peso' },
{ code: 'ZAR', name: 'South African Rand' },
{ code: 'DKK', name: 'Danish Krone' },
]
lines.push(`Currency: ${activeCurrency} | size=14`)
for (const { code, name } of currencies) {
const check = code === activeCurrency ? ' *' : ''
// The real CLI subcommand is `codeburn currency [code]` (with `--reset`
// for USD), not `codeburn config currency` — the latter doesn't exist
// and silently fails when SwiftBar runs it. Fixes #27.
if (code === 'USD') {
lines.push(`--${name} (${code})${check} | terminal=false refresh=true shell=${bin} param1=currency param2=--reset`)
} else {
lines.push(`--${name} (${code})${check} | terminal=false refresh=true shell=${bin} param1=currency param2=${code}`)
}
}
lines.push(`Refresh | refresh=true`)
return lines.join('\n')
}
export async function installMenubar(): Promise<string> {
if (platform() !== 'darwin') {
return 'Menu bar integration is only available on macOS. Use `codeburn watch` or `codeburn status` instead.'
}
const bin = getCodeburnBin()
const pluginContent = generatePlugin(bin)
const { pluginDir, appName } = chooseMenubarPluginDir(getSwiftBarPluginDirs(), getXbarPluginDir(), existsSync)
if (!existsSync(pluginDir)) {
await mkdir(pluginDir, { recursive: true })
}
const pluginPath = join(pluginDir, `codeburn.${PLUGIN_REFRESH}.sh`)
await writeFile(pluginPath, pluginContent, 'utf-8')
await chmod(pluginPath, 0o755)
const swiftbarInstalled = existsSync('/Applications/SwiftBar.app') || existsSync(join(homedir(), 'Applications', 'SwiftBar.app'))
const xbarInstalled = existsSync('/Applications/xbar.app') || existsSync(join(homedir(), 'Applications', 'xbar.app'))
const lines: string[] = []
lines.push(`\n Plugin installed to: ${pluginPath}`)
if (swiftbarInstalled || xbarInstalled) {
lines.push(` ${appName} detected - plugin should appear in your menu bar shortly.`)
lines.push(` If not, open ${appName} and refresh plugins.\n`)
} else {
lines.push(`\n To see CodeBurn in your menu bar, install SwiftBar:`)
lines.push(` brew install --cask swiftbar`)
lines.push(`\n Then launch SwiftBar - the plugin will load automatically.\n`)
}
return lines.join('\n')
}
export async function uninstallMenubar(): Promise<string> {
const paths = [
...getSwiftBarPluginDirs().map(dir => join(dir, `codeburn.${PLUGIN_REFRESH}.sh`)),
join(getXbarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
]
let removed = false
for (const p of paths) {
if (existsSync(p)) {
await unlink(p)
removed = true
}
}
return removed
? '\n Menu bar plugin removed.\n'
: '\n No menu bar plugin found.\n'
}

1134
src/optimize.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { readdir, readFile } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { readSessionFile } from './fs-utils.js'
import { calculateCost, getShortModelName } from './models.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import type { ParsedProviderCall } from './providers/types.js'
@ -168,11 +169,11 @@ function buildSessionSummary(
project: string,
turns: ClassifiedTurn[],
): SessionSummary {
const modelBreakdown: SessionSummary['modelBreakdown'] = {}
const toolBreakdown: SessionSummary['toolBreakdown'] = {}
const mcpBreakdown: SessionSummary['mcpBreakdown'] = {}
const bashBreakdown: SessionSummary['bashBreakdown'] = {}
const categoryBreakdown: SessionSummary['categoryBreakdown'] = {} as SessionSummary['categoryBreakdown']
const modelBreakdown: SessionSummary['modelBreakdown'] = Object.create(null)
const toolBreakdown: SessionSummary['toolBreakdown'] = Object.create(null)
const mcpBreakdown: SessionSummary['mcpBreakdown'] = Object.create(null)
const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null)
const categoryBreakdown: SessionSummary['categoryBreakdown'] = Object.create(null)
let totalCost = 0
let totalInput = 0
@ -265,12 +266,17 @@ async function parseSessionFile(
seenMsgIds: Set<string>,
dateRange?: DateRange,
): Promise<SessionSummary | null> {
let content: string
try {
content = await readFile(filePath, 'utf-8')
} catch {
return null
// Skip files whose mtime is older than the range start. A session file
// can only contain entries up to its last-modified time; if that predates
// the requested range, nothing in this file can match.
if (dateRange) {
try {
const s = await stat(filePath)
if (s.mtimeMs < dateRange.start.getTime()) return null
} catch { /* fall through to normal read; missing stat shouldn't break parsing */ }
}
const content = await readSessionFile(filePath)
if (content === null) return null
const lines = content.split('\n').filter(l => l.trim())
const entries: JournalEntry[] = []
@ -391,6 +397,12 @@ async function parseProviderSources(
const sessionMap = new Map<string, { project: string; turns: ClassifiedTurn[] }>()
for (const source of sources) {
if (dateRange) {
try {
const s = await stat(source.path)
if (s.mtimeMs < dateRange.start.getTime()) continue
} catch { /* fall through; treat unknown stat as "may contain data" */ }
}
const parser = provider.createSessionParser(
{ path: source.path, project: source.project, provider: providerName },
seenKeys,
@ -462,6 +474,31 @@ function cachePut(key: string, data: ProjectSummary[]) {
sessionCache.set(key, { data, ts: now })
}
export function filterProjectsByName(
projects: ProjectSummary[],
include?: string[],
exclude?: string[],
): ProjectSummary[] {
let result = projects
if (include && include.length > 0) {
const patterns = include.map(s => s.toLowerCase())
result = result.filter(p => {
const name = p.project.toLowerCase()
const path = p.projectPath.toLowerCase()
return patterns.some(pat => name.includes(pat) || path.includes(pat))
})
}
if (exclude && exclude.length > 0) {
const patterns = exclude.map(s => s.toLowerCase())
result = result.filter(p => {
const name = p.project.toLowerCase()
const path = p.projectPath.toLowerCase()
return !patterns.some(pat => name.includes(pat) || path.includes(pat))
})
}
return result
}
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
const key = cacheKey(dateRange, providerFilter)
const cached = sessionCache.get(key)

View file

@ -1,7 +1,8 @@
import { readdir, readFile, stat } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -65,10 +66,11 @@ function sanitizeProject(cwd: string): string {
}
async function readFirstLine(filePath: string): Promise<CodexEntry | null> {
const content = await readSessionFile(filePath)
if (content === null) return null
const line = content.split('\n')[0]
if (!line?.trim()) return null
try {
const content = await readFile(filePath, 'utf-8')
const line = content.split('\n')[0]
if (!line?.trim()) return null
return JSON.parse(line) as CodexEntry
} catch {
return null
@ -139,13 +141,8 @@ function resolveModel(info: CodexEntry['payload'], sessionModel?: string): strin
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
let content: string
try {
content = await readFile(source.path, 'utf-8')
} catch {
return
}
const content = await readSessionFile(source.path)
if (content === null) return
const lines = content.split('\n').filter(l => l.trim())
let sessionModel: string | undefined
let sessionId = ''

View file

@ -1,7 +1,8 @@
import { readdir, readFile, stat } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -85,13 +86,8 @@ function parseCwd(yaml: string): string | null {
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
let content: string
try {
content = await readFile(source.path, 'utf-8')
} catch {
return
}
const content = await readSessionFile(source.path)
if (content === null) return
const sessionId = basename(dirname(source.path))
const lines = content.split('\n').filter(l => l.trim())
let currentModel = ''
@ -177,11 +173,11 @@ async function discoverSessionsInDir(sessionStateDir: string): Promise<SessionSo
if (!s?.isFile()) continue
let project = sessionId
try {
const yaml = await readFile(join(sessionStateDir, sessionId, 'workspace.yaml'), 'utf-8')
const yaml = await readSessionFile(join(sessionStateDir, sessionId, 'workspace.yaml'))
if (yaml !== null) {
const cwd = parseCwd(yaml)
if (cwd) project = basename(cwd)
} catch {}
}
sources.push({ path: eventsPath, project, provider: 'copilot' })
}

View file

@ -1,7 +1,8 @@
import { readdir, readFile, stat } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -60,10 +61,11 @@ function getOmpSessionsDir(override?: string): string {
}
async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
const content = await readSessionFile(filePath)
if (content === null) return null
const line = content.split('\n')[0]
if (!line?.trim()) return null
try {
const content = await readFile(filePath, 'utf-8')
const line = content.split('\n')[0]
if (!line?.trim()) return null
return JSON.parse(line) as PiEntry
} catch {
return null
@ -112,13 +114,8 @@ async function discoverSessionsInDir(sessionsDir: string, providerName: string):
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
let content: string
try {
content = await readFile(source.path, 'utf-8')
} catch {
return
}
const content = await readSessionFile(source.path)
if (content === null) return
const lines = content.split('\n').filter(l => l.trim())
let sessionId = basename(source.path, '.jsonl')
let pendingUserMessage = ''

View file

@ -1,6 +1,11 @@
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
/// Thin SQLite read-only wrapper over Node's built-in `node:sqlite` module (stable in
/// Node 24, experimental in Node 22 / 23). Replaces the earlier `better-sqlite3` binding
/// so the dependency graph no longer pulls in the deprecated `prebuild-install` package
/// (issue #75). Works across Cursor and OpenCode session DBs, both of which we only read.
const requireForSqlite = createRequire(import.meta.url)
type Row = Record<string, unknown>
@ -9,21 +14,64 @@ export type SqliteDatabase = {
close(): void
}
let BetterSqlite3: unknown = null
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => {
prepare(sql: string): { all(...params: unknown[]): Row[] }
close(): void
}
let DatabaseSync: DatabaseSyncCtor | null = null
let loadAttempted = false
let loadError: string | null = null
/// Lazily imports `node:sqlite`. On Node 22/23 it emits an ExperimentalWarning the first
/// time the module is loaded; we silence that specific warning once so dashboards aren't
/// preceded by a scary stderr line every run. Any other warnings (including future
/// non-SQLite ones) are left untouched.
function loadDriver(): boolean {
if (loadAttempted) return BetterSqlite3 !== null
if (loadAttempted) return DatabaseSync !== null
loadAttempted = true
const origEmit = process.emit.bind(process)
let restored = false
const restore = () => {
if (restored) return
restored = true
process.emit = origEmit
}
// Node's `process.emit` signature is overloaded; we intercept the 'warning' channel
// only and proxy everything else through unchanged. The `any` cast avoids chasing the
// overload union which isn't worth its verbosity for a single-purpose shim.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.emit = function patchedEmit(this: NodeJS.Process, event: string, ...args: any[]): boolean {
if (event === 'warning') {
const warning = args[0] as { name?: string; message?: string } | undefined
if (
warning?.name === 'ExperimentalWarning' &&
typeof warning.message === 'string' &&
/SQLite/i.test(warning.message)
) {
return false
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (origEmit as any).call(this, event, ...args)
} as typeof process.emit
try {
BetterSqlite3 = require('better-sqlite3')
const mod = requireForSqlite('node:sqlite') as { DatabaseSync: DatabaseSyncCtor }
DatabaseSync = mod.DatabaseSync
return true
} catch {
loadError = 'SQLite-based providers (Cursor, OpenCode) require the better-sqlite3 package.\n' +
'Install it with: npm install -g better-sqlite3\n' +
'Then run codeburn again.'
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
loadError =
'SQLite-based providers (Cursor, OpenCode) need Node 22+ with the node:sqlite module.\n' +
`Current Node: ${process.version}.\n` +
'Upgrade Node (https://nodejs.org) and run codeburn again.\n' +
`(underlying error: ${message})`
return false
} finally {
restore()
}
}
@ -36,16 +84,11 @@ export function getSqliteLoadError(): string {
}
export function openDatabase(path: string): SqliteDatabase {
if (!loadDriver()) {
if (!loadDriver() || DatabaseSync === null) {
throw new Error(getSqliteLoadError())
}
const Database = BetterSqlite3 as new (path: string, options?: Record<string, unknown>) => {
prepare(sql: string): { all(...params: unknown[]): Row[] }
close(): void
}
const db = new Database(path, { readonly: true, fileMustExist: true })
const db = new DatabaseSync(path, { readOnly: true })
return {
query<T extends Row = Row>(sql: string, params: unknown[] = []): T[] {

189
tests/daily-cache.test.ts Normal file
View file

@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readFile, rm } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
addNewDays,
DAILY_CACHE_VERSION,
type DailyCache,
type DailyEntry,
getDaysInRange,
loadDailyCache,
saveDailyCache,
withDailyCacheLock,
} from '../src/daily-cache.js'
function emptyDay(date: string, cost = 0, calls = 0): DailyEntry {
return {
date,
cost,
calls,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: {},
categories: {},
providers: {},
}
}
const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-cache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
beforeEach(() => {
process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
})
afterEach(async () => {
delete process.env['CODEBURN_CACHE_DIR']
if (existsSync(TMP_CACHE_ROOT)) {
await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
}
})
describe('loadDailyCache', () => {
it('returns an empty cache when the file does not exist', async () => {
const cache = await loadDailyCache()
expect(cache.version).toBe(DAILY_CACHE_VERSION)
expect(cache.lastComputedDate).toBeNull()
expect(cache.days).toEqual([])
})
it('returns an empty cache when the file contains invalid JSON', async () => {
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'not valid json{{', 'utf-8')
const cache = await loadDailyCache()
expect(cache.days).toEqual([])
})
it('returns an empty cache when the version does not match', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION - 999,
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 10)],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
expect(cache.days).toEqual([])
expect(cache.lastComputedDate).toBeNull()
})
it('round-trips a valid cache through save and load', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
}
await saveDailyCache(saved)
const loaded = await loadDailyCache()
expect(loaded).toEqual(saved)
})
})
describe('saveDailyCache', () => {
it('writes atomically so no temp file is left after a successful save', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 5)],
}
await saveDailyCache(saved)
const { readdir } = await import('fs/promises')
const files = await readdir(TMP_CACHE_ROOT)
const tempLeftovers = files.filter(f => f.endsWith('.tmp'))
expect(tempLeftovers).toEqual([])
const finalFile = await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'utf-8')
expect(JSON.parse(finalFile)).toEqual(saved)
})
})
describe('addNewDays', () => {
it('returns a new cache with the added days sorted ascending by date', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
lastComputedDate: '2026-04-08',
days: [emptyDay('2026-04-07', 3), emptyDay('2026-04-08', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-10', 9), emptyDay('2026-04-09', 7)], '2026-04-10')
expect(updated.days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09', '2026-04-10'])
expect(updated.lastComputedDate).toBe('2026-04-10')
})
it('skips days already present in the cache (first write wins)', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
lastComputedDate: '2026-04-08',
days: [emptyDay('2026-04-08', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-08', 99)], '2026-04-08')
const aprilEight = updated.days.find(d => d.date === '2026-04-08')!
expect(aprilEight.cost).toBe(5)
})
it('does not regress lastComputedDate if incoming newestDate is older', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-05', 3)], '2026-04-05')
expect(updated.lastComputedDate).toBe('2026-04-10')
})
})
describe('getDaysInRange', () => {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
lastComputedDate: '2026-04-10',
days: [
emptyDay('2026-04-05', 1),
emptyDay('2026-04-06', 2),
emptyDay('2026-04-07', 3),
emptyDay('2026-04-08', 4),
emptyDay('2026-04-09', 5),
emptyDay('2026-04-10', 6),
],
}
it('returns inclusive start and end range', () => {
const days = getDaysInRange(cache, '2026-04-07', '2026-04-09')
expect(days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09'])
})
it('returns empty when range is entirely outside cache', () => {
expect(getDaysInRange(cache, '2026-03-01', '2026-03-10')).toEqual([])
expect(getDaysInRange(cache, '2026-05-01', '2026-05-10')).toEqual([])
})
it('clips to available cache days when range extends beyond', () => {
const days = getDaysInRange(cache, '2026-04-09', '2026-04-20')
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
})
})
describe('withDailyCacheLock', () => {
it('serializes concurrent operations', async () => {
const sequence: string[] = []
const op = async (tag: string): Promise<void> => {
await withDailyCacheLock(async () => {
sequence.push(`start-${tag}`)
await new Promise(r => setTimeout(r, 20))
sequence.push(`end-${tag}`)
})
}
await Promise.all([op('a'), op('b'), op('c')])
for (let i = 0; i < sequence.length; i += 2) {
expect(sequence[i]?.startsWith('start-')).toBe(true)
expect(sequence[i + 1]?.startsWith('end-')).toBe(true)
expect(sequence[i]!.slice(6)).toBe(sequence[i + 1]!.slice(4))
}
})
})

View file

@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { parseDateRangeFlags } from '../src/cli-date.js'
describe('parseDateRangeFlags', () => {
it('returns null when neither flag is provided', () => {
expect(parseDateRangeFlags(undefined, undefined)).toBeNull()
})
it('parses a symmetric range in local time', () => {
const range = parseDateRangeFlags('2026-04-07', '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getFullYear()).toBe(2026)
expect(range!.start.getMonth()).toBe(3)
expect(range!.start.getDate()).toBe(7)
expect(range!.start.getHours()).toBe(0)
expect(range!.end.getDate()).toBe(10)
expect(range!.end.getHours()).toBe(23)
expect(range!.end.getMinutes()).toBe(59)
expect(range!.end.getSeconds()).toBe(59)
})
it('accepts --from alone (open-ended to today 23:59:59)', () => {
const range = parseDateRangeFlags('2026-04-01', undefined)
expect(range).not.toBeNull()
expect(range!.start.getDate()).toBe(1)
expect(range!.end.getHours()).toBe(23)
})
it('accepts --to alone (start = epoch)', () => {
const range = parseDateRangeFlags(undefined, '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getTime()).toBe(new Date(0).getTime())
expect(range!.end.getDate()).toBe(10)
})
it('throws when --from > --to', () => {
expect(() => parseDateRangeFlags('2026-04-10', '2026-04-07'))
.toThrow('--from must not be after --to')
})
it('throws on a non-ISO string', () => {
expect(() => parseDateRangeFlags('April 7', undefined))
.toThrow('Invalid date format')
})
it('throws on wrong digit count', () => {
expect(() => parseDateRangeFlags('26-4-7', undefined))
.toThrow('Invalid date format')
})
it('same day is valid (start midnight, end 23:59:59)', () => {
const range = parseDateRangeFlags('2026-04-10', '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getDate()).toBe(10)
expect(range!.end.getDate()).toBe(10)
})
})

View file

@ -0,0 +1,258 @@
import { describe, expect, it } from 'vitest'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js'
import type { ProjectSummary } from '../src/types.js'
function makeProject(overrides: Partial<ProjectSummary> & { sessions: ProjectSummary['sessions'] }): ProjectSummary {
return {
project: 'p',
projectPath: '/p',
totalCostUSD: overrides.sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalApiCalls: overrides.sessions.reduce((s, sess) => s + sess.apiCalls, 0),
...overrides,
}
}
function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provider = 'claude') {
return {
provider,
model,
usage: {
inputTokens: 100,
outputTokens: 200,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 50,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD,
tools: [],
mcpTools: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard' as const,
timestamp,
bashCommands: [],
deduplicationKey: `dk-${timestamp}-${costUSD}`,
}
}
describe('aggregateProjectsIntoDays', () => {
it('buckets api calls by calendar date derived from timestamp', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-09T10:00:00Z',
lastTimestamp: '2026-04-10T08:00:00Z',
totalCostUSD: 10,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 2,
turns: [
{
userMessage: 'hi',
timestamp: '2026-04-09T10:00:00Z',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [
makeCall('2026-04-09T10:00:00Z', 4),
makeCall('2026-04-10T08:00:00Z', 6),
],
},
],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
expect(days[0]!.cost).toBe(4)
expect(days[0]!.calls).toBe(1)
expect(days[1]!.cost).toBe(6)
expect(days[1]!.calls).toBe(1)
})
it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-09T10:00:00Z',
lastTimestamp: '2026-04-09T10:05:00Z',
totalCostUSD: 3,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [
{
userMessage: 'hi',
timestamp: '2026-04-09T10:00:00Z',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [makeCall('2026-04-09T10:00:00Z', 3)],
},
],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
const day = days[0]!
expect(day.editTurns).toBe(1)
expect(day.oneShotTurns).toBe(1)
expect(day.categories['coding']).toEqual({
turns: 1,
cost: 3,
editTurns: 1,
oneShotTurns: 1,
})
})
it('counts a session under its firstTimestamp date', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-09T23:59:00Z',
lastTimestamp: '2026-04-10T00:10:00Z',
totalCostUSD: 1,
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 0,
turns: [],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
expect(days[0]!.date).toBe('2026-04-09')
expect(days[0]!.sessions).toBe(1)
})
it('aggregates per-model and per-provider totals inside each day', () => {
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
sessionId: 's1',
project: 'p',
firstTimestamp: '2026-04-10T10:00:00Z',
lastTimestamp: '2026-04-10T10:00:00Z',
totalCostUSD: 10,
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 2,
turns: [
{
userMessage: 'x', timestamp: '2026-04-10T10:00:00Z', sessionId: 's1',
category: 'coding', retries: 0, hasEdits: false,
assistantCalls: [
makeCall('2026-04-10T10:00:00Z', 7, 'Opus 4.7', 'claude'),
makeCall('2026-04-10T10:00:00Z', 3, 'gpt-5', 'codex'),
],
},
],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as never,
}],
}),
]
const days = aggregateProjectsIntoDays(projects)
const day = days[0]!
expect(day.models['Opus 4.7']).toEqual({
calls: 1, cost: 7,
inputTokens: 100, outputTokens: 200,
cacheReadTokens: 50, cacheWriteTokens: 0,
})
expect(day.models['gpt-5']).toEqual({
calls: 1, cost: 3,
inputTokens: 100, outputTokens: 200,
cacheReadTokens: 50, cacheWriteTokens: 0,
})
expect(day.providers['claude']).toEqual({ calls: 1, cost: 7 })
expect(day.providers['codex']).toEqual({ calls: 1, cost: 3 })
})
})
describe('buildPeriodDataFromDays', () => {
function makeDay(date: string, cost: number) {
return {
date,
cost,
calls: 10,
sessions: 2,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 300,
cacheWriteTokens: 0,
editTurns: 3,
oneShotTurns: 2,
models: {
'Opus 4.7': { calls: 8, cost: cost * 0.8, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
'Haiku 4.5': { calls: 2, cost: cost * 0.2, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
},
categories: { 'coding': { turns: 2, cost: cost * 0.5, editTurns: 2, oneShotTurns: 1 } },
providers: { 'claude': { calls: 10, cost } },
}
}
it('sums cost, calls, sessions, tokens across days', () => {
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
const pd = buildPeriodDataFromDays(days, '7 Days')
expect(pd.label).toBe('7 Days')
expect(pd.cost).toBe(30)
expect(pd.calls).toBe(20)
expect(pd.sessions).toBe(4)
expect(pd.inputTokens).toBe(200)
expect(pd.outputTokens).toBe(400)
expect(pd.cacheReadTokens).toBe(600)
})
it('merges per-model totals across days and sorts by cost desc', () => {
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
const pd = buildPeriodDataFromDays(days, 'Today')
expect(pd.models[0]!.name).toBe('Opus 4.7')
expect(pd.models[0]!.cost).toBeCloseTo(24)
expect(pd.models[1]!.name).toBe('Haiku 4.5')
expect(pd.models[1]!.cost).toBeCloseTo(6)
})
it('merges per-category totals and keeps editTurns + oneShotTurns per category', () => {
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
const pd = buildPeriodDataFromDays(days, 'Today')
const coding = pd.categories.find(c => c.name === 'Coding')!
expect(coding.turns).toBe(4)
expect(coding.editTurns).toBe(4)
expect(coding.oneShotTurns).toBe(2)
expect(coding.cost).toBeCloseTo(15)
})
it('returns empty period totals when no days supplied', () => {
const pd = buildPeriodDataFromDays([], 'Today')
expect(pd.cost).toBe(0)
expect(pd.calls).toBe(0)
expect(pd.sessions).toBe(0)
expect(pd.categories).toEqual([])
expect(pd.models).toEqual([])
})
})

View file

@ -120,8 +120,15 @@ describe('exportCsv', () => {
]
const outputPath = join(tmpDir, 'report.csv')
await exportCsv(periods, outputPath)
const content = await readFile(outputPath, 'utf-8')
const folder = await exportCsv(periods, outputPath)
// exportCsv now writes a folder of clean one-table-per-file CSVs, so the formula-prefix
// guard is scattered across files. Concatenate them for the assertion surface.
const [projects, models, shell] = await Promise.all([
readFile(join(folder, 'projects.csv'), 'utf-8'),
readFile(join(folder, 'models.csv'), 'utf-8'),
readFile(join(folder, 'shell-commands.csv'), 'utf-8'),
])
const content = projects + models + shell
expect(content).toContain("\"'=cmd,calc\"")
expect(content).toContain("'+danger-model")

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-bash","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"command":"/x/__proto__"}}],"usage":{"input_tokens":1,"output_tokens":1}}}

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-model","type":"message","role":"assistant","model":"__proto__","content":[{"type":"text","text":"x"}],"usage":{"input_tokens":1,"output_tokens":1}}}

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-tool","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"t1","name":"__proto__","input":{}}],"usage":{"input_tokens":1,"output_tokens":1}}}

63
tests/fs-utils.test.ts Normal file
View file

@ -0,0 +1,63 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { mkdtemp, writeFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
MAX_SESSION_FILE_BYTES,
STREAM_THRESHOLD_BYTES,
readSessionFile,
} from '../src/fs-utils.js'
describe('readSessionFile', () => {
const tmpDirs: string[] = []
afterEach(async () => {
delete process.env.CODEBURN_VERBOSE
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string | Buffer): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-fs-'))
tmpDirs.push(base)
const p = join(base, 'x.jsonl')
await writeFile(p, content)
return p
}
it('returns content for small files via readFile fast path', async () => {
const p = await tmpPath('hello\nworld\n')
expect(await readSessionFile(p)).toBe('hello\nworld\n')
})
it('returns content for files at the stream threshold via stream path', async () => {
const p = await tmpPath(Buffer.alloc(STREAM_THRESHOLD_BYTES, 'a'))
const got = await readSessionFile(p)
expect(got).not.toBeNull()
expect(got!.length).toBe(STREAM_THRESHOLD_BYTES)
})
it('returns null and skips files over the cap', async () => {
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'b'))
expect(await readSessionFile(p)).toBeNull()
})
it('emits stderr warning under CODEBURN_VERBOSE=1 for skipped file', async () => {
process.env.CODEBURN_VERBOSE = '1'
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'c'))
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await readSessionFile(p)
expect(spy).toHaveBeenCalled()
const msg = (spy.mock.calls[0][0] as string)
expect(msg).toContain('codeburn')
expect(msg).toContain('oversize')
spy.mockRestore()
})
it('returns null on stat failure without throwing', async () => {
expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull()
})
})

234
tests/menubar-json.test.ts Normal file
View file

@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest'
import { buildMenubarPayload, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
import type { OptimizeResult } from '../src/optimize.js'
function emptyPeriod(label: string): PeriodData {
return {
label,
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
categories: [],
models: [],
}
}
describe('buildMenubarPayload', () => {
it('emits the full schema with current-period metrics and iso timestamp', () => {
const period: PeriodData = {
label: '7 Days',
cost: 1248.01,
calls: 11231,
sessions: 97,
inputTokens: 19100,
outputTokens: 675600,
cacheReadTokens: 0,
cacheWriteTokens: 0,
categories: [],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.generated).toMatch(/^\d{4}-\d{2}-\d{2}T/)
expect(payload.current.label).toBe('7 Days')
expect(payload.current.cost).toBe(1248.01)
expect(payload.current.calls).toBe(11231)
expect(payload.current.sessions).toBe(97)
expect(payload.current.inputTokens).toBe(19100)
expect(payload.current.outputTokens).toBe(675600)
})
it('computes per-category oneShotRate from editTurns and skips categories without edits', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [
{ name: 'Coding', cost: 15.83, turns: 7, editTurns: 7, oneShotTurns: 6 },
{ name: 'Conversation', cost: 16.69, turns: 47, editTurns: 0, oneShotTurns: 0 },
],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
const coding = payload.current.topActivities.find(a => a.name === 'Coding')!
expect(coding.oneShotRate).toBeCloseTo(6 / 7)
const conv = payload.current.topActivities.find(a => a.name === 'Conversation')!
expect(conv.oneShotRate).toBeNull()
})
it('computes aggregate oneShotRate across categories with edits', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [
{ name: 'Coding', cost: 1, turns: 7, editTurns: 10, oneShotTurns: 8 },
{ name: 'Debugging', cost: 1, turns: 5, editTurns: 10, oneShotTurns: 6 },
{ name: 'Conversation', cost: 1, turns: 40, editTurns: 0, oneShotTurns: 0 },
],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.oneShotRate).toBeCloseTo((8 + 6) / (10 + 10))
})
it('returns null aggregate oneShotRate when no categories have editTurns', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [{ name: 'Conversation', cost: 1, turns: 5, editTurns: 0, oneShotTurns: 0 }],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.oneShotRate).toBeNull()
})
it('filters out the synthetic model and caps topModels at 20 so multi-model users see all their models', () => {
const models = Array.from({ length: 30 }, (_, i) => ({
name: `Model${i}`, cost: 30 - i, calls: 100,
}))
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [],
models: [{ name: '<synthetic>', cost: 99, calls: 0 }, ...models],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.topModels.find(m => m.name === '<synthetic>')).toBeUndefined()
expect(payload.current.topModels).toHaveLength(20)
expect(payload.current.topModels[0].name).toBe('Model0')
})
it('caps topActivities at 20 so all task categories can surface', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: Array.from({ length: 25 }, (_, i) => ({
name: `Cat${i}`, cost: 1, turns: 1, editTurns: 1, oneShotTurns: 1,
})),
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.topActivities).toHaveLength(20)
})
it('computes cacheHitPercent from cache reads over input plus cache reads', () => {
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 900,
cacheWriteTokens: 0,
categories: [],
models: [],
}
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.cacheHitPercent).toBeCloseTo(90)
})
it('returns zero cacheHitPercent when there is no input or cache traffic', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload.current.cacheHitPercent).toBe(0)
})
it('handles null optimize as empty findings block', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] })
})
it('converts tokensSaved to savingsUSD via costRate and caps topFindings at 10', () => {
const findings = Array.from({ length: 15 }, (_, i) => ({
title: `F${i}`, explanation: '', impact: 'low' as const, tokensSaved: 1000,
fix: { type: 'paste' as const, label: '', text: '' },
}))
const optimize: OptimizeResult = {
findings,
costRate: 0.00002,
healthScore: 60,
healthGrade: 'C',
}
const payload = buildMenubarPayload(emptyPeriod('Today'), [], optimize)
expect(payload.optimize.findingCount).toBe(15)
expect(payload.optimize.topFindings).toHaveLength(10)
expect(payload.optimize.topFindings[0].title).toBe('F0')
expect(payload.optimize.topFindings[0].savingsUSD).toBeCloseTo(1000 * 0.00002)
expect(payload.optimize.savingsUSD).toBeCloseTo(15 * 1000 * 0.00002)
})
it('maps providers into a lowercased dict inside the current-period block', () => {
const providers: ProviderCost[] = [
{ name: 'Claude Code', cost: 76.45 },
{ name: 'Cursor', cost: 2.18 },
{ name: 'Codex', cost: 1.5 },
]
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
expect(payload.current.providers).toEqual({ 'claude code': 76.45, cursor: 2.18, codex: 1.5 })
})
it('keeps zero-cost providers in the dict so installed-but-unused providers still render as tabs', () => {
const providers: ProviderCost[] = [
{ name: 'Claude', cost: 76.45 },
{ name: 'Codex', cost: 0 },
{ name: 'Cursor', cost: 2.18 },
]
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
expect(payload.current.providers).toEqual({ claude: 76.45, codex: 0, cursor: 2.18 })
})
it('includes up to 365 daily history entries sorted ascending by date', () => {
const history = Array.from({ length: 400 }, (_, i) => {
const d = new Date(2025, 0, 1)
d.setDate(d.getDate() + i)
return {
date: d.toISOString().slice(0, 10),
cost: i,
calls: i * 10,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
})
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history)
expect(payload.history.daily).toHaveLength(365)
expect(payload.history.daily[0]!.date < payload.history.daily[364]!.date).toBe(true)
expect(payload.history.daily[364]!.date).toBe(history[399]!.date)
})
it('preserves token fields in dailyHistory entries', () => {
const history = [
{ date: '2026-04-15', cost: 10, calls: 50, inputTokens: 100, outputTokens: 200, cacheReadTokens: 5000, cacheWriteTokens: 800, topModels: [{ name: 'Opus 4.7', cost: 8, calls: 40, inputTokens: 80, outputTokens: 160 }] },
{ date: '2026-04-16', cost: 20, calls: 75, inputTokens: 150, outputTokens: 350, cacheReadTokens: 8000, cacheWriteTokens: 1200, topModels: [] },
]
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history)
expect(payload.history.daily[0]).toEqual(history[0])
expect(payload.history.daily[1]).toEqual(history[1])
})
it('returns empty history when none supplied', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload.history.daily).toEqual([])
})
it('drops providers with negative cost defensively', () => {
const providers: ProviderCost[] = [
{ name: 'Claude', cost: 76.45 },
{ name: 'Broken', cost: -1 },
]
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
expect(payload.current.providers).toEqual({ claude: 76.45 })
})
})

View file

@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest'
import { join } from 'path'
import { homedir } from 'os'
import { chooseMenubarPluginDir, parsePluginDirectoryPreference } from '../src/menubar.js'
describe('parsePluginDirectoryPreference', () => {
it('trims defaults output and preserves spaces in paths', () => {
expect(parsePluginDirectoryPreference('/Users/test/Documents/Tech stuff/swiftbar_plugins\n')).toBe('/Users/test/Documents/Tech stuff/swiftbar_plugins')
})
it('expands tilde paths', () => {
expect(parsePluginDirectoryPreference('~/swiftbar_plugins')).toBe(join(homedir(), 'swiftbar_plugins'))
})
it('ignores blank preference values', () => {
expect(parsePluginDirectoryPreference(' \n')).toBeUndefined()
})
})
describe('chooseMenubarPluginDir', () => {
const configuredSwiftBarDir = '/Users/test/Documents/Tech stuff/swiftbar_plugins'
const defaultSwiftBarDir = '/Users/test/Library/Application Support/SwiftBar/plugins'
const xbarDir = '/Users/test/Library/Application Support/xbar/plugins'
it('uses SwiftBar configured plugin directory before the default directory', () => {
const existing = new Set([configuredSwiftBarDir, defaultSwiftBarDir])
const result = chooseMenubarPluginDir(
[configuredSwiftBarDir, defaultSwiftBarDir],
xbarDir,
path => existing.has(path),
)
expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
})
it('falls back to xbar when no SwiftBar plugin directory exists', () => {
const existing = new Set([xbarDir])
const result = chooseMenubarPluginDir(
[defaultSwiftBarDir],
xbarDir,
path => existing.has(path),
)
expect(result).toEqual({ pluginDir: xbarDir, appName: 'xbar' })
})
it('creates the preferred SwiftBar directory when no plugin directory exists', () => {
const result = chooseMenubarPluginDir(
[configuredSwiftBarDir, defaultSwiftBarDir],
xbarDir,
() => false,
)
expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
})
})

402
tests/optimize-fs.test.ts Normal file
View file

@ -0,0 +1,402 @@
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
vi.mock('os', async () => {
const actual = await vi.importActual<typeof import('os')>('os')
const fs = await vi.importActual<typeof import('fs')>('fs')
const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/codeburn-home-')
fs.mkdirSync(fakeHome + '/.claude', { recursive: true })
process.env['CODEBURN_TEST_FAKE_HOME'] = fakeHome
return { ...actual, homedir: () => fakeHome }
})
const FAKE_HOME_FOR_MOCK = process.env['CODEBURN_TEST_FAKE_HOME']!
import {
detectBloatedClaudeMd,
detectUnusedMcp,
detectBashBloat,
detectGhostAgents,
detectGhostSkills,
detectGhostCommands,
loadMcpConfigs,
scanJsonlFile,
scanAndDetect,
type ToolCall,
} from '../src/optimize.js'
import {
estimateContextBudget,
discoverProjectCwd,
} from '../src/context-budget.js'
// ============================================================================
// Helpers for filesystem fixtures
// ============================================================================
const FIXTURE_ROOTS: string[] = [FAKE_HOME_FOR_MOCK]
function makeFixtureRoot(): string {
const dir = mkdtempSync(join(tmpdir(), 'codeburn-test-'))
FIXTURE_ROOTS.push(dir)
return dir
}
function writeFile(path: string, content: string): void {
mkdirSync(join(path, '..'), { recursive: true })
writeFileSync(path, content)
}
function touchOld(path: string, daysAgo: number): void {
const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)
utimesSync(path, past, past)
}
afterAll(() => {
for (const dir of FIXTURE_ROOTS) {
rmSync(dir, { recursive: true, force: true })
}
})
// ============================================================================
// detectBloatedClaudeMd (including @-import expansion)
// ============================================================================
describe('detectBloatedClaudeMd', () => {
it('flags a CLAUDE.md with more than 200 lines', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
const content = Array.from({ length: 300 }, (_, i) => `line ${i}`).join('\n')
writeFile(join(projectDir, 'CLAUDE.md'), content)
const finding = detectBloatedClaudeMd(new Set([projectDir]))
expect(finding).not.toBeNull()
})
it('expands @-imports and counts transitive load', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(
join(projectDir, 'CLAUDE.md'),
'line 1\nline 2\n@./rules.md\n@./conventions.md\n',
)
writeFile(join(projectDir, 'rules.md'), Array.from({ length: 120 }, (_, i) => `rule ${i}`).join('\n'))
writeFile(join(projectDir, 'conventions.md'), Array.from({ length: 120 }, (_, i) => `conv ${i}`).join('\n'))
const finding = detectBloatedClaudeMd(new Set([projectDir]))
expect(finding).not.toBeNull()
expect(finding!.explanation).toContain('2 @-imports')
})
it('does not flag a lean CLAUDE.md under 200 lines with no imports', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, 'CLAUDE.md'), 'just a few\nlines\nhere\n')
expect(detectBloatedClaudeMd(new Set([projectDir]))).toBeNull()
})
it('does not recurse infinitely on circular @-imports', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, 'CLAUDE.md'), '@./a.md\n')
writeFile(join(projectDir, 'a.md'), '@./b.md\n')
writeFile(join(projectDir, 'b.md'), '@./a.md\n')
expect(() => detectBloatedClaudeMd(new Set([projectDir]))).not.toThrow()
})
it('ignores @ tokens that are not paths (emails, npm scopes)', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(
join(projectDir, 'CLAUDE.md'),
Array.from({ length: 250 }, (_, i) =>
i === 10 ? '@user@example.com' :
i === 20 ? '@org/package' :
`line ${i}`
).join('\n'),
)
const finding = detectBloatedClaudeMd(new Set([projectDir]))
expect(finding).not.toBeNull()
// "with N @-imports" suffix appears only when non-zero imports were resolved
expect(finding!.explanation).not.toMatch(/with \d+ @-import/)
})
})
// ============================================================================
// loadMcpConfigs + detectUnusedMcp
// ============================================================================
describe('loadMcpConfigs', () => {
it('returns empty map when no configs exist', () => {
const root = makeFixtureRoot()
const servers = loadMcpConfigs([root])
expect(servers.size).toBe(0)
})
it('reads servers from project .mcp.json', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { foo: { command: 'foo' }, bar: { command: 'bar' } },
}))
const servers = loadMcpConfigs([projectDir])
expect(servers.has('foo')).toBe(true)
expect(servers.has('bar')).toBe(true)
})
it('normalizes server names by replacing colons with underscores', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { 'plugin:context7:context7': { command: 'ctx' } },
}))
const servers = loadMcpConfigs([projectDir])
expect(servers.has('plugin_context7_context7')).toBe(true)
expect(servers.get('plugin_context7_context7')!.original).toBe('plugin:context7:context7')
})
it('handles malformed JSON without crashing', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), '{ not valid json')
expect(() => loadMcpConfigs([projectDir])).not.toThrow()
expect(loadMcpConfigs([projectDir]).size).toBe(0)
})
})
describe('detectUnusedMcp', () => {
it('flags servers configured but never called', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { ghost: { command: 'x' } },
}))
const configFile = join(projectDir, '.mcp.json')
touchOld(configFile, 30)
const finding = detectUnusedMcp([], [], new Set([projectDir]))
expect(finding).not.toBeNull()
expect(finding!.explanation).toContain('ghost')
})
it('does not flag servers configured within 24 hours', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { freshly_added: { command: 'x' } },
}))
expect(detectUnusedMcp([], [], new Set([projectDir]))).toBeNull()
})
it('does not flag servers that were called', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
mcpServers: { used: { command: 'x' } },
}))
touchOld(join(projectDir, '.mcp.json'), 30)
const calls: ToolCall[] = [
{ name: 'mcp__used__some_tool', input: {}, sessionId: 's1', project: 'p1' },
]
expect(detectUnusedMcp(calls, [], new Set([projectDir]))).toBeNull()
})
})
// ============================================================================
// detectBashBloat
// ============================================================================
describe('detectBashBloat', () => {
const originalEnv = process.env['BASH_MAX_OUTPUT_LENGTH']
beforeEach(() => {
delete process.env['BASH_MAX_OUTPUT_LENGTH']
})
afterAll(() => {
if (originalEnv !== undefined) process.env['BASH_MAX_OUTPUT_LENGTH'] = originalEnv
})
it('flags when env var is unset (uses default 30K)', () => {
const finding = detectBashBloat()
expect(finding).not.toBeNull()
expect(finding!.impact).toBe('medium')
})
it('does not flag when env var is at recommended 15K', () => {
process.env['BASH_MAX_OUTPUT_LENGTH'] = '15000'
expect(detectBashBloat()).toBeNull()
})
it('does not flag when env var is below recommended', () => {
process.env['BASH_MAX_OUTPUT_LENGTH'] = '10000'
expect(detectBashBloat()).toBeNull()
})
it('flags when env var is above 15K', () => {
process.env['BASH_MAX_OUTPUT_LENGTH'] = '50000'
const finding = detectBashBloat()
expect(finding).not.toBeNull()
})
})
// ============================================================================
// detectGhostCommands (the pure-function ghost detector)
// ============================================================================
describe('detectGhostCommands', () => {
it('returns null when no commands are defined', async () => {
expect(await detectGhostCommands([])).toBeNull()
})
it('does not match /tmp or /usr or other path prefixes as command usage', async () => {
const messages = [
'check /tmp/debug.log',
'look at /usr/local/bin',
'rm -rf /var/cache',
]
expect(await detectGhostCommands(messages)).toBeNull()
})
it('matches <command-name> tags in user messages', async () => {
const messages = ['<command-name>review</command-name>']
expect(await detectGhostCommands(messages)).toBeNull()
})
})
// ============================================================================
// scanJsonlFile
// ============================================================================
describe('scanJsonlFile', () => {
it('returns empty result for nonexistent file', async () => {
const result = await scanJsonlFile('/nonexistent/path.jsonl', 'p1', undefined)
expect(result.calls).toEqual([])
expect(result.cwds).toEqual([])
expect(result.apiCalls).toEqual([])
expect(result.userMessages).toEqual([])
})
it('parses tool_use blocks from assistant entries', async () => {
const root = makeFixtureRoot()
const filePath = join(root, 'session.jsonl')
const now = new Date().toISOString()
const lines = [
JSON.stringify({
type: 'assistant',
timestamp: now,
message: {
content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/x/foo.ts' } }],
},
}),
]
writeFile(filePath, lines.join('\n'))
const result = await scanJsonlFile(filePath, 'p1', undefined)
expect(result.calls).toHaveLength(1)
expect(result.calls[0].name).toBe('Read')
})
it('skips malformed JSONL lines without crashing', async () => {
const root = makeFixtureRoot()
const filePath = join(root, 'session.jsonl')
writeFile(filePath, 'this is not json\n{broken\n{"type":"assistant","message":{"content":[]}}\n')
const result = await scanJsonlFile(filePath, 'p1', undefined)
expect(result.calls).toEqual([])
})
it('respects date-range filter for assistant entries', async () => {
const root = makeFixtureRoot()
const filePath = join(root, 'session.jsonl')
const old = '2020-01-01T00:00:00Z'
const now = new Date().toISOString()
writeFile(filePath, [
JSON.stringify({
type: 'assistant', timestamp: old,
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/old' } }] },
}),
JSON.stringify({
type: 'assistant', timestamp: now,
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/new' } }] },
}),
].join('\n'))
const today = new Date()
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const result = await scanJsonlFile(filePath, 'p1', { start, end: today })
expect(result.calls).toHaveLength(1)
expect((result.calls[0].input as Record<string, unknown>).file_path).toBe('/new')
})
})
// ============================================================================
// scanAndDetect (top-level integration)
// ============================================================================
describe('scanAndDetect', () => {
it('returns healthy result for empty projects', async () => {
const result = await scanAndDetect([])
expect(result.findings).toEqual([])
expect(result.healthScore).toBe(100)
expect(result.healthGrade).toBe('A')
expect(result.costRate).toBe(0)
})
})
// ============================================================================
// context-budget
// ============================================================================
describe('estimateContextBudget', () => {
it('returns only system base when project has no config', async () => {
const root = makeFixtureRoot()
const budget = await estimateContextBudget(root)
expect(budget.total).toBeGreaterThan(0)
expect(budget.mcpTools.count).toBe(0)
expect(budget.skills.count).toBe(0)
})
it('includes MCP tools from project .mcp.json', async () => {
const root = makeFixtureRoot()
writeFile(join(root, '.mcp.json'), JSON.stringify({
mcpServers: { a: { command: 'x' }, b: { command: 'x' } },
}))
const budget = await estimateContextBudget(root)
expect(budget.mcpTools.count).toBeGreaterThan(0)
})
it('includes memory file tokens from CLAUDE.md', async () => {
const root = makeFixtureRoot()
writeFile(join(root, 'CLAUDE.md'), 'Project context for Claude.\n')
const budget = await estimateContextBudget(root)
expect(budget.memory.count).toBeGreaterThan(0)
expect(budget.memory.tokens).toBeGreaterThan(0)
})
})
describe('discoverProjectCwd', () => {
it('returns null for empty directory', async () => {
const root = makeFixtureRoot()
expect(await discoverProjectCwd(root)).toBeNull()
})
it('returns null for directory with no jsonl files', async () => {
const root = makeFixtureRoot()
writeFile(join(root, 'readme.txt'), 'hi')
expect(await discoverProjectCwd(root)).toBeNull()
})
it('extracts cwd from the first jsonl entry', async () => {
const root = makeFixtureRoot()
const entry = JSON.stringify({ type: 'assistant', cwd: '/Users/test/project', timestamp: new Date().toISOString() })
writeFile(join(root, 'session.jsonl'), entry + '\n')
expect(await discoverProjectCwd(root)).toBe('/Users/test/project')
})
})

301
tests/optimize.test.ts Normal file
View file

@ -0,0 +1,301 @@
import { describe, it, expect } from 'vitest'
import {
detectJunkReads,
detectDuplicateReads,
detectLowReadEditRatio,
detectCacheBloat,
detectBloatedClaudeMd,
computeHealth,
computeTrend,
type ToolCall,
type ApiCallMeta,
type WasteFinding,
} from '../src/optimize.js'
import type { ProjectSummary } from '../src/types.js'
function call(name: string, input: Record<string, unknown>, sessionId = 's1', project = 'p1'): ToolCall {
return { name, input, sessionId, project }
}
function emptyProjects(): ProjectSummary[] {
return []
}
describe('detectJunkReads', () => {
it('returns null below minimum threshold', () => {
const calls = [
call('Read', { file_path: '/x/node_modules/a.js' }),
call('Read', { file_path: '/x/node_modules/b.js' }),
]
expect(detectJunkReads(calls)).toBeNull()
})
it('flags when threshold is met', () => {
const calls = [
call('Read', { file_path: '/x/node_modules/a.js' }),
call('Read', { file_path: '/x/node_modules/b.js' }),
call('Read', { file_path: '/x/.git/config' }),
]
const finding = detectJunkReads(calls)
expect(finding).not.toBeNull()
expect(finding!.impact).toBe('low')
})
it('scales impact with read count', () => {
const make = (n: number) => Array.from({ length: n }, (_, i) =>
call('Read', { file_path: `/x/node_modules/file-${i}.js` })
)
expect(detectJunkReads(make(25))!.impact).toBe('high')
expect(detectJunkReads(make(10))!.impact).toBe('medium')
})
it('ignores non-junk paths', () => {
const calls = [
call('Read', { file_path: '/x/src/a.ts' }),
call('Read', { file_path: '/x/src/b.ts' }),
call('Read', { file_path: '/x/README.md' }),
]
expect(detectJunkReads(calls)).toBeNull()
})
it('ignores non-read tools', () => {
const calls = [
call('Edit', { file_path: '/x/node_modules/a.js' }),
call('Bash', { command: 'ls node_modules' }),
call('Grep', { pattern: 'test', path: '/x/node_modules' }),
]
expect(detectJunkReads(calls)).toBeNull()
})
it('handles missing file_path gracefully', () => {
const calls = [
call('Read', {}),
call('Read', { file_path: null as unknown as string }),
]
expect(detectJunkReads(calls)).toBeNull()
})
it('suggests CLAUDE.md advice listing detected and common junk dirs', () => {
const calls = Array.from({ length: 5 }, () => call('Read', { file_path: '/x/node_modules/a.js' }))
const finding = detectJunkReads(calls)!
expect(finding.fix.type).toBe('paste')
if (finding.fix.type === 'paste') {
expect(finding.fix.text).toContain('node_modules')
}
expect(finding.fix.label).toContain('CLAUDE.md')
})
})
describe('detectDuplicateReads', () => {
it('counts same file read multiple times in same session', () => {
const calls = [
...Array.from({ length: 4 }, () => call('Read', { file_path: '/src/a.ts' }, 's1')),
...Array.from({ length: 4 }, () => call('Read', { file_path: '/src/b.ts' }, 's1')),
]
const finding = detectDuplicateReads(calls)
expect(finding).not.toBeNull()
})
it('does not count across sessions', () => {
const calls = [
call('Read', { file_path: '/src/a.ts' }, 's1'),
call('Read', { file_path: '/src/a.ts' }, 's2'),
call('Read', { file_path: '/src/a.ts' }, 's3'),
]
expect(detectDuplicateReads(calls)).toBeNull()
})
it('excludes junk directory reads', () => {
const calls = Array.from({ length: 10 }, () =>
call('Read', { file_path: '/x/node_modules/foo.js' }, 's1')
)
expect(detectDuplicateReads(calls)).toBeNull()
})
it('returns null for single reads', () => {
const calls = [
call('Read', { file_path: '/src/a.ts' }, 's1'),
call('Read', { file_path: '/src/b.ts' }, 's1'),
]
expect(detectDuplicateReads(calls)).toBeNull()
})
})
describe('detectLowReadEditRatio', () => {
it('returns null below minimum edit count', () => {
const calls = [
call('Edit', {}),
call('Edit', {}),
call('Read', {}),
]
expect(detectLowReadEditRatio(calls)).toBeNull()
})
it('returns null when ratio is healthy', () => {
const calls = [
...Array.from({ length: 40 }, () => call('Read', {})),
...Array.from({ length: 10 }, () => call('Edit', {})),
]
expect(detectLowReadEditRatio(calls)).toBeNull()
})
it('flags when edits outpace reads', () => {
const calls = [
...Array.from({ length: 5 }, () => call('Read', {})),
...Array.from({ length: 10 }, () => call('Edit', {})),
]
const finding = detectLowReadEditRatio(calls)
expect(finding).not.toBeNull()
expect(finding!.impact).toBe('high')
})
it('counts Grep and Glob as reads for ratio', () => {
const calls = [
...Array.from({ length: 40 }, () => call('Grep', {})),
...Array.from({ length: 10 }, () => call('Edit', {})),
]
expect(detectLowReadEditRatio(calls)).toBeNull()
})
it('counts Write as edit', () => {
const calls = [
...Array.from({ length: 15 }, () => call('Read', {})),
...Array.from({ length: 10 }, () => call('Write', {})),
]
const finding = detectLowReadEditRatio(calls)
expect(finding).not.toBeNull()
})
})
describe('detectCacheBloat', () => {
it('returns null below minimum api calls', () => {
const apiCalls: ApiCallMeta[] = [
{ cacheCreationTokens: 80000, version: '2.1.100' },
{ cacheCreationTokens: 80000, version: '2.1.100' },
]
expect(detectCacheBloat(apiCalls, emptyProjects())).toBeNull()
})
it('returns null when median is close to baseline', () => {
const apiCalls: ApiCallMeta[] = Array.from({ length: 20 }, () => ({
cacheCreationTokens: 50000,
version: '2.1.98',
}))
expect(detectCacheBloat(apiCalls, emptyProjects())).toBeNull()
})
it('flags when median exceeds 1.4x baseline', () => {
const apiCalls: ApiCallMeta[] = Array.from({ length: 20 }, () => ({
cacheCreationTokens: 80000,
version: '2.1.100',
}))
const finding = detectCacheBloat(apiCalls, emptyProjects())
expect(finding).not.toBeNull()
})
})
describe('detectBloatedClaudeMd', () => {
it('returns null when no projects have CLAUDE.md', () => {
const result = detectBloatedClaudeMd(new Set(['/nonexistent/path']))
expect(result).toBeNull()
})
it('returns null for empty project set', () => {
const result = detectBloatedClaudeMd(new Set())
expect(result).toBeNull()
})
})
describe('computeHealth', () => {
it('returns A with 100 for no findings', () => {
const { score, grade } = computeHealth([])
expect(score).toBe(100)
expect(grade).toBe('A')
})
function mockFinding(impact: 'high' | 'medium' | 'low'): WasteFinding {
return {
title: 't', explanation: 'e', impact, tokensSaved: 1000,
fix: { type: 'paste', label: 'l', text: 't' },
}
}
it('one low finding stays at A', () => {
const { score, grade } = computeHealth([mockFinding('low')])
expect(score).toBe(97)
expect(grade).toBe('A')
})
it('two high findings drop to C', () => {
const { score, grade } = computeHealth([mockFinding('high'), mockFinding('high')])
expect(score).toBe(70)
expect(grade).toBe('C')
})
it('caps penalty at 80 to prevent score below 20', () => {
const findings = Array.from({ length: 20 }, () => mockFinding('high'))
const { score } = computeHealth(findings)
expect(score).toBe(20)
})
it('progresses grades predictably', () => {
expect(computeHealth([mockFinding('low')]).grade).toBe('A')
expect(computeHealth([mockFinding('medium')]).grade).toBe('A')
expect(computeHealth([mockFinding('medium'), mockFinding('medium')]).grade).toBe('B')
expect(computeHealth([mockFinding('high'), mockFinding('high'), mockFinding('high')]).grade).toBe('C')
expect(computeHealth([mockFinding('high'), mockFinding('high'), mockFinding('high'), mockFinding('high'), mockFinding('high')]).grade).toBe('F')
})
})
describe('computeTrend', () => {
const window = 48 * 60 * 60 * 1000
const baselineWindow = 5 * 24 * 60 * 60 * 1000
it('returns active when no recent activity detected', () => {
const trend = computeTrend({
recentCount: 0, recentWindowMs: window,
baselineCount: 100, baselineWindowMs: baselineWindow,
hasRecentActivity: false,
})
expect(trend).toBe('active')
})
it('returns resolved when recent activity exists but zero waste in it', () => {
const trend = computeTrend({
recentCount: 0, recentWindowMs: window,
baselineCount: 100, baselineWindowMs: baselineWindow,
hasRecentActivity: true,
})
expect(trend).toBe('resolved')
})
it('returns improving when recent rate is less than half of baseline rate', () => {
const trend = computeTrend({
recentCount: 5, recentWindowMs: window,
baselineCount: 100, baselineWindowMs: baselineWindow,
hasRecentActivity: true,
})
expect(trend).toBe('improving')
})
it('returns active when recent rate matches baseline rate', () => {
const recentRate = 100 / baselineWindow
const recentCount = Math.ceil(recentRate * window)
const trend = computeTrend({
recentCount, recentWindowMs: window,
baselineCount: 100, baselineWindowMs: baselineWindow,
hasRecentActivity: true,
})
expect(trend).toBe('active')
})
it('returns active when baseline is empty (new finding)', () => {
const trend = computeTrend({
recentCount: 10, recentWindowMs: window,
baselineCount: 0, baselineWindowMs: baselineWindow,
hasRecentActivity: true,
})
expect(trend).toBe('active')
})
})

View file

@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { filterProjectsByName } from '../src/parser.js'
import type { ProjectSummary } from '../src/types.js'
function makeProject(project: string, projectPath = project): ProjectSummary {
return {
project,
projectPath,
sessions: [],
totalCostUSD: 0,
totalApiCalls: 0,
}
}
describe('filterProjectsByName', () => {
const projects = [
makeProject('codeburn', '/Users/alice/codeburn'),
makeProject('AgentSeal', '/Users/alice/projects/AgentSeal'),
makeProject('dashboard', '/Users/alice/AgentSeal/dashboard'),
makeProject('sandbox', '/tmp/sandbox'),
]
it('returns all projects when no filters given', () => {
expect(filterProjectsByName(projects)).toEqual(projects)
expect(filterProjectsByName(projects, [], [])).toEqual(projects)
expect(filterProjectsByName(projects, undefined, undefined)).toEqual(projects)
})
it('include matches project name (case-insensitive substring)', () => {
const result = filterProjectsByName(projects, ['codeburn'])
expect(result.map(p => p.project)).toEqual(['codeburn'])
})
it('include is case-insensitive', () => {
const result = filterProjectsByName(projects, ['AGENTSEAL'])
expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
})
it('include matches substring in path when name does not match', () => {
const result = filterProjectsByName(projects, ['alice/projects'])
expect(result.map(p => p.project)).toEqual(['AgentSeal'])
})
it('include uses OR semantics across patterns', () => {
const result = filterProjectsByName(projects, ['codeburn', 'sandbox'])
expect(result.map(p => p.project).sort()).toEqual(['codeburn', 'sandbox'])
})
it('exclude removes matching projects (AND-negation across patterns)', () => {
const result = filterProjectsByName(projects, undefined, ['codeburn', 'sandbox'])
expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
})
it('exclude matches path substring', () => {
const result = filterProjectsByName(projects, undefined, ['/tmp'])
expect(result.map(p => p.project)).not.toContain('sandbox')
})
it('exclude is applied after include', () => {
const result = filterProjectsByName(projects, ['AgentSeal'], ['dashboard'])
expect(result.map(p => p.project)).toEqual(['AgentSeal'])
})
it('returns empty array when no project matches include', () => {
expect(filterProjectsByName(projects, ['does-not-exist'])).toEqual([])
})
it('empty-string pattern matches every project', () => {
const resultInclude = filterProjectsByName(projects, [''])
expect(resultInclude).toHaveLength(projects.length)
const resultExclude = filterProjectsByName(projects, undefined, [''])
expect(resultExclude).toEqual([])
})
it('does not mutate the input array', () => {
const input = [makeProject('a'), makeProject('b')]
const snapshot = [...input]
filterProjectsByName(input, ['a'], ['b'])
expect(input).toEqual(snapshot)
})
})

View file

@ -29,7 +29,7 @@ function createTestDb(dir: string): string {
mkdirSync(ocDir, { recursive: true })
const dbPath = join(ocDir, 'opencode.db')
const Database = require('better-sqlite3')
const { DatabaseSync: Database } = require('node:sqlite')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE session (
@ -57,7 +57,7 @@ function createTestDb(dir: string): string {
}
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
const Database = require('better-sqlite3')
const { DatabaseSync: Database } = require('node:sqlite')
const db = new Database(dbPath)
fn(db)
db.close()
@ -221,7 +221,7 @@ skipUnlessSqlite('opencode provider - session discovery', () => {
const ocDir = join(tmpDir, 'opencode')
await mkdir(ocDir, { recursive: true })
const Database = require('better-sqlite3')
const { DatabaseSync: Database } = require('node:sqlite')
for (const file of ['opencode.db', 'opencode-dev.db']) {
const dbPath = join(ocDir, file)
const db = new Database(dbPath)

View file

@ -0,0 +1,77 @@
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
import { mkdtemp, mkdir, cp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { parseAllSessions } from '../../src/parser.js'
import type { DateRange } from '../../src/types.js'
// Fixtures carry timestamp 2026-04-16T00:00:00Z. The range below must stay
// wide enough to include that date; if the fixtures move, move FIXTURE_DAY too.
const FIXTURE_DAY = Date.UTC(2026, 3, 16) // month index 3 = April (Date.UTC is 0-indexed)
const RANGE_BEFORE_MS = FIXTURE_DAY - 24 * 60 * 60 * 1000
const RANGE_AFTER_MS = FIXTURE_DAY + 24 * 60 * 60 * 1000
const PROJECT_NAME = 'codeburn-poc-testing'
function makeRange(offsetMs: number): DateRange {
return {
start: new Date(RANGE_BEFORE_MS + offsetMs),
end: new Date(RANGE_AFTER_MS + offsetMs),
}
}
// Hermeticity note: the Claude provider also scans a fixed Desktop sessions
// dir independent of CLAUDE_CONFIG_DIR. The narrow dateRange above excludes
// any real sessions in practice, but these tests are not strictly isolated
// on a machine with April 2026 Claude Desktop activity. A stricter fix
// belongs in a follow-up to discoverSessions itself.
describe('HIGH-1 prototype pollution via unchecked bracket-assign', () => {
const tmpDirs: string[] = []
let originalConfigDir: string | undefined
beforeEach(() => {
originalConfigDir = process.env['CLAUDE_CONFIG_DIR']
})
afterEach(async () => {
delete (Object.prototype as Record<string, unknown>).calls
if (originalConfigDir === undefined) {
delete process.env['CLAUDE_CONFIG_DIR']
} else {
process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir
}
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function setupPoc(fixture: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-sec-'))
tmpDirs.push(base)
const projectDir = join(base, 'projects', PROJECT_NAME)
await mkdir(projectDir, { recursive: true })
await cp(join(__dirname, '..', 'fixtures', 'security', fixture), join(projectDir, 'pwn.jsonl'))
process.env['CLAUDE_CONFIG_DIR'] = base
return base
}
it('does not pollute Object.prototype when session contains tool_use name "__proto__"', async () => {
await setupPoc('proto-tool.jsonl')
await expect(parseAllSessions(makeRange(0), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
it('does not pollute Object.prototype when bash command basename is "__proto__"', async () => {
await setupPoc('proto-bash.jsonl')
await expect(parseAllSessions(makeRange(1), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
it('does not pollute Object.prototype when model name is "__proto__"', async () => {
await setupPoc('proto-model.jsonl')
await expect(parseAllSessions(makeRange(2), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
})