Guidelines for AI agents working in this repository.
Python CLI tool that reads OpenCode's local SQLite database and displays token usage statistics, with an optional LLM-powered insights analysis that produces an HTML report. Uses Rich for terminal rendering, argparse for CLI with subcommands, and dataclasses for data structures.
Two modes of operation:
run(default) — Token usage tables: daily breakdown, group-by model/agent/provider/session, period comparison, JSON export.insights— LLM-powered analysis pipeline: extract session transcripts → concurrent LLM facet extraction → aggregate analysis → generate a self-contained HTML report.
Stack: Python 3.10+, Rich, SQLite, pytest, Ruff, uv
src/opencode_usage/
__init__.py # Package init, __version__ via importlib.metadata
__main__.py # `python -m opencode_usage` entry
_opencode_cli.py # Wrapper around `opencode` binary (db path, config paths, models)
auth.py # Credential resolution (env → auth.json → opencode.json)
cli.py # Subcommand parser (run/insights), main() dispatcher
db.py # SQLite queries, dataclasses (TokenStats, UsageRow, OpenCodeDB)
llm.py # Thin OpenAI-compatible HTTP client (stdlib urllib)
models.py # Model discovery, tier-based ranking, interactive picker
render.py # Rich table rendering, formatting helpers
insights/ # LLM-powered analysis subpackage
__init__.py # Re-exports: AggregatedStats, FacetCache, InsightsConfig, SessionFacet, SessionMeta
types.py # Dataclasses: SessionFacet, SessionMeta, AggregatedStats, InsightsConfig
extract.py # Session filtering, metadata extraction, transcript reconstruction, stats
analyze.py # Concurrent LLM facet extraction, aggregate analysis, NDJSON parsing
cache.py # Per-session facet cache with atomic writes (.tmp → os.rename)
prompts.py # Prompt builders for 8 analysis dimensions
report.py # HTML report generation (9-section terminal-hacker aesthetic)
orchestrator.py # Pipeline: extract → analyze → report, Rich progress display
tests/
conftest.py # autouse fixture: clears lru_cache between tests
test_auth.py # Credential resolution, list_providers
test_cli.py # CLI parsing, _resolve_since, _compute_deltas, _fetch_rows
test_db.py # DB queries with in-memory SQLite fixtures
test_insights_analyze.py # Facet extraction, aggregate analysis, NDJSON/JSON parsing
test_insights_cache.py # FacetCache has/get/put/clear, corruption handling
test_insights_extract.py # Session filtering, metadata, transcript, stats extraction
test_insights_orchestrator.py # Pipeline orchestration, graceful degradation
test_insights_prompts.py # Prompt builder output validation
test_insights_report.py # HTML report sections, CSS, rendering helpers
test_insights_types.py # Dataclass defaults, field types
test_llm.py # HTTP client, error handling, JSON parsing
test_models.py # Model listing, ranking, search, tier sorting
test_opencode_cli.py # CLI wrapper, path resolution, XDG fallbacks
test_render.py # Formatting helpers (_fmt_tokens, _spark_bar, etc.)
The CLI uses argparse subcommands. When invoked without a subcommand (or with flags only), it auto-defaults to run.
Token usage statistics with tabular output.
opencode-usage # default: last 7 days, daily breakdown
opencode-usage run --days 30
opencode-usage run --since 7d # relative: 7d, 2w, 30d, 3h
opencode-usage run --since 2025-01-01 # ISO date
opencode-usage run --by model # group by: model, agent, provider, session, day
opencode-usage run --by agent --limit 10
opencode-usage run --json
opencode-usage run --compare # compare with previous period of same length| Flag | Type | Default | Description |
|---|---|---|---|
--days N |
int | 7 | Show last N days |
--since SPEC |
str | — | '7d', '2w', '3h', or ISO date |
--by DIM |
choice | day |
model, agent, provider, session, day |
--limit N |
int | — | Max rows to display |
--json |
flag | — | Output as JSON |
--compare |
flag | — | Compare with previous period |
LLM-powered usage analysis → HTML report.
opencode-usage insights # interactive model picker
opencode-usage insights --model gpt-4o-mini # specific model
opencode-usage insights --force # ignore cache, re-analyze
opencode-usage insights --concurrency 4 # limit parallel LLM workers
opencode-usage insights --output report.html # custom output path
opencode-usage insights --days 30 # last 30 days (default: 30)| Flag | Type | Default | Description |
|---|---|---|---|
--days N |
int | 30 | Show last N days |
--since SPEC |
str | — | '7d', '2w', '3h', or ISO date |
--model ID |
str | — | Model for analysis (interactive picker if omitted) |
--force |
flag | — | Re-analyze, ignore cache |
--concurrency N |
int | min(cpu_count, 8) |
Max parallel LLM workers |
--output PATH |
str | ./opencode-insights.html |
Output path for HTML report |
| Flag | Description |
|---|---|
-V, --version |
Print version and exit |
# Install dependencies
uv sync
# Run all tests
uv run pytest tests/
# Run a single test file
uv run pytest tests/test_render.py
# Run a single test class
uv run pytest tests/test_render.py::TestSparkBar
# Run a single test method
uv run pytest tests/test_render.py::TestSparkBar::test_mid_value
# Verbose output
uv run pytest tests/ -v
# Lint (check only, matches CI)
uvx ruff check .
uvx ruff format --check .
# Lint (auto-fix, matches pre-commit)
uvx ruff check --fix .
uvx ruff format .Important: CI runs ruff check and ruff format --check (no auto-fix). Always run both checks before committing to avoid CI failures. Pre-commit hooks auto-fix but CI does not.
- CI (
.github/workflows/ci.yml): Runs on push/PR to main. Lint with ruff, test on Python 3.10/3.12/3.13. - Release (
.github/workflows/release.yml): Runs on push to main. Release-please manages version PRs and auto-syncsuv.lock; on merge, publishes to PyPI and creates GitHub Release.
Defined in pyproject.toml:
- Line length: 100
- Target: py310
- Rules: E, W, F, I (isort), UP (pyupgrade), B (bugbear), SIM (simplify), RUF
Every module starts with from __future__ import annotations. Group order:
from __future__ import annotations # 1. Always first
import os # 2. Standard library (alphabetized)
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from rich.console import Console # 3. Third-party
from .db import OpenCodeDB, UsageRow # 4. Local (relative imports)Use TYPE_CHECKING for imports only needed by type checkers:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .db import UsageRow| Element | Convention | Example |
|---|---|---|
| Classes | PascalCase | OpenCodeDB, TokenStats, SessionFacet |
| Public functions | snake_case | daily, render_summary, filter_sessions |
| Private functions | _snake_case |
_fmt_tokens, _connect, _tier |
| Private modules | _snake_case.py |
_opencode_cli.py |
| Module constants | _UPPER_CASE |
_BAR_WIDTH_DEFAULT, _PREFERRED, _MAX_CONCURRENCY |
| Variables | snake_case | db_path, group_by |
| Test classes | Test{Feature} |
TestSparkBar, TestDaily, TestFacetCache |
| Test methods | test_{behavior} |
test_zero_returns_dash |
Annotate all function signatures and non-obvious variables. Use | union syntax (not Union/Optional):
def __init__(self, db_path: Path | str | None = None) -> None:
def _time_filter(self, since: datetime | None, until: datetime | None = None) -> tuple[str, list[Any]]:
d: dict[str, Any] = { ... }One-line triple-quoted summaries. No parameter/return docs:
"""SQLite query layer for OpenCode's database.""" # module
"""Read-only access to the OpenCode SQLite database.""" # class
"""Human-readable token count.""" # function- Use built-in exceptions with descriptive messages — no custom exception classes
try/finallyfor resource cleanup (DB connections)argparse.ArgumentTypeErrorfor CLI input validation- Catch
FileNotFoundErrorinmain(), print with Rich markup,sys.exit(1) RuntimeErrorfor auth failures, HTTP errors, LLM parse errorswarnings.warnfor non-critical LLM failures (individual session extraction)- Graceful degradation: when
opencodebinary is unavailable, generate data-only report
# DB connection pattern
conn = self._connect()
try:
rows = conn.execute(sql, params).fetchall()
finally:
conn.close()
# LLM error pattern — retry with exponential backoff
for attempt in range(max_retries):
try:
result = subprocess.run([...], timeout=timeout)
except subprocess.TimeoutExpired:
if attempt < max_retries - 1:
time.sleep(min(2**attempt * 2, 60))
continueUse @dataclass for data containers. Default values via field(default_factory=...):
@dataclass
class UsageRow:
label: str
calls: int = 0
tokens: TokenStats = field(default_factory=TokenStats)
cost: float = 0.0
detail: str | None = None
@dataclass
class SessionFacet:
session_id: str
underlying_goal: str
goal_categories: dict[str, int] = field(default_factory=dict)
outcome: str = ""
# ... (structured LLM extraction result per session)
@dataclass
class InsightsConfig:
model: str
days: int | None = None
since: datetime | None = None
force: bool = False
output_path: str = "./opencode-insights.html"
concurrency: int | None = None- Framework: pytest (no unittest)
- Structure: Test classes grouped by feature, one class per function/component
- Fixtures:
@pytest.fixture()for shared setup (e.g., temp DB with test data) - Shared fixtures:
conftest.pyprovidesautousefixture that clearslru_cacheon_opencode_clihelpers between every test - Assertions: Plain
assert,pytest.approxfor floats,pytest.raisesfor exceptions - Mocking:
unittest.mock.patchfor subprocess calls, file I/O, and LLM responses - Section comments separate test groups:
# ── _fmt_tokens ───────────── - Test files mirror source:
test_db.pytestsdb.py,test_render.pytestsrender.py, insights tests usetest_insights_{module}.pypattern
class TestFmtCost:
def test_zero_returns_dash(self):
assert _fmt_cost(0) == "-"
def test_small_value_four_decimals(self):
assert _fmt_cost(0.001) == "$0.0010"DB tests create in-memory SQLite with realistic JSON message data:
@pytest.fixture()
def db_path(tmp_path: Path) -> Path:
db_file = tmp_path / "opencode.db"
conn = sqlite3.connect(str(db_file))
conn.execute("CREATE TABLE message (...)")
# Insert test rows with JSON data
conn.close()
return db_fileSemantic commits in English. No co-author trailers, no footers:
feat: add horizontal trend bar to daily view
feat(cli): add --version / -V flag
feat(insights): add concurrent LLM analysis with ThreadPoolExecutor
fix(db): use ~/.local/share path on all platforms
refactor(cli): restructure argument parsing with run/insights subcommands
chore(release): bump version to 0.2.3
test: add unit test suite
docs: add PyPI installation instructions
- Console: Module-level
console = Console()in render.py and orchestrator.py; honorsNO_COLORenv var automatically via Rich - SQL: Raw f-strings for dynamic GROUP BY/ORDER/WHERE, parameterized
?for user values - Datetime: Always timezone-aware (
datetime.now().astimezone()), stored as milliseconds in SQLite - JSON output:
round(cost, 4),ensure_ascii=False,indent=2 - Version:
importlib.metadata.version("opencode-usage")in__init__.py, canonical source ispyproject.toml - CLI wrapper (
_opencode_cli.py):subprocess.runto invokeopencodebinary, results cached with@lru_cache, XDG-based fallback paths when binary unavailable - Credential chain (
auth.py): Environment variable →auth.json→opencode.jsonfor API key and base URL - Concurrent LLM (
analyze.py):ThreadPoolExecutorwith configurable worker count,warnings.warnon per-session failures, NDJSON stream parsing - Atomic cache (
cache.py): Write to.tmpfile thenos.rename()for crash safety, one JSON file per session ID - Insights pipeline (
orchestrator.py): Phase 1 (extract from DB) → Phase 2 (concurrent LLM analysis) → Phase 3 (HTML report), with graceful degradation to data-only mode - Model ranking (
models.py): Tier-based sorting (preferred → opencode/* → github-copilot/* → others), interactive picker with search
| Environment Variable | Description |
|---|---|
OPENCODE_DB |
Override database path (default: auto-detected via opencode db path or ~/.local/share/opencode/opencode.db) |
NO_COLOR |
Disable colored output when set (via Rich) |
{PROVIDER}_API_KEY |
API key for insights LLM provider (e.g. OPENAI_API_KEY) |
{PROVIDER}_BASE_URL |
Base URL override for insights LLM provider |