Repository: ryoppippi/ccusage Branch: main Commit: 2d4222c35067 Files: 211 Total size: 934.0 KB Directory structure: gitextract_lintubch/ ├── .claude/ │ ├── commands/ │ │ └── reduce-similarities.md │ └── skills/ │ ├── byethrow/ │ │ └── SKILL.md │ └── use-gunshi-cli/ │ └── SKILL.md ├── .envrc ├── .githooks/ │ └── pre-commit ├── .github/ │ ├── FUNDING.yaml │ ├── actions/ │ │ └── setup-nix/ │ │ └── action.yaml │ ├── renovate.json │ └── workflows/ │ ├── check-pr-title.yaml │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .mcp.json ├── .oxfmtrc.jsonc ├── CLAUDE.md ├── apps/ │ ├── amp/ │ │ ├── CLAUDE.md │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── _consts.ts │ │ │ ├── _macro.ts │ │ │ ├── _types.ts │ │ │ ├── commands/ │ │ │ │ ├── daily.ts │ │ │ │ ├── index.ts │ │ │ │ ├── monthly.ts │ │ │ │ └── session.ts │ │ │ ├── data-loader.ts │ │ │ ├── index.ts │ │ │ ├── logger.ts │ │ │ ├── pricing.ts │ │ │ └── run.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── ccusage/ │ │ ├── CLAUDE.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── config-schema.json │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── generate-json-schema.ts │ │ ├── src/ │ │ │ ├── _config-loader-tokens.ts │ │ │ ├── _consts.ts │ │ │ ├── _daily-grouping.ts │ │ │ ├── _date-utils.ts │ │ │ ├── _jq-processor.ts │ │ │ ├── _json-output-types.ts │ │ │ ├── _macro.ts │ │ │ ├── _pricing-fetcher.ts │ │ │ ├── _project-names.ts │ │ │ ├── _session-blocks.ts │ │ │ ├── _shared-args.ts │ │ │ ├── _token-utils.ts │ │ │ ├── _types.ts │ │ │ ├── _utils.ts │ │ │ ├── calculate-cost.ts │ │ │ ├── commands/ │ │ │ │ ├── _session_id.ts │ │ │ │ ├── blocks.ts │ │ │ │ ├── daily.ts │ │ │ │ ├── index.ts │ │ │ │ ├── monthly.ts │ │ │ │ ├── session.ts │ │ │ │ ├── statusline.ts │ │ │ │ └── weekly.ts │ │ │ ├── data-loader.ts │ │ │ ├── debug.ts │ │ │ ├── index.ts │ │ │ └── logger.ts │ │ ├── test/ │ │ │ ├── statusline-test-opus4.json │ │ │ ├── statusline-test-sonnet4.json │ │ │ ├── statusline-test-sonnet41.json │ │ │ ├── statusline-test.json │ │ │ └── test-transcript.jsonl │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── codex/ │ │ ├── CLAUDE.md │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── _consts.ts │ │ │ ├── _macro.ts │ │ │ ├── _shared-args.ts │ │ │ ├── _types.ts │ │ │ ├── command-utils.ts │ │ │ ├── commands/ │ │ │ │ ├── daily.ts │ │ │ │ ├── monthly.ts │ │ │ │ └── session.ts │ │ │ ├── daily-report.ts │ │ │ ├── data-loader.ts │ │ │ ├── date-utils.ts │ │ │ ├── index.ts │ │ │ ├── logger.ts │ │ │ ├── monthly-report.ts │ │ │ ├── pricing.ts │ │ │ ├── run.ts │ │ │ ├── session-report.ts │ │ │ └── token-utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── mcp/ │ │ ├── CLAUDE.md │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── ccusage.ts │ │ │ ├── cli-utils.ts │ │ │ ├── codex.ts │ │ │ ├── command.ts │ │ │ ├── consts.ts │ │ │ ├── index.ts │ │ │ ├── mcp-utils.ts │ │ │ └── mcp.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── opencode/ │ │ ├── CLAUDE.md │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── commands/ │ │ │ │ ├── daily.ts │ │ │ │ ├── index.ts │ │ │ │ ├── monthly.ts │ │ │ │ ├── session.ts │ │ │ │ └── weekly.ts │ │ │ ├── cost-utils.ts │ │ │ ├── data-loader.ts │ │ │ ├── index.ts │ │ │ ├── logger.ts │ │ │ └── run.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── pi/ │ ├── CLAUDE.md │ ├── README.md │ ├── eslint.config.js │ ├── package.json │ ├── src/ │ │ ├── _consts.ts │ │ ├── _pi-agent.ts │ │ ├── _types.ts │ │ ├── commands/ │ │ │ ├── daily.ts │ │ │ ├── index.ts │ │ │ ├── monthly.ts │ │ │ └── session.ts │ │ ├── data-loader.ts │ │ ├── index.ts │ │ └── logger.ts │ ├── tsconfig.json │ ├── tsdown.config.ts │ └── vitest.config.ts ├── ccusage.example.json ├── docs/ │ ├── .gitignore │ ├── .vitepress/ │ │ └── config.ts │ ├── CLAUDE.md │ ├── eslint.config.js │ ├── guide/ │ │ ├── blocks-reports.md │ │ ├── cli-options.md │ │ ├── codex/ │ │ │ ├── daily.md │ │ │ ├── index.md │ │ │ ├── monthly.md │ │ │ └── session.md │ │ ├── config-files.md │ │ ├── configuration.md │ │ ├── cost-modes.md │ │ ├── custom-paths.md │ │ ├── daily-reports.md │ │ ├── directory-detection.md │ │ ├── environment-variables.md │ │ ├── getting-started.md │ │ ├── index.md │ │ ├── installation.md │ │ ├── json-output.md │ │ ├── library-usage.md │ │ ├── live-monitoring.md │ │ ├── mcp-server.md │ │ ├── monthly-reports.md │ │ ├── opencode/ │ │ │ └── index.md │ │ ├── pi/ │ │ │ └── index.md │ │ ├── related-projects.md │ │ ├── session-reports.md │ │ ├── sponsors.md │ │ ├── statusline.md │ │ └── weekly-reports.md │ ├── index.md │ ├── package.json │ ├── public/ │ │ └── mcp-claude-desktop.avif │ ├── tsconfig.json │ ├── typedoc.config.ts │ ├── update-api-index.ts │ └── wrangler.jsonc ├── eslint.config.js ├── flake.nix ├── package.json ├── packages/ │ ├── internal/ │ │ ├── CLAUDE.md │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── format.ts │ │ │ ├── logger.ts │ │ │ ├── pricing-fetch-utils.ts │ │ │ └── pricing.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ └── terminal/ │ ├── CLAUDE.md │ ├── eslint.config.js │ ├── package.json │ ├── src/ │ │ ├── table.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── pnpm-workspace.yaml └── typos.toml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/commands/reduce-similarities.md ================================================ Run `similarity-ts .` to detect semantic code similarities. Execute this command, analyze the duplicate code patterns, and create a refactoring plan. Check `similarity-ts -h` for detailed options. ================================================ FILE: .claude/skills/byethrow/SKILL.md ================================================ --- name: byethrow description: Reference the byethrow documentation to understand and use the Result type library for error handling in JavaScript/TypeScript. Access detailed API references, practical usage examples, and best practice guides. allowed-tools: Read, Grep, Glob --- ## About byethrow `@praha/byethrow` is a lightweight, tree-shakable Result type library for handling fallible operations in JavaScript and TypeScript. It provides a simple, consistent API for managing errors and results without throwing exceptions. For detailed API references and usage examples, refer to the documentation in `node_modules/@praha/byethrow-docs/docs/**/*.md`. ### Documentation CLI The byethrow documentation CLI provides commands to browse, search, and navigate documentation directly from your terminal. #### `list` command List all available documentation organized by sections. ```bash # List all documentation npx @praha/byethrow-docs list # List documentation with filter query npx @praha/byethrow-docs list --query "your query" ``` **Options:** - `--query `: Filter documentation by keywords (optional) #### `search` command Search documentation and get matching results with highlighted snippets. ```bash # Search documentation npx @praha/byethrow-docs search "your query" # Limit number of results (default: 5) npx @praha/byethrow-docs search "your query" --limit 10 ``` **Arguments:** - `query`: Search query string (required) **Options:** - `--limit `: Maximum number of results to return (default: 5) #### `toc` command Display table of contents from a documentation file. ```bash # Display table of contents from a markdown file npx @praha/byethrow-docs toc path/to/document.md ``` **Arguments:** - `path`: Path to the documentation file (required) ================================================ FILE: .claude/skills/use-gunshi-cli/SKILL.md ================================================ --- name: use-gunshi-cli description: Use the Gunshi library to create command-line interfaces in JavaScript/TypeScript. globs: '*.ts, *.tsx, *.js, *.jsx, package.json' alwaysApply: false --- use gunshi library for creating cli instead of other libraries including cac, yargs, commander, etc. Gunshi is a modern javascript command-line library For more information, read the gunshi API docs in `node_modules/@gunshi/docs/**.md`. ================================================ FILE: .envrc ================================================ watch_file pnpm-lock.yaml watch_file pnpm-workspace.yaml use flake ================================================ FILE: .githooks/pre-commit ================================================ #!/bin/sh # Run lint-staged npx --no-install lint-staged ================================================ FILE: .github/FUNDING.yaml ================================================ github: ryoppippi ================================================ FILE: .github/actions/setup-nix/action.yaml ================================================ name: Setup Nix description: Install Nix and configure Cachix inputs: cachix-auth-token: description: Cachix authentication token required: false runs: using: composite steps: - name: Install Nix uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 with: github_access_token: ${{ github.token }} - name: Setup Cachix (numtide) uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 with: name: numtide authToken: '' - name: Setup Cachix (ryoppippi) if: inputs.cachix-auth-token != '' uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 with: name: ryoppippi authToken: ${{ inputs.cachix-auth-token }} - name: Load Nix development environment shell: bash run: nix develop --command true ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "nix": { "enabled": true }, "extends": ["github>ryoppippi/renovate-config:no-group"] } ================================================ FILE: .github/workflows/check-pr-title.yaml ================================================ name: Check PR title on: pull_request: types: - opened - reopened - edited - synchronize permissions: pull-requests: read jobs: main: name: Validate PR title runs-on: ubuntu-slim steps: - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: ignoreLabels: | autorelease: pending dependencies ================================================ FILE: .github/workflows/ci.yaml ================================================ name: CI on: push: pull_request: jobs: lint-check: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-nix - run: nix develop --command pnpm lint - run: nix develop --command pnpm typecheck test: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-nix - name: Create default Claude directories for tests run: | mkdir -p $HOME/.claude/projects mkdir -p $HOME/.config/claude/projects - run: nix develop --command pnpm run test npm-publish-dry-run-and-upload-pkg-pr-now: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-nix - run: nix develop --command pnpm pkg-pr-new publish --pnpm './apps/*' spell-check: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-nix - run: nix develop --command typos --config ./typos.toml schema-check: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-nix - name: Generate schema files run: nix develop --command pnpm run generate:schema working-directory: apps/ccusage - name: Check if schema files are up-to-date run: | if git diff --exit-code apps/ccusage/config-schema.json docs/public/config-schema.json; then echo "✅ Schema files are up-to-date" else echo "❌ Schema files are not up-to-date. Please run 'pnpm run generate:schema' and commit the changes." echo "" echo "Changed files:" git diff --name-only apps/ccusage/config-schema.json docs/public/config-schema.json echo "" echo "Diff:" git diff apps/ccusage/config-schema.json docs/public/config-schema.json exit 1 fi ================================================ FILE: .github/workflows/release.yaml ================================================ name: npm publish on: push: tags: - '*' jobs: npm: runs-on: ubuntu-24.04-arm timeout-minutes: 10 permissions: contents: read id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - uses: ./.github/actions/setup-nix - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: registry-url: 'https://registry.npmjs.org' node-version: lts/* - run: nix develop --command pnpm --filter='./apps/**' publish --provenance --no-git-checks --access public release: needs: - npm runs-on: ubuntu-24.04-arm permissions: contents: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - uses: ./.github/actions/setup-nix - run: nix develop --command pnpm changelogithub env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} ================================================ FILE: .gitignore ================================================ # dependencies (bun install) node_modules # output out dist *.tgz # code coverage coverage *.lcov # logs logs _.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # caches .eslintcache .cache *.tsbuildinfo # IntelliJ based IDEs .idea # Finder (MacOS) folder config .DS_Store .eslintcache # nix .direnv !.envrc ================================================ FILE: .mcp.json ================================================ { "mcpServers": { "context7": { "type": "http", "url": "https://mcp.context7.com/mcp" }, "grep": { "type": "http", "url": "https://mcp.grep.app" } } } ================================================ FILE: .oxfmtrc.jsonc ================================================ { "$schema": "https://unpkg.com/oxfmt/configuration_schema.json", "useTabs": true, "singleQuote": true, "files": { "ignore": ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/pnpm-lock.yaml"], }, } ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Monorepo Structure This is a monorepo containing multiple packages. For package-specific guidance, refer to the individual CLAUDE.md files: - **Main CLI Package**: @apps/ccusage/CLAUDE.md - Core ccusage CLI tool and library - **Codex CLI Package**: @apps/codex/CLAUDE.md - OpenAI Codex usage tracking CLI - **OpenCode CLI Package**: @apps/opencode/CLAUDE.md - OpenCode usage tracking CLI - **MCP Server Package**: @apps/mcp/CLAUDE.md - MCP server implementation for ccusage data - **Documentation**: @docs/CLAUDE.md - VitePress-based documentation website Each package has its own development commands, dependencies, and specific guidelines. Always check the relevant package's CLAUDE.md when working within that package directory. ### Apps Are Bundled All projects under `apps/` ship as bundled CLIs/binaries. Treat their runtime dependencies as bundled assets: list everything in each app's `devDependencies` (never `dependencies`) so the bundler owns the runtime payload. ## Development Commands **Testing and Quality:** - `pnpm run test` - Run all tests (using vitest via pnpm, watch mode disabled) - Lint code using ESLint MCP server (available via Claude Code tools) - `pnpm run format` - Format code with ESLint (writes changes) - `pnpm typecheck` - Type check with TypeScript **Build and Release:** - `pnpm run build` - Build distribution files with tsdown - `pnpm run release` - Full release workflow (lint + typecheck + test + build + version bump) **Development Usage:** - `pnpm run start daily` - Show daily usage report - `pnpm run start monthly` - Show monthly usage report - `pnpm run start session` - Show session-based usage report - `pnpm run start blocks` - Show 5-hour billing blocks usage report - `pnpm run start statusline` - Show compact status line (Beta) - `pnpm run start daily --json` - Show daily usage report in JSON format - `pnpm run start monthly --json` - Show monthly usage report in JSON format - `pnpm run start session --json` - Show session usage report in JSON format - `pnpm run start blocks --json` - Show blocks usage report in JSON format - `pnpm run start daily --mode ` - Control cost calculation mode (auto/calculate/display) - `pnpm run start monthly --mode ` - Control cost calculation mode (auto/calculate/display) - `pnpm run start session --mode ` - Control cost calculation mode (auto/calculate/display) - `pnpm run start blocks --mode ` - Control cost calculation mode (auto/calculate/display) - `pnpm run start blocks --active` - Show only active block with projections - `pnpm run start blocks --recent` - Show blocks from last 3 days (including active) - `pnpm run start blocks --token-limit ` - Token limit for quota warnings (number or "max") - `node ./src/index.ts` - Direct execution for development **MCP Server Usage:** (now provided by the `@ccusage/mcp` package) - `pnpm dlx @ccusage/mcp@latest -- --help` - Show available options - `pnpm dlx @ccusage/mcp@latest -- --type http --port 8080` - Start HTTP transport **Cost Calculation Modes:** - `auto` (default) - Use pre-calculated costUSD when available, otherwise calculate from tokens - `calculate` - Always calculate costs from token counts using model pricing, ignore costUSD - `display` - Always use pre-calculated costUSD values, show 0 for missing costs **Environment Variables:** - `LOG_LEVEL` - Control logging verbosity (0=silent, 1=warn, 2=log, 3=info, 4=debug, 5=trace) - Example: `LOG_LEVEL=0 pnpm run start daily` for silent output - Useful for debugging or suppressing non-critical output **Multiple Claude Data Directories:** This tool supports multiple Claude data directories to handle different Claude Code installations: - **Default Behavior**: Automatically searches both `~/.config/claude/projects/` (new default) and `~/.claude/projects/` (old default) - **Environment Variable**: Set `CLAUDE_CONFIG_DIR` to specify custom path(s) - Single path: `export CLAUDE_CONFIG_DIR="/path/to/claude"` - Multiple paths: `export CLAUDE_CONFIG_DIR="/path/to/claude1,/path/to/claude2"` - **Data Aggregation**: Usage data from all valid directories is automatically combined - **Backward Compatibility**: Existing configurations continue to work without changes This addresses the breaking change in Claude Code where logs moved from `~/.claude` to `~/.config/claude`. ## Architecture Overview This is a CLI tool that analyzes Claude Code usage data from local JSONL files stored in Claude data directories (supports both `~/.claude/projects/` and `~/.config/claude/projects/`). The architecture follows a clear separation of concerns: **Core Data Flow:** 1. **Data Loading** (`data-loader.ts`) - Parses JSONL files from multiple Claude data directories, including pre-calculated costs 2. **Token Aggregation** (`calculate-cost.ts`) - Utility functions for aggregating token counts and costs 3. **Command Execution** (`commands/`) - CLI subcommands that orchestrate data loading and presentation 4. **CLI Entry** (`index.ts`) - Gunshi-based CLI setup with subcommand routing **Output Formats:** - Table format (default): Pretty-printed tables with colors for terminal display - JSON format (`--json`): Structured JSON output for programmatic consumption **Key Data Structures:** - Raw usage data is parsed from JSONL with timestamp, token counts, and pre-calculated costs - Data is aggregated into daily summaries, monthly summaries, session summaries, or 5-hour billing blocks - **Important Note on Naming**: The term "session" in this codebase has two different meanings: 1. **Session Reports** (`pnpm run start session`): Groups usage by project directories. What we call "sessionId" in these reports is actually derived from the directory structure (project/directory) 2. **True Session ID**: The actual Claude Code session ID found in the `sessionId` field within JSONL entries and used as the filename ({sessionId}.jsonl) - File structure: `projects/{project}/{sessionId}.jsonl` where: - `{project}` is the project directory name (used for grouping) - `{sessionId}.jsonl` is the JSONL file named with the actual session ID from Claude Code - Each JSONL file contains all usage entries for a single Claude Code session - The sessionId in the filename matches the `sessionId` field inside the JSONL entries - 5-hour blocks group usage data by Claude's billing cycles with active block tracking **External Dependencies:** - Uses local timezone for date formatting - CLI built with `gunshi` framework, tables with `cli-table3` - **LiteLLM Integration**: Cost calculations depend on LiteLLM's pricing database for model pricing data **MCP Integration:** - **Built-in MCP Server**: Exposes usage data through MCP protocol with tools: - `daily` - Daily usage reports - `session` - Session-based usage reports - `monthly` - Monthly usage reports - `blocks` - 5-hour billing blocks usage reports - **External MCP Servers Available:** - **ESLint MCP**: Lint TypeScript/JavaScript files directly through Claude Code tools - **Context7 MCP**: Look up documentation for libraries and frameworks - **Claude Code Skills Available:** - **use-gunshi-cli**: Guide for using gunshi CLI framework (via @gunshi/docs) - **byethrow**: Guide for using @praha/byethrow Result type (via @praha/byethrow-docs) ## Git Commit and PR Conventions **Commit Message Format:** Follow the Conventional Commits specification with package/area prefixes: ``` (): ``` **Scope Naming Rules:** - **Apps**: Use the app directory name - `feat(ccusage):` - Changes to apps/ccusage - `fix(mcp):` - Fixes in apps/mcp - `feat(codex):` - Features for apps/codex (if exists) - **Packages**: Use the package directory name - `feat(terminal):` - Changes to packages/terminal - `fix(ui):` - Fixes in packages/ui - `refactor(core):` - Refactoring packages/core - **Documentation**: Use `docs` scope - `docs:` or `docs(guide):` - Documentation updates - `docs(api):` - API documentation changes - **Root-level changes**: No scope (preferred) or use `root` - `chore:` - Root config updates - `ci:` - CI/CD changes - `feat:` - Root-level features - `docs:` - Root documentation updates - `build:` or `build(root):` - Root build system changes **Type Prefixes:** - `feat:` - New feature - `fix:` - Bug fix - `docs:` - Documentation only changes - `style:` - Code style changes (formatting, missing semi-colons, etc) - `refactor:` - Code change that neither fixes a bug nor adds a feature - `perf:` - Performance improvements - `test:` - Adding missing tests or correcting existing tests - `chore:` - Changes to the build process or auxiliary tools - `ci:` - CI/CD configuration changes - `revert:` - Reverting a previous commit **Examples:** ``` feat(ccusage): add support for Claude 4.1 models fix(mcp): resolve connection timeout issues docs(guide): update installation instructions refactor(ccusage): extract cost calculation to separate module test(mcp): add integration tests for HTTP transport chore: update dependencies ``` **PR Title Convention:** PR titles should follow the same format as commit messages. When a PR contains multiple commits, the title should describe the main change: ``` feat(ccusage): implement session-based usage reports fix(mcp): handle edge cases in data aggregation docs: comprehensive API documentation update ``` ## Code Style Notes - Uses ESLint for linting and formatting with tab indentation and double quotes - TypeScript with strict mode and bundler module resolution - No console.log allowed except where explicitly disabled with eslint-disable - Error handling: silently skips malformed JSONL lines during parsing - File paths always use Node.js path utilities for cross-platform compatibility - **Import conventions**: Use `.ts` extensions for local file imports (e.g., `import { foo } from './utils.ts'`) **Error Handling:** - **Prefer @praha/byethrow Result type** over traditional try-catch for functional error handling - Documentation: Available via byethrow skill (use `/byethrow` or check `.claude/skills/byethrow/`) - Use `Result.try()` for wrapping operations that may throw (JSON parsing, etc.) - Use `Result.isFailure()` for checking errors (more readable than `!Result.isSuccess()`) - Use early return pattern (`if (Result.isFailure(result)) continue;`) instead of ternary operators - For async operations: create wrapper function with `Result.try()` then call it - Keep traditional try-catch only for: file I/O with complex error handling, legacy code that's hard to refactor - Always use `Result.isFailure()` and `Result.isSuccess()` type guards for better code clarity **Naming Conventions:** - Variables: start with lowercase (camelCase) - e.g., `usageDataSchema`, `modelBreakdownSchema` - Types: start with uppercase (PascalCase) - e.g., `UsageData`, `ModelBreakdown` - Constants: can use UPPER_SNAKE_CASE - e.g., `DEFAULT_CLAUDE_CODE_PATH` - Internal files: use underscore prefix - e.g., `_types.ts`, `_utils.ts`, `_consts.ts` **Export Rules:** - **IMPORTANT**: Only export constants, functions, and types that are actually used by other modules - Internal/private constants that are only used within the same file should NOT be exported - Always check if a constant is used elsewhere before making it `export const` vs just `const` - This follows the principle of minimizing the public API surface area - Dependencies should always be added as `devDependencies` unless explicitly requested otherwise **Post-Code Change Workflow:** After making any code changes, ALWAYS run these commands in parallel: - `pnpm run format` - Auto-fix and format code with ESLint (includes linting) - `pnpm typecheck` - Type check with TypeScript - `pnpm run test` - Run all tests This ensures code quality and catches issues immediately after changes. ## Documentation Guidelines **Screenshot Usage:** - **Placement**: Always place screenshots immediately after the main heading (H1) in documentation pages - **Purpose**: Provide immediate visual context to users before textual explanations - **Guides with Screenshots**: - `/docs/guide/index.md` (What is ccusage) - Main usage screenshot - `/docs/guide/daily-reports.md` - Daily report output screenshot - `/docs/guide/live-monitoring.md` - Live monitoring dashboard screenshot - `/docs/guide/mcp-server.md` - Claude Desktop integration screenshot - **Image Path**: Use relative paths like `/screenshot.png` for images stored in `/docs/public/` - **Alt Text**: Always include descriptive alt text for accessibility ## Claude Models and Testing **Supported Claude 4 Models (as of 2025):** - `claude-sonnet-4-20250514` - Latest Claude 4 Sonnet model - `claude-opus-4-20250514` - Latest Claude 4 Opus model **Model Naming Convention:** - Pattern: `claude-{model-type}-{generation}-{date}` - Example: `claude-sonnet-4-20250514` (NOT `claude-4-sonnet-20250514`) - The generation number comes AFTER the model type **Testing Guidelines:** - **In-Source Testing Pattern**: This project uses in-source testing with `if (import.meta.vitest != null)` blocks - Tests are written directly in the same files as the source code, not in separate test files - Vitest globals (`describe`, `it`, `expect`) are available automatically without imports - **IMPORTANT**: DO NOT use `await import()` dynamic imports anywhere in the codebase - this causes tree-shaking issues and should be avoided entirely - **ESPECIALLY**: Never use dynamic imports in vitest test blocks - this is particularly problematic for test execution - **Vitest globals are enabled**: Use `describe`, `it`, `expect` directly without any imports since globals are configured - Mock data is created using `fs-fixture` with `createFixture()` for Claude data directory simulation - All test files must use current Claude 4 models, not outdated Claude 3 models - Test coverage should include both Sonnet and Opus models for comprehensive validation - Model names in tests must exactly match LiteLLM's pricing database entries - When adding new model tests, verify the model exists in LiteLLM before implementation - Tests depend on real pricing data from LiteLLM - failures may indicate model availability issues **LiteLLM Integration Notes:** - Cost calculations require exact model name matches with LiteLLM's database - Test failures often indicate model names don't exist in LiteLLM's pricing data - Future model updates require checking LiteLLM compatibility first - The application cannot calculate costs for models not supported by LiteLLM # Tips for Claude Code - Context7 MCP server available for library documentation lookup - use-gunshi-cli skill available for gunshi CLI framework documentation - byethrow skill available for @praha/byethrow Result type documentation - do not use console.log. use logger.ts instead - **CRITICAL VITEST REMINDER**: Vitest globals are enabled - use `describe`, `it`, `expect` directly WITHOUT imports. NEVER use `await import()` dynamic imports anywhere, especially in test blocks. # important-instruction-reminders Do what has been asked; nothing more, nothing less. NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User. Dependencies should always be added as devDependencies unless explicitly requested otherwise. ================================================ FILE: apps/amp/CLAUDE.md ================================================ # Amp CLI Notes ## Log Sources - Amp session usage is recorded under `${AMP_DATA_DIR:-~/.local/share/amp}/threads/` (the CLI resolves `AMP_DATA_DIR` and falls back to `~/.local/share/amp`). - Each thread is stored as a JSON file (not JSONL) named `T-{uuid}.json`. - Token usage is extracted from the `usageLedger.events[]` array in each thread file. - Cache token information (creation/read) is extracted from `messages[].usage` for detailed breakdown. ## Token Fields - `inputTokens`: total input tokens sent to the model. - `outputTokens`: output tokens (completion text). - `cacheCreationInputTokens`: tokens used for cache creation (from message usage). - `cacheReadInputTokens`: tokens read from cache (from message usage). - `totalTokens`: sum of input and output tokens. ## Credits - Amp uses a credits-based billing system in addition to standard token counts. - Each usage event includes a `credits` field representing the billing cost in Amp's credit system. - Credits are displayed alongside USD cost estimates in reports. ## Cost Calculation - Pricing is pulled from LiteLLM's public JSON (`model_prices_and_context_window.json`). - Amp primarily uses Anthropic Claude models (Haiku, Sonnet, Opus variants). - Cost formula per model: - Input: `inputTokens / 1_000_000 * input_cost_per_mtoken` - Cached input read: `cacheReadInputTokens / 1_000_000 * cached_input_cost_per_mtoken` - Cache creation: `cacheCreationInputTokens / 1_000_000 * cache_creation_cost_per_mtoken` - Output: `outputTokens / 1_000_000 * output_cost_per_mtoken` ## CLI Usage - Treat Amp as a sibling to `apps/ccusage`, `apps/codex`, and `apps/opencode`. - Reuse shared packages (`@ccusage/terminal`, `@ccusage/internal`) wherever possible. - Amp is packaged as a bundled CLI. Keep every runtime dependency in `devDependencies`. - Entry point uses Gunshi framework with subcommands: `daily`, `monthly`, `session`. - Data discovery relies on `AMP_DATA_DIR` environment variable. - Default path: `~/.local/share/amp`. ## Available Commands - `ccusage-amp daily` - Show daily usage report - `ccusage-amp monthly` - Show monthly usage report - `ccusage-amp session` - Show usage by thread (session) - Add `--json` flag for JSON output format - Add `--compact` flag for compact table mode ## Testing Notes - Tests rely on `fs-fixture` with `using` to ensure cleanup. - All vitest blocks live alongside implementation files via `if (import.meta.vitest != null)`. - Vitest globals are enabled - use `describe`, `it`, `expect` directly without imports. - **CRITICAL**: NEVER use `await import()` dynamic imports anywhere, especially in test blocks. ## Data Structure Amp thread files have the following structure: ```json { "id": "T-{uuid}", "created": 1700000000000, "title": "Thread Title", "messages": [ { "role": "assistant", "messageId": 1, "usage": { "model": "claude-haiku-4-5-20251001", "inputTokens": 100, "outputTokens": 50, "cacheCreationInputTokens": 500, "cacheReadInputTokens": 200, "credits": 1.5 } } ], "usageLedger": { "events": [ { "id": "event-uuid", "timestamp": "2025-11-23T10:00:00.000Z", "model": "claude-haiku-4-5-20251001", "credits": 1.5, "tokens": { "input": 100, "output": 50 }, "operationType": "inference", "fromMessageId": 0, "toMessageId": 1 } ] } } ``` ## Environment Variables - `AMP_DATA_DIR` - Custom Amp data directory path (defaults to `~/.local/share/amp`) - `LOG_LEVEL` - Control logging verbosity (0=silent, 1=warn, 2=log, 3=info, 4=debug, 5=trace) ================================================ FILE: apps/amp/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; /** @type {import('eslint').Linter.FlatConfig[]} */ const config = ryoppippi( { type: 'app', stylistic: false, }, { rules: { 'test/no-importing-vitest-globals': 'error', }, }, ); export default config; ================================================ FILE: apps/amp/package.json ================================================ { "name": "@ccusage/amp", "type": "module", "version": "18.0.10", "description": "Usage analysis tool for Amp CLI sessions", "author": "ryoppippi", "license": "MIT", "funding": "https://github.com/ryoppippi/ccusage?sponsor=1", "homepage": "https://github.com/ryoppippi/ccusage#readme", "repository": { "type": "git", "url": "git+https://github.com/ryoppippi/ccusage.git", "directory": "apps/amp" }, "bugs": { "url": "https://github.com/ryoppippi/ccusage/issues" }, "main": "./dist/index.js", "module": "./dist/index.js", "bin": { "ccusage-amp": "./src/index.ts" }, "files": [ "dist" ], "publishConfig": { "bin": { "ccusage-amp": "./dist/index.js" } }, "engines": { "node": ">=20.19.4" }, "scripts": { "build": "tsdown", "format": "pnpm run lint --fix", "lint": "eslint --cache .", "prepack": "pnpm run build && clean-pkg-json", "prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build", "start": "bun ./src/index.ts", "test": "TZ=UTC vitest", "typecheck": "tsgo --noEmit" }, "devDependencies": { "@ccusage/internal": "workspace:*", "@ccusage/terminal": "workspace:*", "@praha/byethrow": "catalog:runtime", "@ryoppippi/eslint-config": "catalog:lint", "@typescript/native-preview": "catalog:types", "clean-pkg-json": "catalog:release", "eslint": "catalog:lint", "fast-sort": "catalog:runtime", "fs-fixture": "catalog:testing", "gunshi": "catalog:runtime", "path-type": "catalog:runtime", "picocolors": "catalog:runtime", "tinyglobby": "catalog:runtime", "tsdown": "catalog:build", "unplugin-macros": "catalog:build", "unplugin-unused": "catalog:build", "valibot": "catalog:runtime", "vitest": "catalog:testing" } } ================================================ FILE: apps/amp/src/_consts.ts ================================================ import { homedir } from 'node:os'; import path from 'node:path'; /** * Environment variable name for custom Amp data directory */ export const AMP_DATA_DIR_ENV = 'AMP_DATA_DIR'; /** * Default Amp data directory path (~/.local/share/amp) */ const DEFAULT_AMP_PATH = '.local/share/amp'; /** * User home directory */ const USER_HOME_DIR = homedir(); /** * Default Amp data directory (absolute path) */ export const DEFAULT_AMP_DIR = path.join(USER_HOME_DIR, DEFAULT_AMP_PATH); /** * Amp threads subdirectory name */ export const AMP_THREADS_DIR_NAME = 'threads'; /** * Glob pattern for Amp thread files */ export const AMP_THREAD_GLOB = '**/*.json'; /** * Million constant for pricing calculations */ export const MILLION = 1_000_000; ================================================ FILE: apps/amp/src/_macro.ts ================================================ import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; import { createPricingDataset, fetchLiteLLMPricingDataset, filterPricingDataset, } from '@ccusage/internal/pricing-fetch-utils'; const AMP_MODEL_PREFIXES = ['claude-', 'anthropic/']; function isAmpModel(modelName: string, _pricing: LiteLLMModelPricing): boolean { return AMP_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix)); } export async function prefetchAmpPricing(): Promise> { try { const dataset = await fetchLiteLLMPricingDataset(); return filterPricingDataset(dataset, isAmpModel); } catch (error) { console.warn('Failed to prefetch Amp pricing data, proceeding with empty cache.', error); return createPricingDataset(); } } ================================================ FILE: apps/amp/src/_types.ts ================================================ /** * Token usage delta for a single event */ export type TokenUsageDelta = { inputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; outputTokens: number; totalTokens: number; }; /** * Token usage event loaded from Amp thread files */ export type TokenUsageEvent = TokenUsageDelta & { timestamp: string; threadId: string; model: string; credits: number; operationType: string; }; /** * Model usage summary with token counts */ export type ModelUsage = TokenUsageDelta & { credits: number; }; /** * Daily usage summary */ export type DailyUsageSummary = { date: string; firstTimestamp: string; costUSD: number; credits: number; models: Map; } & TokenUsageDelta; /** * Monthly usage summary */ export type MonthlyUsageSummary = { month: string; firstTimestamp: string; costUSD: number; credits: number; models: Map; } & TokenUsageDelta; /** * Session (thread) usage summary */ export type SessionUsageSummary = { threadId: string; title: string; firstTimestamp: string; lastTimestamp: string; costUSD: number; credits: number; models: Map; } & TokenUsageDelta; /** * Model pricing information */ export type ModelPricing = { inputCostPerMToken: number; cachedInputCostPerMToken: number; cacheCreationCostPerMToken: number; outputCostPerMToken: number; }; /** * Pricing source interface */ export type PricingSource = { getPricing: (model: string) => Promise; }; /** * Daily report row for JSON output */ export type DailyReportRow = { date: string; inputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; outputTokens: number; totalTokens: number; costUSD: number; credits: number; models: Record; }; /** * Monthly report row for JSON output */ export type MonthlyReportRow = { month: string; inputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; outputTokens: number; totalTokens: number; costUSD: number; credits: number; models: Record; }; /** * Session report row for JSON output */ export type SessionReportRow = { threadId: string; title: string; lastActivity: string; inputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; outputTokens: number; totalTokens: number; costUSD: number; credits: number; models: Record; }; ================================================ FILE: apps/amp/src/commands/daily.ts ================================================ import type { TokenUsageEvent } from '../_types.ts'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; import { loadAmpUsageEvents } from '../data-loader.ts'; import { AmpPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 9; function groupByDate(events: TokenUsageEvent[]): Map { const grouped = new Map(); for (const event of events) { const date = event.timestamp.split('T')[0]!; const existing = grouped.get(date); if (existing != null) { existing.push(event); } else { grouped.set(date, [event]); } } return grouped; } export const dailyCommand = define({ name: 'daily', description: 'Show Amp token usage grouped by day', args: { json: { type: 'boolean', short: 'j', description: 'Output in JSON format', }, compact: { type: 'boolean', description: 'Force compact table mode', }, }, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); const { events } = await loadAmpUsageEvents(); if (events.length === 0) { const output = jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No Amp usage data found.'; // eslint-disable-next-line no-console console.log(output); return; } using pricingSource = new AmpPricingSource({ offline: false }); const eventsByDate = groupByDate(events); const dailyData: Array<{ date: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; credits: number; totalCost: number; modelsUsed: string[]; }> = []; for (const [date, dayEvents] of eventsByDate) { let inputTokens = 0; let outputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; let credits = 0; let totalCost = 0; const modelsSet = new Set(); for (const event of dayEvents) { inputTokens += event.inputTokens; outputTokens += event.outputTokens; cacheCreationTokens += event.cacheCreationInputTokens; cacheReadTokens += event.cacheReadInputTokens; credits += event.credits; const cost = await pricingSource.calculateCost(event.model, { inputTokens: event.inputTokens, outputTokens: event.outputTokens, cacheCreationInputTokens: event.cacheCreationInputTokens, cacheReadInputTokens: event.cacheReadInputTokens, }); totalCost += cost; modelsSet.add(event.model); } const totalTokens = inputTokens + outputTokens; dailyData.push({ date, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, credits, totalCost, modelsUsed: Array.from(modelsSet), }); } dailyData.sort((a, b) => a.date.localeCompare(b.date)); const totals = { inputTokens: dailyData.reduce((sum, d) => sum + d.inputTokens, 0), outputTokens: dailyData.reduce((sum, d) => sum + d.outputTokens, 0), cacheCreationTokens: dailyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0), cacheReadTokens: dailyData.reduce((sum, d) => sum + d.cacheReadTokens, 0), totalTokens: dailyData.reduce((sum, d) => sum + d.totalTokens, 0), credits: dailyData.reduce((sum, d) => sum + d.credits, 0), totalCost: dailyData.reduce((sum, d) => sum + d.totalCost, 0), }; if (jsonOutput) { // eslint-disable-next-line no-console console.log( JSON.stringify( { daily: dailyData, totals, }, null, 2, ), ); return; } // eslint-disable-next-line no-console console.log('\n📊 Amp Token Usage Report - Daily\n'); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Date', 'Models', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Credits', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Date', 'Models', 'Input', 'Output', 'Credits', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: Boolean(ctx.values.compact), style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of dailyData) { table.push([ data.date, formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), formatNumber(data.cacheCreationTokens), formatNumber(data.cacheReadTokens), formatNumber(data.totalTokens), data.credits.toFixed(2), formatCurrency(data.totalCost), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(totals.credits.toFixed(2)), pc.yellow(formatCurrency(totals.totalCost)), ]); // eslint-disable-next-line no-console console.log(table.toString()); if (table.isCompactMode()) { // eslint-disable-next-line no-console console.log('\nRunning in Compact Mode'); // eslint-disable-next-line no-console console.log('Expand terminal width to see cache metrics and total tokens'); } }, }); ================================================ FILE: apps/amp/src/commands/index.ts ================================================ export { dailyCommand } from './daily.ts'; export { monthlyCommand } from './monthly.ts'; export { sessionCommand } from './session.ts'; ================================================ FILE: apps/amp/src/commands/monthly.ts ================================================ import type { TokenUsageEvent } from '../_types.ts'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; import { loadAmpUsageEvents } from '../data-loader.ts'; import { AmpPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 9; function groupByMonth(events: TokenUsageEvent[]): Map { const grouped = new Map(); for (const event of events) { const month = event.timestamp.slice(0, 7); // YYYY-MM const existing = grouped.get(month); if (existing != null) { existing.push(event); } else { grouped.set(month, [event]); } } return grouped; } export const monthlyCommand = define({ name: 'monthly', description: 'Show Amp token usage grouped by month', args: { json: { type: 'boolean', short: 'j', description: 'Output in JSON format', }, compact: { type: 'boolean', description: 'Force compact table mode', }, }, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); const { events } = await loadAmpUsageEvents(); if (events.length === 0) { const output = jsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No Amp usage data found.'; // eslint-disable-next-line no-console console.log(output); return; } using pricingSource = new AmpPricingSource({ offline: false }); const eventsByMonth = groupByMonth(events); const monthlyData: Array<{ month: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; credits: number; totalCost: number; modelsUsed: string[]; }> = []; for (const [month, monthEvents] of eventsByMonth) { let inputTokens = 0; let outputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; let credits = 0; let totalCost = 0; const modelsSet = new Set(); for (const event of monthEvents) { inputTokens += event.inputTokens; outputTokens += event.outputTokens; cacheCreationTokens += event.cacheCreationInputTokens; cacheReadTokens += event.cacheReadInputTokens; credits += event.credits; const cost = await pricingSource.calculateCost(event.model, { inputTokens: event.inputTokens, outputTokens: event.outputTokens, cacheCreationInputTokens: event.cacheCreationInputTokens, cacheReadInputTokens: event.cacheReadInputTokens, }); totalCost += cost; modelsSet.add(event.model); } const totalTokens = inputTokens + outputTokens; monthlyData.push({ month, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, credits, totalCost, modelsUsed: Array.from(modelsSet), }); } monthlyData.sort((a, b) => a.month.localeCompare(b.month)); const totals = { inputTokens: monthlyData.reduce((sum, d) => sum + d.inputTokens, 0), outputTokens: monthlyData.reduce((sum, d) => sum + d.outputTokens, 0), cacheCreationTokens: monthlyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0), cacheReadTokens: monthlyData.reduce((sum, d) => sum + d.cacheReadTokens, 0), totalTokens: monthlyData.reduce((sum, d) => sum + d.totalTokens, 0), credits: monthlyData.reduce((sum, d) => sum + d.credits, 0), totalCost: monthlyData.reduce((sum, d) => sum + d.totalCost, 0), }; if (jsonOutput) { // eslint-disable-next-line no-console console.log( JSON.stringify( { monthly: monthlyData, totals, }, null, 2, ), ); return; } // eslint-disable-next-line no-console console.log('\n📊 Amp Token Usage Report - Monthly\n'); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Month', 'Models', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Credits', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Month', 'Models', 'Input', 'Output', 'Credits', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: Boolean(ctx.values.compact), style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of monthlyData) { table.push([ data.month, formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), formatNumber(data.cacheCreationTokens), formatNumber(data.cacheReadTokens), formatNumber(data.totalTokens), data.credits.toFixed(2), formatCurrency(data.totalCost), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(totals.credits.toFixed(2)), pc.yellow(formatCurrency(totals.totalCost)), ]); // eslint-disable-next-line no-console console.log(table.toString()); if (table.isCompactMode()) { // eslint-disable-next-line no-console console.log('\nRunning in Compact Mode'); // eslint-disable-next-line no-console console.log('Expand terminal width to see cache metrics and total tokens'); } }, }); ================================================ FILE: apps/amp/src/commands/session.ts ================================================ import type { TokenUsageEvent } from '../_types.ts'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; import { loadAmpUsageEvents } from '../data-loader.ts'; import { AmpPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 9; function groupByThread(events: TokenUsageEvent[]): Map { const grouped = new Map(); for (const event of events) { const existing = grouped.get(event.threadId); if (existing != null) { existing.push(event); } else { grouped.set(event.threadId, [event]); } } return grouped; } export const sessionCommand = define({ name: 'session', description: 'Show Amp token usage grouped by thread (session)', args: { json: { type: 'boolean', short: 'j', description: 'Output in JSON format', }, compact: { type: 'boolean', description: 'Force compact table mode', }, }, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); const { events, threads } = await loadAmpUsageEvents(); if (events.length === 0) { const output = jsonOutput ? JSON.stringify({ sessions: [], totals: null }) : 'No Amp usage data found.'; // eslint-disable-next-line no-console console.log(output); return; } using pricingSource = new AmpPricingSource({ offline: false }); const eventsByThread = groupByThread(events); const sessionData: Array<{ threadId: string; title: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; credits: number; totalCost: number; modelsUsed: string[]; lastActivity: string; }> = []; for (const [threadId, threadEvents] of eventsByThread) { let inputTokens = 0; let outputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; let credits = 0; let totalCost = 0; const modelsSet = new Set(); let lastActivity = threadEvents[0]!.timestamp; for (const event of threadEvents) { inputTokens += event.inputTokens; outputTokens += event.outputTokens; cacheCreationTokens += event.cacheCreationInputTokens; cacheReadTokens += event.cacheReadInputTokens; credits += event.credits; const cost = await pricingSource.calculateCost(event.model, { inputTokens: event.inputTokens, outputTokens: event.outputTokens, cacheCreationInputTokens: event.cacheCreationInputTokens, cacheReadInputTokens: event.cacheReadInputTokens, }); totalCost += cost; modelsSet.add(event.model); if (event.timestamp > lastActivity) { lastActivity = event.timestamp; } } const totalTokens = inputTokens + outputTokens; const threadInfo = threads.get(threadId); sessionData.push({ threadId, title: threadInfo?.title ?? 'Untitled', inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, credits, totalCost, modelsUsed: Array.from(modelsSet), lastActivity, }); } sessionData.sort((a, b) => a.lastActivity.localeCompare(b.lastActivity)); const totals = { inputTokens: sessionData.reduce((sum, s) => sum + s.inputTokens, 0), outputTokens: sessionData.reduce((sum, s) => sum + s.outputTokens, 0), cacheCreationTokens: sessionData.reduce((sum, s) => sum + s.cacheCreationTokens, 0), cacheReadTokens: sessionData.reduce((sum, s) => sum + s.cacheReadTokens, 0), totalTokens: sessionData.reduce((sum, s) => sum + s.totalTokens, 0), credits: sessionData.reduce((sum, s) => sum + s.credits, 0), totalCost: sessionData.reduce((sum, s) => sum + s.totalCost, 0), }; if (jsonOutput) { // eslint-disable-next-line no-console console.log( JSON.stringify( { sessions: sessionData, totals, }, null, 2, ), ); return; } // eslint-disable-next-line no-console console.log('\n📊 Amp Token Usage Report - Sessions (Threads)\n'); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Thread', 'Models', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Credits', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Thread', 'Models', 'Input', 'Output', 'Credits', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: Boolean(ctx.values.compact), style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of sessionData) { // Truncate title for display const displayTitle = data.title.length > 30 ? `${data.title.slice(0, 27)}...` : data.title; table.push([ displayTitle, formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), formatNumber(data.cacheCreationTokens), formatNumber(data.cacheReadTokens), formatNumber(data.totalTokens), data.credits.toFixed(2), formatCurrency(data.totalCost), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(totals.credits.toFixed(2)), pc.yellow(formatCurrency(totals.totalCost)), ]); // eslint-disable-next-line no-console console.log(table.toString()); if (table.isCompactMode()) { // eslint-disable-next-line no-console console.log('\nRunning in Compact Mode'); // eslint-disable-next-line no-console console.log('Expand terminal width to see cache metrics and total tokens'); } }, }); ================================================ FILE: apps/amp/src/data-loader.ts ================================================ /** * @fileoverview Data loading utilities for Amp CLI usage analysis * * This module provides functions for loading and parsing Amp usage data * from JSON thread files stored in Amp data directories. * Amp stores usage data in ~/.local/share/amp/threads/ * * @module data-loader */ import type { TokenUsageEvent } from './_types.ts'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { Result } from '@praha/byethrow'; import { createFixture } from 'fs-fixture'; import { isDirectorySync } from 'path-type'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; import { AMP_DATA_DIR_ENV, AMP_THREAD_GLOB, AMP_THREADS_DIR_NAME, DEFAULT_AMP_DIR, } from './_consts.ts'; import { logger } from './logger.ts'; /** * Amp usageLedger event schema */ const usageLedgerEventSchema = v.object({ id: v.string(), timestamp: v.string(), model: v.string(), credits: v.number(), tokens: v.object({ input: v.optional(v.number()), output: v.optional(v.number()), }), operationType: v.optional(v.string()), fromMessageId: v.optional(v.number()), toMessageId: v.optional(v.number()), }); /** * Amp message usage schema (for cache tokens) */ const messageUsageSchema = v.object({ model: v.optional(v.string()), inputTokens: v.optional(v.number()), outputTokens: v.optional(v.number()), cacheCreationInputTokens: v.optional(v.number()), cacheReadInputTokens: v.optional(v.number()), totalInputTokens: v.optional(v.number()), credits: v.optional(v.number()), }); /** * Amp message schema */ const messageSchema = v.object({ role: v.string(), messageId: v.number(), usage: v.optional(messageUsageSchema), }); /** * Amp thread file schema */ const threadSchema = v.object({ id: v.string(), created: v.optional(v.number()), title: v.optional(v.string()), messages: v.optional(v.array(messageSchema)), usageLedger: v.optional( v.object({ events: v.optional(v.array(usageLedgerEventSchema)), }), ), }); type ParsedThread = v.InferOutput; type ParsedUsageLedgerEvent = v.InferOutput; type ParsedMessage = v.InferOutput; /** * Get Amp data directory * @returns Path to Amp data directory, or null if not found */ export function getAmpPath(): string | null { // Check environment variable first const envPath = process.env[AMP_DATA_DIR_ENV]; if (envPath != null && envPath.trim() !== '') { const normalizedPath = path.resolve(envPath); if (isDirectorySync(normalizedPath)) { return normalizedPath; } } // Use default path if (isDirectorySync(DEFAULT_AMP_DIR)) { return DEFAULT_AMP_DIR; } return null; } /** * Find cache token information from messages for a specific messageId range */ function findCacheTokensForEvent( messages: ParsedMessage[] | undefined, fromMessageId: number | undefined, toMessageId: number | undefined, ): { cacheCreationInputTokens: number; cacheReadInputTokens: number } { if (messages == null || toMessageId == null) { return { cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; } // Find the assistant message that corresponds to this event const message = messages.find((m) => m.role === 'assistant' && m.messageId === toMessageId); if (message?.usage == null) { return { cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; } return { cacheCreationInputTokens: message.usage.cacheCreationInputTokens ?? 0, cacheReadInputTokens: message.usage.cacheReadInputTokens ?? 0, }; } /** * Convert usageLedger event to TokenUsageEvent */ function convertLedgerEventToUsageEvent( threadId: string, event: ParsedUsageLedgerEvent, messages: ParsedMessage[] | undefined, ): TokenUsageEvent { const inputTokens = event.tokens.input ?? 0; const outputTokens = event.tokens.output ?? 0; const { cacheCreationInputTokens, cacheReadInputTokens } = findCacheTokensForEvent( messages, event.fromMessageId, event.toMessageId, ); return { timestamp: event.timestamp, threadId, model: event.model, credits: event.credits, operationType: event.operationType ?? 'unknown', inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens, totalTokens: inputTokens + outputTokens, }; } /** * Load and parse a single Amp thread file */ async function loadThreadFile(filePath: string): Promise { const readResult = await Result.try({ try: readFile(filePath, 'utf-8'), catch: (error) => error, }); if (Result.isFailure(readResult)) { logger.debug('Failed to read Amp thread file', { filePath, error: readResult.error }); return null; } const parseResult = Result.try({ try: () => JSON.parse(readResult.value) as unknown, catch: (error) => error, })(); if (Result.isFailure(parseResult)) { logger.debug('Failed to parse Amp thread JSON', { filePath, error: parseResult.error }); return null; } const validationResult = v.safeParse(threadSchema, parseResult.value); if (!validationResult.success) { logger.debug('Failed to validate Amp thread schema', { filePath, issues: validationResult.issues, }); return null; } return validationResult.output; } export type LoadOptions = { threadDirs?: string[]; }; export type LoadResult = { events: TokenUsageEvent[]; threads: Map; missingDirectories: string[]; }; /** * Load all Amp usage events from thread files */ export async function loadAmpUsageEvents(options: LoadOptions = {}): Promise { const ampPath = getAmpPath(); const providedDirs = options.threadDirs != null && options.threadDirs.length > 0 ? options.threadDirs.map((dir) => path.resolve(dir)) : undefined; const defaultThreadsDir = ampPath != null ? path.join(ampPath, AMP_THREADS_DIR_NAME) : null; const threadDirs = providedDirs ?? (defaultThreadsDir != null ? [defaultThreadsDir] : []); const events: TokenUsageEvent[] = []; const threads = new Map(); const missingDirectories: string[] = []; for (const dir of threadDirs) { if (!isDirectorySync(dir)) { missingDirectories.push(dir); continue; } const files = await glob(AMP_THREAD_GLOB, { cwd: dir, absolute: true, }); for (const file of files) { const thread = await loadThreadFile(file); if (thread == null) { continue; } const threadId = thread.id; threads.set(threadId, { title: thread.title ?? 'Untitled', created: thread.created, }); const ledgerEvents = thread.usageLedger?.events ?? []; for (const ledgerEvent of ledgerEvents) { const event = convertLedgerEventToUsageEvent(threadId, ledgerEvent, thread.messages); events.push(event); } } } // Sort events by timestamp events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); return { events, threads, missingDirectories }; } if (import.meta.vitest != null) { describe('loadAmpUsageEvents', () => { it('parses Amp thread files and extracts usage events', async () => { const threadData = { v: 195, id: 'T-test-thread-123', created: 1700000000000, title: 'Test Thread', messages: [ { role: 'user', messageId: 0, content: [{ type: 'text', text: 'hi' }], }, { role: 'assistant', messageId: 1, content: [{ type: 'text', text: 'Hello!' }], usage: { model: 'claude-haiku-4-5-20251001', inputTokens: 100, outputTokens: 50, cacheCreationInputTokens: 500, cacheReadInputTokens: 200, totalInputTokens: 800, credits: 1.5, }, }, ], usageLedger: { events: [ { id: 'event-1', timestamp: '2025-11-23T10:00:00.000Z', model: 'claude-haiku-4-5-20251001', credits: 1.5, tokens: { input: 100, output: 50, }, operationType: 'inference', fromMessageId: 0, toMessageId: 1, }, ], }, }; await using fixture = await createFixture({ threads: { 'T-test-thread-123.json': JSON.stringify(threadData), }, }); const { events, threads, missingDirectories } = await loadAmpUsageEvents({ threadDirs: [fixture.getPath('threads')], }); expect(missingDirectories).toEqual([]); expect(events).toHaveLength(1); const event = events[0]!; expect(event.threadId).toBe('T-test-thread-123'); expect(event.model).toBe('claude-haiku-4-5-20251001'); expect(event.inputTokens).toBe(100); expect(event.outputTokens).toBe(50); expect(event.cacheCreationInputTokens).toBe(500); expect(event.cacheReadInputTokens).toBe(200); expect(event.credits).toBe(1.5); expect(threads.get('T-test-thread-123')).toEqual({ title: 'Test Thread', created: 1700000000000, }); }); it('handles missing directories gracefully', async () => { const { events, missingDirectories } = await loadAmpUsageEvents({ threadDirs: ['/nonexistent/path'], }); expect(events).toEqual([]); expect(missingDirectories).toContain(path.resolve('/nonexistent/path')); }); it('handles malformed JSON gracefully', async () => { await using fixture = await createFixture({ threads: { 'invalid.json': 'not valid json', }, }); const { events } = await loadAmpUsageEvents({ threadDirs: [fixture.getPath('threads')], }); expect(events).toEqual([]); }); }); } ================================================ FILE: apps/amp/src/index.ts ================================================ #!/usr/bin/env node import { run } from './run.ts'; // eslint-disable-next-line antfu/no-top-level-await await run(); ================================================ FILE: apps/amp/src/logger.ts ================================================ import { createLogger } from '@ccusage/internal/logger'; export const logger = createLogger('@ccusage/amp'); ================================================ FILE: apps/amp/src/pricing.ts ================================================ import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; import type { ModelPricing, PricingSource } from './_types.ts'; import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import { Result } from '@praha/byethrow'; import { MILLION } from './_consts.ts'; import { prefetchAmpPricing } from './_macro.ts' with { type: 'macro' }; import { logger } from './logger.ts'; const AMP_PROVIDER_PREFIXES = ['anthropic/']; const ZERO_MODEL_PRICING = { inputCostPerMToken: 0, cachedInputCostPerMToken: 0, cacheCreationCostPerMToken: 0, outputCostPerMToken: 0, } as const satisfies ModelPricing; function toPerMillion(value: number | undefined, fallback?: number): number { const perToken = value ?? fallback ?? 0; return perToken * MILLION; } export type AmpPricingSourceOptions = { offline?: boolean; offlineLoader?: () => Promise>; }; const PREFETCHED_AMP_PRICING = prefetchAmpPricing(); export class AmpPricingSource implements PricingSource, Disposable { private readonly fetcher: LiteLLMPricingFetcher; constructor(options: AmpPricingSourceOptions = {}) { this.fetcher = new LiteLLMPricingFetcher({ offline: options.offline ?? false, offlineLoader: options.offlineLoader ?? (async () => PREFETCHED_AMP_PRICING), logger, providerPrefixes: AMP_PROVIDER_PREFIXES, }); } [Symbol.dispose](): void { this.fetcher[Symbol.dispose](); } async getPricing(model: string): Promise { const directLookup = await this.fetcher.getModelPricing(model); if (Result.isFailure(directLookup)) { throw directLookup.error; } const pricing = directLookup.value; if (pricing == null) { logger.warn(`Pricing not found for model ${model}; defaulting to zero-cost pricing.`); return ZERO_MODEL_PRICING; } return { inputCostPerMToken: toPerMillion(pricing.input_cost_per_token), cachedInputCostPerMToken: toPerMillion( pricing.cache_read_input_token_cost, pricing.input_cost_per_token, ), cacheCreationCostPerMToken: toPerMillion( pricing.cache_creation_input_token_cost, pricing.input_cost_per_token, ), outputCostPerMToken: toPerMillion(pricing.output_cost_per_token), }; } async calculateCost( model: string, tokens: { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number; }, ): Promise { const result = await this.fetcher.calculateCostFromTokens( { input_tokens: tokens.inputTokens, output_tokens: tokens.outputTokens, cache_creation_input_tokens: tokens.cacheCreationInputTokens, cache_read_input_tokens: tokens.cacheReadInputTokens, }, model, ); if (Result.isFailure(result)) { logger.warn(`Failed to calculate cost for model ${model}:`, result.error); return 0; } return result.value; } } if (import.meta.vitest != null) { describe('AmpPricingSource', () => { it('converts LiteLLM pricing to per-million costs', async () => { using source = new AmpPricingSource({ offline: true, offlineLoader: async () => ({ 'claude-haiku-4-5-20251001': { input_cost_per_token: 1e-6, output_cost_per_token: 5e-6, cache_read_input_token_cost: 1e-7, cache_creation_input_token_cost: 1.25e-6, }, }), }); const pricing = await source.getPricing('claude-haiku-4-5-20251001'); expect(pricing.inputCostPerMToken).toBeCloseTo(1); expect(pricing.outputCostPerMToken).toBeCloseTo(5); expect(pricing.cachedInputCostPerMToken).toBeCloseTo(0.1); expect(pricing.cacheCreationCostPerMToken).toBeCloseTo(1.25); }); it('calculates cost from tokens', async () => { using source = new AmpPricingSource({ offline: true, offlineLoader: async () => ({ 'claude-haiku-4-5-20251001': { input_cost_per_token: 1e-6, output_cost_per_token: 5e-6, cache_read_input_token_cost: 1e-7, cache_creation_input_token_cost: 1.25e-6, }, }), }); const cost = await source.calculateCost('claude-haiku-4-5-20251001', { inputTokens: 1000, outputTokens: 500, cacheReadInputTokens: 200, cacheCreationInputTokens: 100, }); const expected = 1000 * 1e-6 + 500 * 5e-6 + 200 * 1e-7 + 100 * 1.25e-6; expect(cost).toBeCloseTo(expected); }); it('falls back to zero pricing for unknown models', async () => { using source = new AmpPricingSource({ offline: true, offlineLoader: async () => ({}), }); const pricing = await source.getPricing('anthropic/unknown'); expect(pricing).toEqual(ZERO_MODEL_PRICING); }); }); } ================================================ FILE: apps/amp/src/run.ts ================================================ import process from 'node:process'; import { cli } from 'gunshi'; import { description, name, version } from '../package.json'; import { dailyCommand, monthlyCommand, sessionCommand } from './commands/index.ts'; const subCommands = new Map([ ['daily', dailyCommand], ['monthly', monthlyCommand], ['session', sessionCommand], ]); const mainCommand = dailyCommand; export async function run(): Promise { // When invoked through npx, the binary name might be passed as the first argument // Filter it out if it matches the expected binary name let args = process.argv.slice(2); if (args[0] === 'ccusage-amp') { args = args.slice(1); } await cli(args, mainCommand, { name, version, description, subCommands, renderHeader: null, }); } ================================================ FILE: apps/amp/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "lib": ["ESNext"], "moduleDetection": "force", "module": "Preserve", "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["vitest/globals", "vitest/importMeta"], "allowImportingTsExtensions": true, "allowJs": false, "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, "noUnusedLocals": false, "noUnusedParameters": false, "noEmit": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true }, "exclude": ["dist"] } ================================================ FILE: apps/amp/tsdown.config.ts ================================================ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], clean: true, dts: false, shims: true, platform: 'node', target: 'node20', fixedExtension: false, }); ================================================ FILE: apps/amp/vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, includeSource: ['src/**/*.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], }, }, define: { 'import.meta.vitest': 'undefined', }, }); ================================================ FILE: apps/ccusage/CLAUDE.md ================================================ # CLAUDE.md - ccusage Package This is the main ccusage CLI package that provides usage analysis for Claude Code. ## Package Overview **Name**: `ccusage` **Description**: Usage analysis tool for Claude Code **Type**: CLI tool and library with TypeScript exports ## Development Commands **Testing and Quality:** - `pnpm run test` - Run all tests (using vitest via pnpm, watch mode disabled) - `pnpm run lint` - Lint code using ESLint - `pnpm run format` - Format and auto-fix code with ESLint - `pnpm typecheck` - Type check with TypeScript **Build and Release:** - `pnpm run build` - Build distribution files with tsdown (includes schema generation) - `pnpm run generate:schema` - Generate JSON schema for configuration - `pnpm run prerelease` - Full release workflow (lint + typecheck + build) **Development Usage:** - `pnpm run start daily` - Show daily usage report - `pnpm run start monthly` - Show monthly usage report - `pnpm run start session` - Show session-based usage report - `pnpm run start blocks` - Show 5-hour billing blocks usage report - `pnpm run start statusline` - Show compact status line (Beta) - Add `--json` flag for JSON output format - Add `--mode ` for cost calculation control (auto/calculate/display) - Add `--active` flag for blocks to show only active block with projections - Add `--recent` flag for blocks to show last 3 days including active **CLI Testing:** - `pnpm run test:statusline` - Test statusline with default test data - `pnpm run test:statusline:all` - Test statusline with all model variants - `pnpm run test:statusline:sonnet4` - Test with Sonnet 4 data - `pnpm run test:statusline:opus4` - Test with Opus 4 data - `pnpm run test:statusline:sonnet41` - Test with Sonnet 4.1 data ## Architecture This package contains the core ccusage functionality: **Key Modules:** - `src/index.ts` - CLI entry point with Gunshi-based command routing - `src/data-loader.ts` - Parses JSONL files from Claude data directories - `src/calculate-cost.ts` - Token aggregation and cost calculation utilities - `src/commands/` - CLI subcommands (daily, monthly, session, blocks, statusline) - `src/logger.ts` - Logging utilities (use instead of console.log) **Data Flow:** 1. Loads JSONL files from `~/.claude/projects/` and `~/.config/claude/projects/` 2. Aggregates usage data by time periods or sessions 3. Calculates costs using LiteLLM pricing database 4. Outputs formatted tables or JSON ## Testing Guidelines - **In-Source Testing**: Tests are written in the same files using `if (import.meta.vitest != null)` blocks - **Vitest Globals Enabled**: Use `describe`, `it`, `expect` directly without imports - **Model Testing**: Use current Claude 4 models (sonnet-4, opus-4) in tests - **Mock Data**: Uses `fs-fixture` with `createFixture()` for Claude data simulation - **CRITICAL**: NEVER use `await import()` dynamic imports anywhere, especially in test blocks ## Code Style - **Error Handling**: Prefer `@praha/byethrow Result` type over try-catch for functional error handling - **Imports**: Use `.ts` extensions for local imports (e.g., `import { foo } from './utils.ts'`) - **Exports**: Only export what's actually used by other modules - **Dependencies**: Add as `devDependencies` unless explicitly requested otherwise - **No console.log**: Use `logger.ts` instead **Post-Change Workflow:** Always run these commands in parallel after code changes: - `pnpm run format` - Auto-fix and format - `pnpm typecheck` - Type checking - `pnpm run test` - Run tests ## Environment Variables - `LOG_LEVEL` - Control logging verbosity (0=silent, 1=warn, 2=log, 3=info, 4=debug, 5=trace) - `CLAUDE_CONFIG_DIR` - Custom Claude data directory paths (supports multiple comma-separated paths) ## Dependencies Because `ccusage` is distributed as a bundled CLI, keep all runtime libraries in `devDependencies` so the bundler captures them. **Key Runtime Dependencies:** - `gunshi` - CLI framework - `cli-table3` - Table formatting - `valibot` - Schema validation - `@praha/byethrow` - Functional error handling **Key Dev Dependencies:** - `vitest` - Testing framework - `tsdown` - TypeScript build tool - `eslint` - Linting and formatting - `fs-fixture` - Test fixture creation ## Package Exports The package provides multiple exports for library usage: - `.` - Main CLI entry point - `./calculate-cost` - Cost calculation utilities - `./data-loader` - Data loading functions - `./debug` - Debug utilities - `./logger` - Logging utilities - `./pricing-fetcher` - LiteLLM pricing integration ================================================ FILE: apps/ccusage/LICENSE ================================================ MIT License Copyright (c) 2025 ryoppippi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: apps/ccusage/README.md ================================================
ccusage logo

ccusage

Socket Badge npm version NPM Downloads install size DeepWiki Mentioned in Awesome Claude Code

> Analyze your Claude Code token usage and costs from local JSONL files — incredibly fast and informative! ## ccusage Family ### 📊 [ccusage](https://www.npmjs.com/package/ccusage) - Claude Code Usage Analyzer The main CLI tool for analyzing Claude Code usage from local JSONL files. Track daily, monthly, and session-based usage with beautiful tables. ### 🤖 [@ccusage/codex](https://www.npmjs.com/package/@ccusage/codex) - OpenAI Codex Usage Analyzer Companion tool for analyzing OpenAI Codex usage. Same powerful features as ccusage but tailored for Codex users, including GPT-5 support and 1M token context windows. ### 🚀 [@ccusage/opencode](https://www.npmjs.com/package/@ccusage/opencode) - OpenCode Usage Analyzer Companion tool for analyzing [OpenCode](https://github.com/opencode-ai/opencode) usage. Track token usage and costs from OpenCode sessions with the same reporting capabilities as ccusage. ### 🥧 [@ccusage/pi](https://www.npmjs.com/package/@ccusage/pi) - Pi-agent Usage Analyzer Companion tool for analyzing [pi-agent](https://github.com/badlogic/pi-mono) session usage. Track token usage and costs from your pi-agent sessions with daily, monthly, and session-based reports. ### ⚡ [@ccusage/amp](https://www.npmjs.com/package/@ccusage/amp) - Amp Usage Analyzer Companion tool for analyzing [Amp](https://ampcode.com/) session usage. Track token usage, costs, and credits from your Amp CLI sessions with daily, monthly, and session-based reports. ### 🔌 [@ccusage/mcp](https://www.npmjs.com/package/@ccusage/mcp) - MCP Server Integration Model Context Protocol server that exposes ccusage data to Claude Desktop and other MCP-compatible tools. Enable real-time usage tracking directly in your AI workflows. ## Installation ### Quick Start (Recommended) Thanks to ccusage's incredibly small bundle size ([![install size](https://packagephobia.com/badge?p=ccusage)](https://packagephobia.com/result?p=ccusage)), you can run it directly without installation: ```bash # Recommended - always include @latest to ensure you get the newest version npx ccusage@latest bunx ccusage # Alternative package runners pnpm dlx ccusage pnpx ccusage # Using deno (with security flags) deno run -E -R=$HOME/.claude/projects/ -S=homedir -N='raw.githubusercontent.com:443' npm:ccusage@latest ``` > 💡 **Important**: We strongly recommend using `@latest` suffix with npx (e.g., `npx ccusage@latest`) to ensure you're running the most recent version with the latest features and bug fixes. ### Related Tools ```bash npx @ccusage/codex@latest # OpenAI Codex usage tracking npx @ccusage/opencode@latest # OpenCode usage tracking npx @ccusage/pi@latest # Pi-agent usage tracking npx @ccusage/amp@latest # Amp usage tracking npx @ccusage/mcp@latest # MCP Server ``` ## Usage ```bash # Basic usage npx ccusage # Show daily report (default) npx ccusage daily # Daily token usage and costs npx ccusage monthly # Monthly aggregated report npx ccusage session # Usage by conversation session npx ccusage blocks # 5-hour billing windows npx ccusage statusline # Compact status line for hooks (Beta) # Filters and options npx ccusage daily --since 20250525 --until 20250530 npx ccusage daily --json # JSON output npx ccusage daily --breakdown # Per-model cost breakdown npx ccusage daily --timezone UTC # Use UTC timezone npx ccusage daily --locale ja-JP # Use Japanese locale for date/time formatting # Project analysis npx ccusage daily --instances # Group by project/instance npx ccusage daily --project myproject # Filter to specific project npx ccusage daily --instances --project myproject --json # Combined usage # Compact mode for screenshots/sharing npx ccusage --compact # Force compact table mode npx ccusage monthly --compact # Compact monthly report ``` ## Features - 📊 **Daily Report**: View token usage and costs aggregated by date - 📅 **Monthly Report**: View token usage and costs aggregated by month - 💬 **Session Report**: View usage grouped by conversation sessions - ⏰ **5-Hour Blocks Report**: Track usage within Claude's billing windows with active block monitoring - 🚀 **Statusline Integration**: Compact usage display for Claude Code status bar hooks (Beta) - 🤖 **Model Tracking**: See which Claude models you're using (Opus, Sonnet, etc.) - 📊 **Model Breakdown**: View per-model cost breakdown with `--breakdown` flag - 📅 **Date Filtering**: Filter reports by date range using `--since` and `--until` - 📁 **Custom Path**: Support for custom Claude data directory locations - 🎨 **Beautiful Output**: Colorful table-formatted display with automatic responsive layout - 📱 **Smart Tables**: Automatic compact mode for narrow terminals (< 100 characters) with essential columns - 📸 **Compact Mode**: Use `--compact` flag to force compact table layout, perfect for screenshots and sharing - 📋 **Enhanced Model Display**: Model names shown as bulleted lists for better readability - 📄 **JSON Output**: Export data in structured JSON format with `--json` - 💰 **Cost Tracking**: Shows costs in USD for each day/month/session - 🔄 **Cache Token Support**: Tracks and displays cache creation and cache read tokens separately - 🌐 **Offline Mode**: Use pre-cached pricing data without network connectivity with `--offline` (Claude models only) - 🔌 **MCP Integration**: Built-in Model Context Protocol server for integration with other tools - 🏗️ **Multi-Instance Support**: Group usage by project with `--instances` flag and filter by specific projects - 🌍 **Timezone Support**: Configure timezone for date grouping with `--timezone` option - 🌐 **Locale Support**: Customize date/time formatting with `--locale` option (e.g., en-US, ja-JP, de-DE) - ⚙️ **Configuration Files**: Set defaults with JSON configuration files, complete with IDE autocomplete and validation - 🚀 **Ultra-Small Bundle**: Unlike other CLI tools, we pay extreme attention to bundle size - incredibly small even without minification! ## Documentation Full documentation is available at **[ccusage.com](https://ccusage.com/)** ## Development Setup ### Using Nix (Recommended for Contributors) For contributors and developers working on ccusage, we provide a Nix flake-based development environment: ```bash # Clone the repository git clone https://github.com/ryoppippi/ccusage.git cd ccusage # Allow direnv (automatically loads Nix environment) direnv allow # Or manually enter the development shell nix develop ``` This ensures consistent tooling versions across all contributors and CI systems. The development environment is defined in `flake.nix` and automatically activated via direnv when entering the project directory. ## Sponsors ### Featured Sponsor Check out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)

ccusage: The Claude Code cost scorecard that went viral

## Star History Star History Chart ## License [MIT](LICENSE) © [@ryoppippi](https://github.com/ryoppippi) ================================================ FILE: apps/ccusage/config-schema.json ================================================ { "$ref": "#/definitions/ccusage-config", "definitions": { "ccusage-config": { "type": "object", "properties": { "$schema": { "type": "string", "description": "JSON Schema URL for validation and autocomplete", "markdownDescription": "JSON Schema URL for validation and autocomplete" }, "defaults": { "type": "object", "properties": { "since": { "type": "string", "description": "Filter from date (YYYYMMDD format)", "markdownDescription": "Filter from date (YYYYMMDD format)" }, "until": { "type": "string", "description": "Filter until date (YYYYMMDD format)", "markdownDescription": "Filter until date (YYYYMMDD format)" }, "json": { "type": "boolean", "description": "Output in JSON format", "markdownDescription": "Output in JSON format", "default": false }, "mode": { "type": "string", "enum": ["auto", "calculate", "display"], "description": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "markdownDescription": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "default": "auto" }, "debug": { "type": "boolean", "description": "Show pricing mismatch information for debugging", "markdownDescription": "Show pricing mismatch information for debugging", "default": false }, "debugSamples": { "type": "number", "description": "Number of sample discrepancies to show in debug output (default: 5)", "markdownDescription": "Number of sample discrepancies to show in debug output (default: 5)", "default": 5 }, "order": { "type": "string", "enum": ["desc", "asc"], "description": "Sort order: desc (newest first) or asc (oldest first)", "markdownDescription": "Sort order: desc (newest first) or asc (oldest first)", "default": "asc" }, "breakdown": { "type": "boolean", "description": "Show per-model cost breakdown", "markdownDescription": "Show per-model cost breakdown", "default": false }, "offline": { "type": "boolean", "description": "Use cached pricing data for Claude models instead of fetching from API", "markdownDescription": "Use cached pricing data for Claude models instead of fetching from API", "default": false }, "color": { "type": "boolean", "description": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.", "markdownDescription": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect." }, "noColor": { "type": "boolean", "description": "Disable colored output (default: auto). NO_COLOR=1 has the same effect.", "markdownDescription": "Disable colored output (default: auto). NO_COLOR=1 has the same effect." }, "timezone": { "type": "string", "description": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone", "markdownDescription": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone" }, "locale": { "type": "string", "description": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "markdownDescription": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "default": "en-CA" }, "jq": { "type": "string", "description": "Process JSON output with jq command (requires jq binary, implies --json)", "markdownDescription": "Process JSON output with jq command (requires jq binary, implies --json)" }, "compact": { "type": "boolean", "description": "Force compact mode for narrow displays (better for screenshots)", "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false } }, "additionalProperties": false, "description": "Default values for all commands", "markdownDescription": "Default values for all commands" }, "commands": { "type": "object", "properties": { "daily": { "type": "object", "properties": { "since": { "type": "string", "description": "Filter from date (YYYYMMDD format)", "markdownDescription": "Filter from date (YYYYMMDD format)" }, "until": { "type": "string", "description": "Filter until date (YYYYMMDD format)", "markdownDescription": "Filter until date (YYYYMMDD format)" }, "json": { "type": "boolean", "description": "Output in JSON format", "markdownDescription": "Output in JSON format", "default": false }, "mode": { "type": "string", "enum": ["auto", "calculate", "display"], "description": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "markdownDescription": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "default": "auto" }, "debug": { "type": "boolean", "description": "Show pricing mismatch information for debugging", "markdownDescription": "Show pricing mismatch information for debugging", "default": false }, "debugSamples": { "type": "number", "description": "Number of sample discrepancies to show in debug output (default: 5)", "markdownDescription": "Number of sample discrepancies to show in debug output (default: 5)", "default": 5 }, "order": { "type": "string", "enum": ["desc", "asc"], "description": "Sort order: desc (newest first) or asc (oldest first)", "markdownDescription": "Sort order: desc (newest first) or asc (oldest first)", "default": "asc" }, "breakdown": { "type": "boolean", "description": "Show per-model cost breakdown", "markdownDescription": "Show per-model cost breakdown", "default": false }, "offline": { "type": "boolean", "description": "Use cached pricing data for Claude models instead of fetching from API", "markdownDescription": "Use cached pricing data for Claude models instead of fetching from API", "default": false }, "color": { "type": "boolean", "description": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.", "markdownDescription": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect." }, "noColor": { "type": "boolean", "description": "Disable colored output (default: auto). NO_COLOR=1 has the same effect.", "markdownDescription": "Disable colored output (default: auto). NO_COLOR=1 has the same effect." }, "timezone": { "type": "string", "description": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone", "markdownDescription": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone" }, "locale": { "type": "string", "description": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "markdownDescription": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "default": "en-CA" }, "jq": { "type": "string", "description": "Process JSON output with jq command (requires jq binary, implies --json)", "markdownDescription": "Process JSON output with jq command (requires jq binary, implies --json)" }, "compact": { "type": "boolean", "description": "Force compact mode for narrow displays (better for screenshots)", "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false }, "instances": { "type": "boolean", "description": "Show usage breakdown by project/instance", "markdownDescription": "Show usage breakdown by project/instance", "default": false }, "project": { "type": "string", "description": "Filter to specific project name", "markdownDescription": "Filter to specific project name" }, "projectAliases": { "type": "string", "description": "Comma-separated project aliases (e.g., 'ccusage=Usage Tracker,myproject=My Project')", "markdownDescription": "Comma-separated project aliases (e.g., 'ccusage=Usage Tracker,myproject=My Project')" } }, "additionalProperties": false }, "monthly": { "type": "object", "properties": { "since": { "type": "string", "description": "Filter from date (YYYYMMDD format)", "markdownDescription": "Filter from date (YYYYMMDD format)" }, "until": { "type": "string", "description": "Filter until date (YYYYMMDD format)", "markdownDescription": "Filter until date (YYYYMMDD format)" }, "json": { "type": "boolean", "description": "Output in JSON format", "markdownDescription": "Output in JSON format", "default": false }, "mode": { "type": "string", "enum": ["auto", "calculate", "display"], "description": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "markdownDescription": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "default": "auto" }, "debug": { "type": "boolean", "description": "Show pricing mismatch information for debugging", "markdownDescription": "Show pricing mismatch information for debugging", "default": false }, "debugSamples": { "type": "number", "description": "Number of sample discrepancies to show in debug output (default: 5)", "markdownDescription": "Number of sample discrepancies to show in debug output (default: 5)", "default": 5 }, "order": { "type": "string", "enum": ["desc", "asc"], "description": "Sort order: desc (newest first) or asc (oldest first)", "markdownDescription": "Sort order: desc (newest first) or asc (oldest first)", "default": "asc" }, "breakdown": { "type": "boolean", "description": "Show per-model cost breakdown", "markdownDescription": "Show per-model cost breakdown", "default": false }, "offline": { "type": "boolean", "description": "Use cached pricing data for Claude models instead of fetching from API", "markdownDescription": "Use cached pricing data for Claude models instead of fetching from API", "default": false }, "color": { "type": "boolean", "description": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.", "markdownDescription": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect." }, "noColor": { "type": "boolean", "description": "Disable colored output (default: auto). NO_COLOR=1 has the same effect.", "markdownDescription": "Disable colored output (default: auto). NO_COLOR=1 has the same effect." }, "timezone": { "type": "string", "description": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone", "markdownDescription": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone" }, "locale": { "type": "string", "description": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "markdownDescription": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "default": "en-CA" }, "jq": { "type": "string", "description": "Process JSON output with jq command (requires jq binary, implies --json)", "markdownDescription": "Process JSON output with jq command (requires jq binary, implies --json)" }, "compact": { "type": "boolean", "description": "Force compact mode for narrow displays (better for screenshots)", "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false } }, "additionalProperties": false }, "weekly": { "type": "object", "properties": { "since": { "type": "string", "description": "Filter from date (YYYYMMDD format)", "markdownDescription": "Filter from date (YYYYMMDD format)" }, "until": { "type": "string", "description": "Filter until date (YYYYMMDD format)", "markdownDescription": "Filter until date (YYYYMMDD format)" }, "json": { "type": "boolean", "description": "Output in JSON format", "markdownDescription": "Output in JSON format", "default": false }, "mode": { "type": "string", "enum": ["auto", "calculate", "display"], "description": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "markdownDescription": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "default": "auto" }, "debug": { "type": "boolean", "description": "Show pricing mismatch information for debugging", "markdownDescription": "Show pricing mismatch information for debugging", "default": false }, "debugSamples": { "type": "number", "description": "Number of sample discrepancies to show in debug output (default: 5)", "markdownDescription": "Number of sample discrepancies to show in debug output (default: 5)", "default": 5 }, "order": { "type": "string", "enum": ["desc", "asc"], "description": "Sort order: desc (newest first) or asc (oldest first)", "markdownDescription": "Sort order: desc (newest first) or asc (oldest first)", "default": "asc" }, "breakdown": { "type": "boolean", "description": "Show per-model cost breakdown", "markdownDescription": "Show per-model cost breakdown", "default": false }, "offline": { "type": "boolean", "description": "Use cached pricing data for Claude models instead of fetching from API", "markdownDescription": "Use cached pricing data for Claude models instead of fetching from API", "default": false }, "color": { "type": "boolean", "description": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.", "markdownDescription": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect." }, "noColor": { "type": "boolean", "description": "Disable colored output (default: auto). NO_COLOR=1 has the same effect.", "markdownDescription": "Disable colored output (default: auto). NO_COLOR=1 has the same effect." }, "timezone": { "type": "string", "description": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone", "markdownDescription": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone" }, "locale": { "type": "string", "description": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "markdownDescription": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "default": "en-CA" }, "jq": { "type": "string", "description": "Process JSON output with jq command (requires jq binary, implies --json)", "markdownDescription": "Process JSON output with jq command (requires jq binary, implies --json)" }, "compact": { "type": "boolean", "description": "Force compact mode for narrow displays (better for screenshots)", "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false }, "startOfWeek": { "type": "string", "enum": [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ], "description": "Day to start the week on", "markdownDescription": "Day to start the week on", "default": "sunday" } }, "additionalProperties": false }, "session": { "type": "object", "properties": { "since": { "type": "string", "description": "Filter from date (YYYYMMDD format)", "markdownDescription": "Filter from date (YYYYMMDD format)" }, "until": { "type": "string", "description": "Filter until date (YYYYMMDD format)", "markdownDescription": "Filter until date (YYYYMMDD format)" }, "json": { "type": "boolean", "description": "Output in JSON format", "markdownDescription": "Output in JSON format", "default": false }, "mode": { "type": "string", "enum": ["auto", "calculate", "display"], "description": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "markdownDescription": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "default": "auto" }, "debug": { "type": "boolean", "description": "Show pricing mismatch information for debugging", "markdownDescription": "Show pricing mismatch information for debugging", "default": false }, "debugSamples": { "type": "number", "description": "Number of sample discrepancies to show in debug output (default: 5)", "markdownDescription": "Number of sample discrepancies to show in debug output (default: 5)", "default": 5 }, "breakdown": { "type": "boolean", "description": "Show per-model cost breakdown", "markdownDescription": "Show per-model cost breakdown", "default": false }, "offline": { "type": "boolean", "description": "Use cached pricing data for Claude models instead of fetching from API", "markdownDescription": "Use cached pricing data for Claude models instead of fetching from API", "default": false }, "color": { "type": "boolean", "description": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.", "markdownDescription": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect." }, "noColor": { "type": "boolean", "description": "Disable colored output (default: auto). NO_COLOR=1 has the same effect.", "markdownDescription": "Disable colored output (default: auto). NO_COLOR=1 has the same effect." }, "timezone": { "type": "string", "description": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone", "markdownDescription": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone" }, "locale": { "type": "string", "description": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "markdownDescription": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "default": "en-CA" }, "jq": { "type": "string", "description": "Process JSON output with jq command (requires jq binary, implies --json)", "markdownDescription": "Process JSON output with jq command (requires jq binary, implies --json)" }, "compact": { "type": "boolean", "description": "Force compact mode for narrow displays (better for screenshots)", "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false }, "id": { "type": "string", "description": "Load usage data for a specific session ID", "markdownDescription": "Load usage data for a specific session ID" } }, "additionalProperties": false }, "blocks": { "type": "object", "properties": { "since": { "type": "string", "description": "Filter from date (YYYYMMDD format)", "markdownDescription": "Filter from date (YYYYMMDD format)" }, "until": { "type": "string", "description": "Filter until date (YYYYMMDD format)", "markdownDescription": "Filter until date (YYYYMMDD format)" }, "json": { "type": "boolean", "description": "Output in JSON format", "markdownDescription": "Output in JSON format", "default": false }, "mode": { "type": "string", "enum": ["auto", "calculate", "display"], "description": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "markdownDescription": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)", "default": "auto" }, "debug": { "type": "boolean", "description": "Show pricing mismatch information for debugging", "markdownDescription": "Show pricing mismatch information for debugging", "default": false }, "debugSamples": { "type": "number", "description": "Number of sample discrepancies to show in debug output (default: 5)", "markdownDescription": "Number of sample discrepancies to show in debug output (default: 5)", "default": 5 }, "order": { "type": "string", "enum": ["desc", "asc"], "description": "Sort order: desc (newest first) or asc (oldest first)", "markdownDescription": "Sort order: desc (newest first) or asc (oldest first)", "default": "asc" }, "breakdown": { "type": "boolean", "description": "Show per-model cost breakdown", "markdownDescription": "Show per-model cost breakdown", "default": false }, "offline": { "type": "boolean", "description": "Use cached pricing data for Claude models instead of fetching from API", "markdownDescription": "Use cached pricing data for Claude models instead of fetching from API", "default": false }, "color": { "type": "boolean", "description": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.", "markdownDescription": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect." }, "noColor": { "type": "boolean", "description": "Disable colored output (default: auto). NO_COLOR=1 has the same effect.", "markdownDescription": "Disable colored output (default: auto). NO_COLOR=1 has the same effect." }, "timezone": { "type": "string", "description": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone", "markdownDescription": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone" }, "locale": { "type": "string", "description": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "markdownDescription": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)", "default": "en-CA" }, "jq": { "type": "string", "description": "Process JSON output with jq command (requires jq binary, implies --json)", "markdownDescription": "Process JSON output with jq command (requires jq binary, implies --json)" }, "compact": { "type": "boolean", "description": "Force compact mode for narrow displays (better for screenshots)", "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false }, "active": { "type": "boolean", "description": "Show only active block with projections", "markdownDescription": "Show only active block with projections", "default": false }, "recent": { "type": "boolean", "description": "Show blocks from last 3 days (including active)", "markdownDescription": "Show blocks from last 3 days (including active)", "default": false }, "tokenLimit": { "type": "string", "description": "Token limit for quota warnings (e.g., 500000 or \"max\")", "markdownDescription": "Token limit for quota warnings (e.g., 500000 or \"max\")" }, "sessionLength": { "type": "number", "description": "Session block duration in hours (default: 5)", "markdownDescription": "Session block duration in hours (default: 5)", "default": 5 } }, "additionalProperties": false }, "statusline": { "type": "object", "properties": { "offline": { "type": "boolean", "description": "Use cached pricing data for Claude models instead of fetching from API", "markdownDescription": "Use cached pricing data for Claude models instead of fetching from API", "default": true }, "visualBurnRate": { "type": "string", "enum": ["off", "emoji", "text", "emoji-text"], "description": "Controls the visualization of the burn rate status", "markdownDescription": "Controls the visualization of the burn rate status", "default": "off" }, "costSource": { "type": "string", "enum": ["auto", "ccusage", "cc", "both"], "description": "Session cost source: auto (prefer CC then ccusage), ccusage (always calculate), cc (always use Claude Code cost), both (show both costs)", "markdownDescription": "Session cost source: auto (prefer CC then ccusage), ccusage (always calculate), cc (always use Claude Code cost), both (show both costs)", "default": "auto" }, "cache": { "type": "boolean", "description": "Enable cache for status line output (default: true)", "markdownDescription": "Enable cache for status line output (default: true)", "default": true }, "refreshInterval": { "type": "number", "description": "Refresh interval in seconds for cache expiry (default: 1)", "markdownDescription": "Refresh interval in seconds for cache expiry (default: 1)", "default": 1 }, "contextLowThreshold": { "type": "string", "description": "Context usage percentage below which status is shown in green (0-100)", "markdownDescription": "Context usage percentage below which status is shown in green (0-100)", "default": 50 }, "contextMediumThreshold": { "type": "string", "description": "Context usage percentage below which status is shown in yellow (0-100)", "markdownDescription": "Context usage percentage below which status is shown in yellow (0-100)", "default": 80 }, "debug": { "type": "boolean", "description": "Show pricing mismatch information for debugging", "markdownDescription": "Show pricing mismatch information for debugging", "default": false } }, "additionalProperties": false } }, "additionalProperties": false, "description": "Command-specific configuration overrides", "markdownDescription": "Command-specific configuration overrides" } }, "additionalProperties": false } }, "$schema": "https://json-schema.org/draft-07/schema#", "title": "ccusage Configuration", "description": "Configuration file for ccusage - Claude Code usage analysis tool", "examples": [ { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "json": false, "mode": "auto", "timezone": "Asia/Tokyo", "locale": "ja-JP" }, "commands": { "daily": { "instances": true }, "blocks": { "tokenLimit": "500000" } } } ] } ================================================ FILE: apps/ccusage/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; /** @type {import('eslint').Linter.FlatConfig[]} */ const config = ryoppippi( { type: 'lib', stylistic: false, }, { rules: { 'test/no-importing-vitest-globals': 'error', }, }, ); export default config; ================================================ FILE: apps/ccusage/package.json ================================================ { "name": "ccusage", "type": "module", "version": "18.0.10", "description": "Usage analysis tool for Claude Code", "author": "ryoppippi", "license": "MIT", "funding": "https://github.com/ryoppippi/ccusage?sponsor=1", "homepage": "https://github.com/ryoppippi/ccusage#readme", "repository": { "type": "git", "url": "git+https://github.com/ryoppippi/ccusage.git", "directory": "apps/ccusage" }, "bugs": { "url": "https://github.com/ryoppippi/ccusage/issues" }, "exports": { ".": "./src/index.ts", "./calculate-cost": "./src/calculate-cost.ts", "./data-loader": "./src/data-loader.ts", "./debug": "./src/debug.ts", "./logger": "./src/logger.ts", "./package.json": "./package.json" }, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { "ccusage": "./src/index.ts" }, "files": [ "config-schema.json", "dist" ], "publishConfig": { "bin": { "ccusage": "./dist/index.js" }, "exports": { ".": "./dist/index.js", "./calculate-cost": "./dist/calculate-cost.js", "./data-loader": "./dist/data-loader.js", "./debug": "./dist/debug.js", "./logger": "./dist/logger.js", "./package.json": "./package.json" } }, "engines": { "node": ">=20.19.4" }, "scripts": { "build": "pnpm run generate:schema && tsdown", "format": "pnpm run lint --fix", "generate:schema": "bun scripts/generate-json-schema.ts", "lint": "eslint --cache .", "prepack": "pnpm run build && clean-pkg-json", "prepare": "pnpm run generate:schema || true", "prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build", "start": "bun ./src/index.ts", "test": "TZ=UTC vitest", "test:statusline": "cat test/statusline-test.json | node ./src/index.ts statusline", "test:statusline:all": "echo 'Testing Sonnet 4:' && pnpm run test:statusline:sonnet4 && echo 'Testing Opus 4.1:' && pnpm run test:statusline:opus4 && echo 'Testing Sonnet 4.1:' && pnpm run test:statusline:sonnet41", "test:statusline:opus4": "cat test/statusline-test-opus4.json | node ./src/index.ts statusline --offline", "test:statusline:sonnet4": "cat test/statusline-test-sonnet4.json | node ./src/index.ts statusline --offline", "test:statusline:sonnet41": "cat test/statusline-test-sonnet41.json | node ./src/index.ts statusline --offline", "typecheck": "tsgo --noEmit" }, "devDependencies": { "@antfu/utils": "catalog:runtime", "@ccusage/internal": "workspace:*", "@ccusage/terminal": "workspace:*", "@oxc-project/runtime": "catalog:build", "@praha/byethrow": "catalog:runtime", "@ryoppippi/eslint-config": "catalog:lint", "@ryoppippi/limo": "catalog:runtime", "@std/async": "catalog:runtime", "@types/bun": "catalog:types", "@typescript/native-preview": "catalog:types", "ansi-escapes": "catalog:runtime", "bumpp": "catalog:release", "clean-pkg-json": "catalog:release", "es-toolkit": "catalog:runtime", "eslint": "catalog:lint", "eslint-plugin-format": "catalog:lint", "fast-sort": "catalog:runtime", "fs-fixture": "catalog:testing", "get-stdin": "catalog:runtime", "gunshi": "catalog:runtime", "nano-spawn": "catalog:runtime", "p-limit": "catalog:runtime", "path-type": "catalog:runtime", "picocolors": "catalog:runtime", "pretty-ms": "catalog:runtime", "publint": "catalog:lint", "string-width": "catalog:runtime", "tinyglobby": "catalog:runtime", "tsdown": "catalog:build", "type-fest": "catalog:runtime", "unplugin-macros": "catalog:build", "unplugin-unused": "catalog:build", "valibot": "catalog:runtime", "vitest": "catalog:testing", "xdg-basedir": "catalog:runtime" } } ================================================ FILE: apps/ccusage/scripts/generate-json-schema.ts ================================================ #!/usr/bin/env bun /** * @fileoverview Generate JSON Schema from args-tokens configuration schema * * This script generates a JSON Schema file from the args-tokens configuration schema * for ccusage configuration files. The generated schema enables: * - IDE autocomplete and validation * - Documentation of available options * - Schema validation for configuration files */ import process from 'node:process'; import { Result } from '@praha/byethrow'; import { $ } from 'bun'; import { sharedArgs } from '../src/_shared-args.ts'; // Import command definitions to access their args import { subCommandUnion } from '../src/commands/index.ts'; import { logger } from '../src/logger.ts'; /** * The filename for the generated JSON Schema file. * Used for both root directory and docs/public directory output. */ const SCHEMA_FILENAME = 'config-schema.json'; /** * Keys to exclude from the generated JSON Schema. * These are CLI-only options that shouldn't appear in configuration files. */ const EXCLUDE_KEYS = ['config']; /** * Command-specific keys to exclude from the generated JSON Schema. * These are CLI-only options that shouldn't appear in configuration files. */ const COMMAND_EXCLUDE_KEYS: Record = { blocks: ['live', 'refreshInterval'], }; /** * Convert args-tokens schema to JSON Schema format */ function tokensSchemaToJsonSchema(schema: Record): Record { const properties: Record = {}; for (const [key, arg] of Object.entries(schema)) { // eslint-disable-next-line ts/no-unsafe-assignment const argTyped = arg; const property: Record = {}; // Handle type conversion // eslint-disable-next-line ts/no-unsafe-member-access switch (argTyped.type) { case 'boolean': property.type = 'boolean'; break; case 'number': property.type = 'number'; break; case 'string': case 'custom': property.type = 'string'; break; case 'enum': property.type = 'string'; // eslint-disable-next-line ts/no-unsafe-member-access if (argTyped.choices != null && Array.isArray(argTyped.choices)) { // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access property.enum = argTyped.choices; } break; default: property.type = 'string'; } // Add description // eslint-disable-next-line ts/no-unsafe-member-access if (argTyped.description != null) { // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access property.description = argTyped.description; // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access property.markdownDescription = argTyped.description; } // Add default value // eslint-disable-next-line ts/no-unsafe-member-access if ('default' in argTyped && argTyped.default !== undefined) { // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access property.default = argTyped.default; } properties[key] = property; } return { type: 'object', properties, additionalProperties: false, }; } /** * Create the complete configuration schema from all command definitions */ function createConfigSchemaJson() { // Create schema for default/shared arguments (excluding CLI-only options) const defaultsSchema = Object.fromEntries( Object.entries(sharedArgs).filter(([key]) => !EXCLUDE_KEYS.includes(key)), ); // Create schemas for each command's specific arguments (excluding CLI-only options) const commandSchemas: Record = {}; for (const [commandName, command] of subCommandUnion) { const commandExcludes = COMMAND_EXCLUDE_KEYS[commandName] ?? []; commandSchemas[commandName] = Object.fromEntries( Object.entries(command.args as Record).filter( ([key]) => !EXCLUDE_KEYS.includes(key) && !commandExcludes.includes(key), ), ); } // Convert to JSON Schema format const defaultsJsonSchema = tokensSchemaToJsonSchema(defaultsSchema); const commandsJsonSchema = { type: 'object', properties: Object.fromEntries( Object.entries(commandSchemas).map(([name, schema]) => [ name, // eslint-disable-next-line ts/no-unsafe-argument tokensSchemaToJsonSchema(schema), ]), ), additionalProperties: false, description: 'Command-specific configuration overrides', markdownDescription: 'Command-specific configuration overrides', }; // Main configuration schema return { $ref: '#/definitions/ccusage-config', definitions: { 'ccusage-config': { type: 'object', properties: { $schema: { type: 'string', description: 'JSON Schema URL for validation and autocomplete', markdownDescription: 'JSON Schema URL for validation and autocomplete', }, defaults: { ...defaultsJsonSchema, description: 'Default values for all commands', markdownDescription: 'Default values for all commands', }, commands: commandsJsonSchema, }, additionalProperties: false, }, }, $schema: 'https://json-schema.org/draft-07/schema#', title: 'ccusage Configuration', description: 'Configuration file for ccusage - Claude Code usage analysis tool', examples: [ { $schema: 'https://ccusage.com/config-schema.json', defaults: { json: false, mode: 'auto', timezone: 'Asia/Tokyo', locale: 'ja-JP', }, commands: { daily: { instances: true, }, blocks: { tokenLimit: '500000', }, }, }, ], }; } /** * Generate JSON Schema and write to files */ async function runFormat(files: string[]) { return Result.try({ try: $`pnpm exec oxfmt ${files}`, catch: (error) => error, }); } async function writeFile(path: string, content: string) { const attempt = Result.try({ try: async () => Bun.write(path, content), catch: (error) => error, }); return attempt(); } async function readFile(path: string): Promise> { return Result.try({ try: async () => { const file = Bun.file(path); return file.text(); }, catch: (error) => error, })(); } async function copySchemaToDocsPublic() { const gitRoot = await $`git rev-parse --show-toplevel`.text().then((text) => text.trim()); await $`cp ${SCHEMA_FILENAME} ${gitRoot}/docs/public/${SCHEMA_FILENAME}`; } async function generateJsonSchema() { logger.info('Generating JSON Schema from args-tokens configuration schema...'); // Create the JSON Schema const schemaObject = Result.pipe( Result.try({ try: () => createConfigSchemaJson(), catch: (error) => error, })(), Result.inspectError((error) => { logger.error('Error creating JSON Schema:', error); process.exit(1); }), Result.unwrap(), ); // Check if existing root schema is identical to avoid unnecessary writes const existingRootSchema = await Result.pipe( readFile(SCHEMA_FILENAME), Result.map((content) => JSON.parse(content) as unknown), Result.unwrap(''), ); const isSchemaChanged = !Bun.deepEquals(existingRootSchema, schemaObject, true); if (!isSchemaChanged) { logger.info('✓ Root schema is up to date, skipping generation'); // Always copy to docs/public since it's gitignored await copySchemaToDocsPublic(); logger.info('JSON Schema sync completed successfully!'); return; } const schemaJson = JSON.stringify(schemaObject, null, '\t'); await Result.pipe( Result.try({ try: writeFile(SCHEMA_FILENAME, schemaJson), safe: true, }), Result.inspectError((error) => { logger.error(`Failed to write ${SCHEMA_FILENAME}:`, error); process.exit(1); }), Result.inspect(() => logger.info(`✓ Generated ${SCHEMA_FILENAME}`)), ); // Copy to docs/public using Bun shell await copySchemaToDocsPublic(); // Run format on the root schema file that was changed await Result.pipe( Result.try({ try: runFormat([SCHEMA_FILENAME]), safe: true, }), Result.inspectError((error) => { logger.error('Failed to format generated files:', error); process.exit(1); }), Result.inspect(() => logger.info('✓ Formatted generated files')), ); logger.info('JSON Schema generation completed successfully!'); } // Run the generator if (import.meta.main) { await generateJsonSchema(); } if (import.meta.vitest != null) { describe('tokensSchemaToJsonSchema', () => { it('should convert boolean args to JSON Schema', () => { const schema = { debug: { type: 'boolean', description: 'Enable debug mode', default: false, }, }; const jsonSchema = tokensSchemaToJsonSchema(schema); expect((jsonSchema.properties as Record).debug).toEqual({ type: 'boolean', description: 'Enable debug mode', markdownDescription: 'Enable debug mode', default: false, }); }); it('should convert enum args to JSON Schema', () => { const schema = { mode: { type: 'enum', description: 'Mode selection', choices: ['auto', 'manual'], default: 'auto', }, }; const jsonSchema = tokensSchemaToJsonSchema(schema); expect((jsonSchema.properties as Record).mode).toEqual({ type: 'string', enum: ['auto', 'manual'], description: 'Mode selection', markdownDescription: 'Mode selection', default: 'auto', }); }); }); describe('createConfigSchemaJson', () => { it('should generate valid JSON Schema', () => { const jsonSchema = createConfigSchemaJson(); expect(jsonSchema).toBeDefined(); expect(jsonSchema.$ref).toBe('#/definitions/ccusage-config'); expect(jsonSchema.definitions).toBeDefined(); expect(jsonSchema.definitions['ccusage-config']).toBeDefined(); expect(jsonSchema.definitions['ccusage-config'].type).toBe('object'); }); it('should include all expected properties', () => { const jsonSchema = createConfigSchemaJson(); const mainSchema = jsonSchema.definitions['ccusage-config']; expect(mainSchema.properties).toHaveProperty('$schema'); expect(mainSchema.properties).toHaveProperty('defaults'); expect(mainSchema.properties).toHaveProperty('commands'); }); it('should include all command schemas', () => { const jsonSchema = createConfigSchemaJson(); const commandsSchema = jsonSchema.definitions['ccusage-config'].properties.commands; expect(commandsSchema.properties).toHaveProperty('daily'); expect(commandsSchema.properties).toHaveProperty('monthly'); expect(commandsSchema.properties).toHaveProperty('weekly'); expect(commandsSchema.properties).toHaveProperty('session'); expect(commandsSchema.properties).toHaveProperty('blocks'); expect(commandsSchema.properties).toHaveProperty('mcp'); expect(commandsSchema.properties).toHaveProperty('statusline'); }); }); } ================================================ FILE: apps/ccusage/src/_config-loader-tokens.ts ================================================ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import process from 'node:process'; import { toArray } from '@antfu/utils'; import { Result } from '@praha/byethrow'; import { createFixture } from 'fs-fixture'; import { CONFIG_FILE_NAME } from './_consts.ts'; import { getClaudePaths } from './data-loader.ts'; import { logger } from './logger.ts'; /** * Minimal command context interface for config merging * Contains only the properties we need from Gunshi's CommandContext */ export type ConfigMergeContext> = { /** Command values from CLI */ values: T; /** Command tokens from CLI */ tokens: unknown[]; /** Command name being executed */ name?: string; }; /** * Extract explicitly provided arguments from gunshi tokens * @param tokens - Command tokens from ctx.tokens * @returns Object with keys as argument names and values as boolean (true if explicitly provided) */ function extractExplicitArgs(tokens: unknown[]): Record { const explicit: Record = {}; for (const token of tokens) { if (typeof token === 'object' && token !== null) { const t = token as { kind?: string; name?: string }; if (t.kind === 'option' && typeof t.name === 'string') { explicit[t.name] = true; } } } return explicit; } // Type for configuration data (simple structure without Valibot) export type ConfigData = { $schema?: string; defaults?: Record; commands?: Record>; source?: string; }; /** * Get configuration file search paths in priority order (highest to lowest) * 1. Local .ccusage/ccusage.json * 2. User config directories from getClaudePaths() + ccusage.json */ function getConfigSearchPaths(): string[] { const claudeConfigDirs = [join(process.cwd(), '.ccusage'), ...toArray(getClaudePaths())]; return claudeConfigDirs.map((dir) => join(dir, CONFIG_FILE_NAME)); } /** * Basic JSON validation - just check if it can be parsed and has expected structure */ function validateConfigJson(data: unknown): data is ConfigData { if (typeof data !== 'object' || data === null) { return false; } const config = data as Record; // Optional schema property if (config.$schema != null && typeof config.$schema !== 'string') { return false; } // Optional defaults property if ( config.defaults != null && (typeof config.defaults !== 'object' || config.defaults === null) ) { return false; } // Optional commands property if ( config.commands != null && (typeof config.commands !== 'object' || config.commands === null) ) { return false; } return true; } /** * Internal function to load and parse a configuration file * @param filePath - Path to the configuration file * @param debug - Whether to enable debug logging * @returns ConfigData if successful, undefined if failed */ function loadConfigFile(filePath: string, debug = false): ConfigData | undefined { if (!existsSync(filePath)) { if (debug) { logger.info(` • Checking: ${filePath} (not found)`); } return undefined; } const parseConfigFileResult = Result.pipe( Result.try({ try: () => { const content = readFileSync(filePath, 'utf-8'); const data = JSON.parse(content) as unknown; if (!validateConfigJson(data)) { throw new Error('Invalid configuration structure'); } // Add source path to the config for debug display data.source = filePath; return data; }, catch: (error) => error, })(), Result.inspect(() => { logger.debug(`Parsed configuration file: ${filePath}`); if (debug) { logger.info(` • Checking: ${filePath} (found ✓)`); } }), Result.inspectError((error) => { const errorMessage = error instanceof Error ? error.message : String(error); logger.warn(`Error parsing configuration file at ${filePath}: ${errorMessage}`); if (debug) { logger.info(` • Checking: ${filePath} (error: ${errorMessage})`); } }), Result.unwrap(undefined), ); return parseConfigFileResult; } /** * Loads configuration from the specified path or auto-discovery * @param configPath - Optional path to specific config file * @param debug - Whether to enable debug logging * @returns Parsed configuration data or undefined if no config found */ export function loadConfig(configPath?: string, debug = false): ConfigData | undefined { if (debug) { logger.info('Debug mode enabled - showing config loading details\n'); } // If specific config path is provided, use it exclusively if (configPath != null) { if (debug) { logger.info('Using specified config file:'); logger.info(` • Path: ${configPath}`); } const config = loadConfigFile(configPath, debug); if (config == null) { logger.warn(`Configuration file not found or invalid: ${configPath}`); } else if (debug) { logger.info(''); logger.info(`Loaded config from: ${configPath}`); logger.info(` • Schema: ${config.$schema ?? 'none'}`); logger.info( ` • Has defaults: ${config.defaults != null ? 'yes' : 'no'}${config.defaults != null ? ` (${Object.keys(config.defaults).length} options)` : ''}`, ); logger.info( ` • Has command configs: ${config.commands != null ? 'yes' : 'no'}${config.commands != null ? ` (${Object.keys(config.commands).join(', ')})` : ''}`, ); } return config; } // Auto-discovery from search paths (highest priority first) if (debug) { logger.info('Searching for config files:'); } for (const searchPath of getConfigSearchPaths()) { const config = loadConfigFile(searchPath, debug); if (config != null) { if (debug) { logger.info(''); logger.info(`Loaded config from: ${searchPath}`); logger.info(` • Schema: ${config.$schema ?? 'none'}`); logger.info( ` • Has defaults: ${config.defaults != null ? 'yes' : 'no'}${config.defaults != null ? ` (${Object.keys(config.defaults).length} options)` : ''}`, ); logger.info( ` • Has command configs: ${config.commands != null ? 'yes' : 'no'}${config.commands != null ? ` (${Object.keys(config.commands).join(', ')})` : ''}`, ); } return config; } // Continue searching other paths even if one config is invalid } logger.debug('No valid configuration file found'); if (debug) { logger.info(''); logger.info('No valid configuration file found'); } return undefined; } /** * Merges configuration with CLI arguments * Priority order (highest to lowest): * 1. CLI arguments (ctx.values) * 2. Command-specific config * 3. Default config * 4. Gunshi defaults * * @param ctx - Command context with values, tokens, and name * @param config - Loaded configuration data * @param debug - Whether to enable debug logging * @returns Merged arguments object */ export function mergeConfigWithArgs>( ctx: ConfigMergeContext, config?: ConfigData, debug = false, ): T { if (config == null) { if (debug) { logger.info(''); logger.info( `No config file loaded, using CLI args only for '${ctx.name ?? 'unknown'}' command`, ); } return ctx.values; } // Start with an empty base const merged = {} as T; const commandName = ctx.name; // Track sources for debug output const sources: Record = {}; // 1. Apply defaults from config (lowest priority) if (config.defaults != null) { for (const [key, value] of Object.entries(config.defaults)) { (merged as Record)[key] = value; sources[key] = 'defaults'; } } // 2. Apply command-specific config if (commandName != null && config.commands?.[commandName] != null) { for (const [key, value] of Object.entries(config.commands[commandName])) { (merged as Record)[key] = value; sources[key] = 'command config'; } } // 3. Apply CLI arguments (highest priority) // Only override with CLI args that are explicitly provided by the user const explicit = extractExplicitArgs(ctx.tokens); for (const [key, value] of Object.entries(ctx.values)) { if (value != null && explicit[key] === true) { // eslint-disable-next-line ts/no-unsafe-member-access (merged as any)[key] = value; sources[key] = 'CLI'; } } logger.debug(`Merged config for ${commandName ?? 'unknown'}:`, merged); if (debug) { logger.info(''); logger.info(`Merging options for '${commandName ?? 'unknown'}' command:`); // Group options by source const bySource: Record = { defaults: [], 'command config': [], CLI: [], }; for (const [key, source] of Object.entries(sources)) { if (bySource[source] != null) { bySource[source].push(`${key}=${JSON.stringify((merged as Record)[key])}`); } } if (bySource.defaults!.length > 0) { logger.info(` • From defaults: ${bySource.defaults!.join(', ')}`); } if (bySource['command config']!.length > 0) { logger.info(` • From command config: ${bySource['command config']!.join(', ')}`); } if (bySource.CLI!.length > 0) { logger.info(` • From CLI args: ${bySource.CLI!.join(', ')}`); } // Show final result with sources logger.info(' • Final merged options: {'); for (const [key, value] of Object.entries(merged)) { const source = sources[key] ?? 'unknown'; logger.info(` ${key}: ${JSON.stringify(value)} (from ${source}),`); } logger.info(' }'); } return merged; } /** * Validates a configuration file without loading it * @param configPath - Path to configuration file * @returns Validation result */ export function validateConfigFile( configPath: string, ): { success: true; data: ConfigData } | { success: false; error: Error } { if (!existsSync(configPath)) { return { success: false, error: new Error(`Configuration file does not exist: ${configPath}`) }; } const parseConfig = Result.try({ try: () => { const content = readFileSync(configPath, 'utf-8'); const data = JSON.parse(content) as unknown; if (!validateConfigJson(data)) { throw new Error('Invalid configuration structure'); } return data; }, catch: (error) => (error instanceof Error ? error : new Error(String(error))), }); const result = parseConfig(); if (Result.isSuccess(result)) { return { success: true, data: result.value }; } else { return { success: false, error: result.error }; } } if (import.meta.vitest != null) { describe('extractExplicitArgs', () => { it('should extract explicit arguments from tokens', () => { const tokens = [ { kind: 'option', name: 'json' }, { kind: 'option', name: 'debug' }, { kind: 'positional', value: 'daily' }, // Should be ignored { kind: 'option', name: 'mode' }, ]; const result = extractExplicitArgs(tokens); expect(result).toEqual({ json: true, debug: true, mode: true, }); }); it('should handle empty tokens array', () => { const result = extractExplicitArgs([]); expect(result).toEqual({}); }); it('should handle invalid token structures', () => { const tokens = [ null, undefined, 'string', 123, { kind: 'option' }, // Missing name { name: 'test' }, // Missing kind { kind: 'other', name: 'ignored' }, // Wrong kind ]; const result = extractExplicitArgs(tokens); expect(result).toEqual({}); }); it('should handle mixed valid and invalid tokens', () => { const tokens = [ { kind: 'option', name: 'valid' }, null, { kind: 'positional', value: 'ignored' }, { kind: 'option', name: 'alsoValid' }, ]; const result = extractExplicitArgs(tokens); expect(result).toEqual({ valid: true, alsoValid: true, }); }); }); describe('loadConfig', () => { beforeEach(() => { vi.restoreAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); it('should load valid configuration from .ccusage/ccusage.json', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': JSON.stringify({ defaults: { json: true }, commands: { daily: { instances: true } }, }), }); // Mock process.cwd to return fixture path vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); const config = loadConfig(); expect(config).toBeDefined(); expect(config?.defaults?.json).toBe(true); expect(config?.commands?.daily?.instances).toBe(true); }); it('should load configuration with specific path', async () => { await using fixture = await createFixture({ 'custom-config.json': JSON.stringify({ defaults: { debug: true }, commands: { monthly: { breakdown: true } }, }), }); const config = loadConfig(fixture.getPath('custom-config.json')); expect(config).toBeDefined(); expect(config?.defaults?.debug).toBe(true); expect(config?.commands?.monthly?.breakdown).toBe(true); }); it('should return undefined for non-existent config file', () => { const config = loadConfig('/non/existent/path.json'); expect(config).toBeUndefined(); }); it('should return undefined when no config files exist in search paths', () => { // Mock process.cwd to return a directory without config files vi.spyOn(process, 'cwd').mockReturnValue('/tmp/empty-dir'); const config = loadConfig(); expect(config).toBeUndefined(); }); it('should handle invalid JSON gracefully', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': '{ invalid json }', }); vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); const config = loadConfig(); expect(config).toBeUndefined(); }); it('should prioritize local .ccusage config over Claude paths', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': JSON.stringify({ defaults: { json: true }, commands: { daily: { priority: 'local' } }, }), }); vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); const config = loadConfig(); expect(config).toBeDefined(); expect(config?.defaults?.json).toBe(true); expect(config?.commands?.daily?.priority).toBe('local'); }); it('should test configuration priority order with multiple files', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': JSON.stringify({ source: 'local', defaults: { mode: 'local-mode' }, }), }); // Test 1: Local config should have highest priority vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); const config1 = loadConfig(); expect(config1?.source).toBe(fixture.getPath('.ccusage/ccusage.json')); expect(config1?.defaults?.mode).toBe('local-mode'); // Test 2: When local doesn't exist, search in Claude paths await using fixture2 = await createFixture({ 'no-ccusage-dir': '', }); vi.spyOn(process, 'cwd').mockReturnValue(fixture2.getPath()); const config2 = loadConfig(); // Since we can't easily mock getClaudePaths, this test verifies the logic // In real implementation, first available config would be loaded expect(config2).toBeUndefined(); // No local .ccusage and no real Claude paths }); it('should handle getClaudePaths() errors gracefully', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': JSON.stringify({ defaults: { json: true }, source: 'local-fallback', }), }); vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); // getClaudePaths might throw if no Claude directories exist const config = loadConfig(); expect(config).toBeDefined(); expect(config?.source).toBe(fixture.getPath('.ccusage/ccusage.json')); expect(config?.defaults?.json).toBe(true); }); it('should handle empty configuration file', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': '{}', }); vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); const config = loadConfig(); expect(config).toBeDefined(); expect(config?.defaults).toBeUndefined(); expect(config?.commands).toBeUndefined(); }); it('should validate configuration structure', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': JSON.stringify({ defaults: 'invalid-type', // Should be object commands: { daily: { instances: true } }, }), }); vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); const config = loadConfig(); expect(config).toBeUndefined(); }); it('should use validateConfigFile internally', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': JSON.stringify({ defaults: { json: true }, commands: { daily: { instances: true } }, }), 'invalid.json': '{ invalid json', 'valid-minimal.json': '{}', }); // Test validateConfigFile directly const validResult = validateConfigFile(fixture.getPath('.ccusage/ccusage.json')); expect(validResult.success).toBe(true); expect((validResult as { success: true; data: ConfigData }).data.defaults?.json).toBe(true); expect( (validResult as { success: true; data: ConfigData }).data.commands?.daily?.instances, ).toBe(true); const invalidResult = validateConfigFile(fixture.getPath('invalid.json')); expect(invalidResult.success).toBe(false); expect((invalidResult as { success: false; error: Error }).error).toBeInstanceOf(Error); const minimalResult = validateConfigFile(fixture.getPath('valid-minimal.json')); expect(minimalResult.success).toBe(true); expect((minimalResult as { success: true; data: ConfigData }).data).toEqual({}); const nonExistentResult = validateConfigFile(fixture.getPath('non-existent.json')); expect(nonExistentResult.success).toBe(false); expect((nonExistentResult as { success: false; error: Error }).error.message).toContain( 'does not exist', ); }); }); describe('mergeConfigWithArgs', () => { it('should merge config with CLI args correctly', () => { const config: ConfigData = { defaults: { json: false, mode: 'auto', debug: false, }, commands: { daily: { instances: true, project: 'test-project', }, }, }; const cliArgs = { json: true, // Override config project: undefined, // Should not override config breakdown: true, // Not in config }; const merged = mergeConfigWithArgs( { values: cliArgs, tokens: [ { kind: 'option', name: 'json' }, { kind: 'option', name: 'breakdown' }, ], name: 'daily', }, config, ); expect(merged).toEqual({ json: true, // From CLI (overrides config) mode: 'auto', // From defaults debug: false, // From defaults instances: true, // From command config project: 'test-project', // From command config (CLI was undefined) breakdown: true, // From CLI (new option) }); }); it('should work without config', () => { const cliArgs = { json: true, debug: false }; const merged = mergeConfigWithArgs({ values: cliArgs, tokens: [ { kind: 'option', name: 'json' }, { kind: 'option', name: 'debug' }, ], name: 'daily', }); expect(merged).toEqual(cliArgs); }); it('should prioritize CLI args over config', () => { const config: ConfigData = { defaults: { json: false }, commands: { daily: { instances: false } }, }; const cliArgs = { json: true, instances: true }; const merged = mergeConfigWithArgs( { values: cliArgs, tokens: [ { kind: 'option', name: 'json' }, { kind: 'option', name: 'instances' }, ], name: 'daily', }, config, ); expect(merged.json).toBe(true); expect(merged.instances).toBe(true); }); it('should not override config with CLI args that were not explicitly provided', () => { const config: ConfigData = { defaults: { json: false, mode: 'calculate', }, commands: { daily: { instances: true, }, }, }; // CLI args has values but only 'json' was explicitly provided const cliArgs = { json: true, mode: 'auto', // This has a value but wasn't explicitly provided instances: false, // This also has a value but wasn't explicitly provided }; const merged = mergeConfigWithArgs( { values: cliArgs, tokens: [ { kind: 'option', name: 'json' }, // Only json was explicitly provided ], name: 'daily', }, config, ); expect(merged).toEqual({ json: true, // From CLI (explicitly provided) mode: 'calculate', // From config (CLI value ignored because not explicit) instances: true, // From command config (CLI value ignored because not explicit) }); }); it('should handle CLI args with null values correctly', () => { const config: ConfigData = { defaults: { project: 'default-project', }, }; const cliArgs = { project: null, // Explicitly set to null }; const merged = mergeConfigWithArgs( { values: cliArgs, tokens: [{ kind: 'option', name: 'project' }], name: 'daily' }, config, ); // null value in CLI args should not override config even if explicit expect(merged).toEqual({ project: 'default-project', // Config value retained because CLI value is null }); }); }); describe('validateConfigFile', () => { it('should validate valid config file', async () => { await using fixture = await createFixture({ 'valid.json': JSON.stringify({ defaults: { json: true }, }), 'invalid.json': '{ invalid json', }); const result = validateConfigFile(fixture.getPath('valid.json')); expect(result.success).toBe(true); }); it('should reject invalid JSON', async () => { await using fixture = await createFixture({ 'valid.json': JSON.stringify({ defaults: { json: true }, }), 'invalid.json': '{ invalid json', }); const result = validateConfigFile(fixture.getPath('invalid.json')); expect(result.success).toBe(false); }); it('should reject non-existent file', () => { const result = validateConfigFile('/non/existent/file.json'); expect(result.success).toBe(false); }); }); describe('debug functionality', () => { let loggerInfoSpy: any; beforeEach(() => { vi.restoreAllMocks(); loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); describe('loadConfig with debug', () => { it('should log debug info when loading config with debug=true', async () => { await using fixture = await createFixture({ '.ccusage/ccusage.json': JSON.stringify({ $schema: 'https://ccusage.com/config-schema.json', defaults: { json: true, mode: 'auto' }, commands: { daily: { instances: true } }, }), }); vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); const config = loadConfig(undefined, true); expect(config).toBeDefined(); expect(loggerInfoSpy).toHaveBeenCalledWith( 'Debug mode enabled - showing config loading details\n', ); expect(loggerInfoSpy).toHaveBeenCalledWith('Searching for config files:'); expect(loggerInfoSpy).toHaveBeenCalledWith( ` • Checking: ${fixture.getPath('.ccusage/ccusage.json')} (found ✓)`, ); expect(loggerInfoSpy).toHaveBeenCalledWith(''); expect(loggerInfoSpy).toHaveBeenCalledWith( `Loaded config from: ${fixture.getPath('.ccusage/ccusage.json')}`, ); expect(loggerInfoSpy).toHaveBeenCalledWith( ' • Schema: https://ccusage.com/config-schema.json', ); expect(loggerInfoSpy).toHaveBeenCalledWith(' • Has defaults: yes (2 options)'); expect(loggerInfoSpy).toHaveBeenCalledWith(' • Has command configs: yes (daily)'); }); it('should log search paths when no config found with debug=true', async () => { await using fixture = await createFixture({ 'no-config-here': '', }); vi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath()); const config = loadConfig(undefined, true); expect(config).toBeUndefined(); expect(loggerInfoSpy).toHaveBeenCalledWith( 'Debug mode enabled - showing config loading details\n', ); expect(loggerInfoSpy).toHaveBeenCalledWith('Searching for config files:'); expect(loggerInfoSpy).toHaveBeenCalledWith(''); expect(loggerInfoSpy).toHaveBeenCalledWith('No valid configuration file found'); }); it('should log specific config file path when provided', async () => { await using fixture = await createFixture({ 'custom-config.json': JSON.stringify({ defaults: { debug: true }, }), }); const configPath = fixture.getPath('custom-config.json'); const config = loadConfig(configPath, true); expect(config).toBeDefined(); expect(loggerInfoSpy).toHaveBeenCalledWith( 'Debug mode enabled - showing config loading details\n', ); expect(loggerInfoSpy).toHaveBeenCalledWith('Using specified config file:'); expect(loggerInfoSpy).toHaveBeenCalledWith(` • Path: ${configPath}`); expect(loggerInfoSpy).toHaveBeenCalledWith(''); expect(loggerInfoSpy).toHaveBeenCalledWith(`Loaded config from: ${configPath}`); expect(loggerInfoSpy).toHaveBeenCalledWith(' • Schema: none'); expect(loggerInfoSpy).toHaveBeenCalledWith(' • Has defaults: yes (1 options)'); expect(loggerInfoSpy).toHaveBeenCalledWith(' • Has command configs: no'); }); }); describe('mergeConfigWithArgs with debug', () => { it('should log merge details with debug=true', () => { const config: ConfigData = { defaults: { mode: 'auto', offline: false, }, commands: { daily: { instances: true, project: 'test-project', }, }, }; const cliArgs = { debug: true, since: '20250101', }; const merged = mergeConfigWithArgs( { values: cliArgs, tokens: [ { kind: 'option', name: 'debug' }, { kind: 'option', name: 'since' }, ], name: 'daily', }, config, true, ); expect(merged).toEqual({ mode: 'auto', offline: false, instances: true, project: 'test-project', debug: true, since: '20250101', }); expect(loggerInfoSpy).toHaveBeenCalledWith(''); expect(loggerInfoSpy).toHaveBeenCalledWith(`Merging options for 'daily' command:`); expect(loggerInfoSpy).toHaveBeenCalledWith(' • From defaults: mode="auto", offline=false'); expect(loggerInfoSpy).toHaveBeenCalledWith( ' • From command config: instances=true, project="test-project"', ); expect(loggerInfoSpy).toHaveBeenCalledWith( ' • From CLI args: debug=true, since="20250101"', ); expect(loggerInfoSpy).toHaveBeenCalledWith(' • Final merged options: {'); }); it('should log no config message with debug=true when config is null', () => { const cliArgs = { json: true, debug: false }; const merged = mergeConfigWithArgs( { values: cliArgs, tokens: [ { kind: 'option', name: 'json' }, { kind: 'option', name: 'debug' }, ], name: 'daily', }, undefined, true, ); expect(merged).toEqual(cliArgs); expect(loggerInfoSpy).toHaveBeenCalledWith(''); expect(loggerInfoSpy).toHaveBeenCalledWith( `No config file loaded, using CLI args only for 'daily' command`, ); }); }); }); } ================================================ FILE: apps/ccusage/src/_consts.ts ================================================ import { homedir } from 'node:os'; import path from 'node:path'; import { xdgConfig } from 'xdg-basedir'; /** * Default number of recent days to include when filtering blocks * Used in both session blocks and commands for consistent behavior */ export const DEFAULT_RECENT_DAYS = 3; /** * Threshold percentage for showing usage warnings in blocks command (80%) * When usage exceeds this percentage of limits, warnings are displayed */ export const BLOCKS_WARNING_THRESHOLD = 0.8; /** * Terminal width threshold for switching to compact display mode in blocks command * Below this width, tables use more compact formatting */ export const BLOCKS_COMPACT_WIDTH_THRESHOLD = 120; /** * Default terminal width when stdout.columns is not available in blocks command * Used as fallback for responsive table formatting */ export const BLOCKS_DEFAULT_TERMINAL_WIDTH = 120; /** * Threshold percentage for considering costs as matching (0.1% tolerance) * Used in debug cost validation to allow for minor calculation differences */ export const DEBUG_MATCH_THRESHOLD_PERCENT = 0.1; /** * User's home directory path * Centralized access to OS home directory for consistent path building */ export const USER_HOME_DIR = homedir(); /** * XDG config directory path * Uses XDG_CONFIG_HOME if set, otherwise falls back to ~/.config */ const XDG_CONFIG_DIR = xdgConfig ?? path.join(USER_HOME_DIR, '.config'); /** * Default Claude data directory path (~/.claude) * Used as base path for loading usage data from JSONL files */ export const DEFAULT_CLAUDE_CODE_PATH = '.claude'; /** * Default Claude data directory path using XDG config directory * Uses XDG_CONFIG_HOME if set, otherwise falls back to ~/.config/claude */ export const DEFAULT_CLAUDE_CONFIG_PATH = path.join(XDG_CONFIG_DIR, 'claude'); /** * Environment variable for specifying multiple Claude data directories * Supports comma-separated paths for multiple locations */ export const CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR'; /** * Claude projects directory name within the data directory * Contains subdirectories for each project with usage data */ export const CLAUDE_PROJECTS_DIR_NAME = 'projects'; /** * JSONL file glob pattern for finding usage data files * Used to recursively find all JSONL files in project directories */ export const USAGE_DATA_GLOB_PATTERN = '**/*.jsonl'; /** * Default port for MCP server HTTP transport * Used when no port is specified for MCP server communication */ export const MCP_DEFAULT_PORT = 8080; /** * Default refresh interval in seconds for statusline cache expiry */ export const DEFAULT_REFRESH_INTERVAL_SECONDS = 1; /** * Context usage percentage thresholds for color coding */ export const DEFAULT_CONTEXT_USAGE_THRESHOLDS = { LOW: 50, // Below 50% - green MEDIUM: 80, // 50-80% - yellow // Above 80% - red } as const; /** * Days of the week for weekly aggregation */ export const WEEK_DAYS = [ 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', ] as const; /** * Week day names type */ export type WeekDay = (typeof WEEK_DAYS)[number]; /** * Day of week as number (0 = Sunday, 1 = Monday, ..., 6 = Saturday) */ export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; /** * Default configuration file name for storing usage data * Used to load and save configuration settings */ export const CONFIG_FILE_NAME = 'ccusage.json'; /** * Default locale for date formatting (en-CA provides YYYY-MM-DD ISO format) * Used consistently across the application for date parsing and display */ export const DEFAULT_LOCALE = 'en-CA'; ================================================ FILE: apps/ccusage/src/_daily-grouping.ts ================================================ import type { DailyProjectOutput } from './_json-output-types.ts'; import type { loadDailyUsageData } from './data-loader.ts'; import { createDailyDate, createModelName } from './_types.ts'; import { getTotalTokens } from './calculate-cost.ts'; /** * Type for daily data returned from loadDailyUsageData */ type DailyData = Awaited>; /** * Group daily usage data by project for JSON output */ export function groupByProject(dailyData: DailyData): Record { const projects: Record = {}; for (const data of dailyData) { const projectName = data.project ?? 'unknown'; if (projects[projectName] == null) { projects[projectName] = []; } projects[projectName].push({ date: data.date, inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalTokens: getTotalTokens(data), totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, }); } return projects; } /** * Group daily usage data by project for table display */ export function groupDataByProject(dailyData: DailyData): Record { const projects: Record = {}; for (const data of dailyData) { const projectName = data.project ?? 'unknown'; if (projects[projectName] == null) { projects[projectName] = []; } projects[projectName].push(data); } return projects; } if (import.meta.vitest != null) { describe('groupByProject', () => { it('groups daily data by project for JSON output', () => { const mockData = [ { date: createDailyDate('2024-01-01'), project: 'project-a', inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 100, cacheReadTokens: 200, totalCost: 0.01, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], }, { date: createDailyDate('2024-01-01'), project: 'project-b', inputTokens: 2000, outputTokens: 1000, cacheCreationTokens: 200, cacheReadTokens: 300, totalCost: 0.02, modelsUsed: [createModelName('claude-opus-4-20250514')], modelBreakdowns: [], }, ]; const result = groupByProject(mockData); expect(Object.keys(result)).toHaveLength(2); expect(result['project-a']).toHaveLength(1); expect(result['project-b']).toHaveLength(1); expect(result['project-a']![0]!.totalTokens).toBe(1800); expect(result['project-b']![0]!.totalTokens).toBe(3500); }); it('handles unknown project names', () => { const mockData = [ { date: createDailyDate('2024-01-01'), project: undefined, inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0.01, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], }, ]; const result = groupByProject(mockData); expect(Object.keys(result)).toHaveLength(1); expect(result.unknown).toHaveLength(1); }); }); describe('groupDataByProject', () => { it('groups daily data by project for table display', () => { const mockData = [ { date: createDailyDate('2024-01-01'), project: 'project-a', inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 100, cacheReadTokens: 200, totalCost: 0.01, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], }, { date: createDailyDate('2024-01-02'), project: 'project-a', inputTokens: 800, outputTokens: 400, cacheCreationTokens: 50, cacheReadTokens: 150, totalCost: 0.008, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], }, ]; const result = groupDataByProject(mockData); expect(Object.keys(result)).toHaveLength(1); expect(result['project-a']).toHaveLength(2); expect(result['project-a']).toEqual(mockData); }); }); } ================================================ FILE: apps/ccusage/src/_date-utils.ts ================================================ /** * Date utility functions for handling date formatting, filtering, and manipulation * @module date-utils */ import type { DayOfWeek, WeekDay } from './_consts.ts'; import type { WeeklyDate } from './_types.ts'; import { sort } from 'fast-sort'; import { DEFAULT_LOCALE } from './_consts.ts'; import { createWeeklyDate } from './_types.ts'; import { unreachable } from './_utils.ts'; // Re-export formatDateCompact from shared package export { formatDateCompact } from '@ccusage/terminal/table'; /** * Sort order for date-based sorting */ export type SortOrder = 'asc' | 'desc'; /** * Creates a date formatter with the specified timezone and locale * @param timezone - Timezone to use (e.g., 'UTC', 'America/New_York') * @param locale - Locale to use for formatting (e.g., 'en-US', 'ja-JP') * @returns Intl.DateTimeFormat instance */ function createDateFormatter(timezone: string | undefined, locale: string): Intl.DateTimeFormat { return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: timezone, }); } /** * Formats a date string to YYYY-MM-DD format * @param dateStr - Input date string * @param timezone - Optional timezone to use for formatting * @param locale - Optional locale to use for formatting (defaults to DEFAULT_LOCALE for YYYY-MM-DD format) * @returns Formatted date string in YYYY-MM-DD format */ export function formatDate(dateStr: string, timezone?: string, locale?: string): string { const date = new Date(dateStr); // Use DEFAULT_LOCALE as default for consistent YYYY-MM-DD format const formatter = createDateFormatter(timezone, locale ?? DEFAULT_LOCALE); return formatter.format(date); } /** * Generic function to sort items by date based on sort order * @param items - Array of items to sort * @param getDate - Function to extract date/timestamp from item * @param order - Sort order (asc or desc) * @returns Sorted array */ export function sortByDate( items: T[], getDate: (item: T) => string | Date, order: SortOrder = 'desc', ): T[] { const sorted = sort(items); switch (order) { case 'desc': return sorted.desc((item) => new Date(getDate(item)).getTime()); case 'asc': return sorted.asc((item) => new Date(getDate(item)).getTime()); default: unreachable(order); } } /** * Filters items by date range * @param items - Array of items to filter * @param getDate - Function to extract date string from item * @param since - Start date in any format (will be converted to YYYYMMDD for comparison) * @param until - End date in any format (will be converted to YYYYMMDD for comparison) * @returns Filtered array */ export function filterByDateRange( items: T[], getDate: (item: T) => string, since?: string, until?: string, ): T[] { if (since == null && until == null) { return items; } return items.filter((item) => { const dateStr = getDate(item).substring(0, 10).replace(/-/g, ''); // Convert to YYYYMMDD if (since != null && dateStr < since) { return false; } if (until != null && dateStr > until) { return false; } return true; }); } /** * Get the first day of the week for a given date * @param date - The date to get the week for * @param startDay - The day to start the week on (0 = Sunday, 1 = Monday, ..., 6 = Saturday) * @returns The date of the first day of the week for the given date */ export function getDateWeek(date: Date, startDay: DayOfWeek): WeeklyDate { const d = new Date(date); const day = d.getDay(); const shift = (day - startDay + 7) % 7; d.setDate(d.getDate() - shift); return createWeeklyDate(d.toISOString().substring(0, 10)); } /** * Convert day name to number (0 = Sunday, 1 = Monday, ..., 6 = Saturday) * @param day - Day name * @returns Day number */ export function getDayNumber(day: WeekDay): DayOfWeek { const dayMap = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, } as const satisfies Record; return dayMap[day]; } if (import.meta.vitest != null) { describe('formatDate', () => { it('should format date string to YYYY-MM-DD format', () => { const result = formatDate('2024-08-04T12:00:00Z'); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); it('should handle timezone parameter', () => { const result = formatDate('2024-08-04T12:00:00Z', 'UTC'); expect(result).toBe('2024-08-04'); }); it('should use default locale when locale is not provided', () => { const result = formatDate('2024-08-04T12:00:00Z'); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); it('should handle custom locale', () => { const result = formatDate('2024-08-04T12:00:00Z', 'UTC', 'en-US'); expect(result).toBe('08/04/2024'); }); }); // formatDateCompact tests are in @ccusage/terminal/table.ts describe('sortByDate', () => { const testData = [ { id: 1, date: '2024-01-01T10:00:00Z' }, { id: 2, date: '2024-01-03T10:00:00Z' }, { id: 3, date: '2024-01-02T10:00:00Z' }, ]; it('should sort by date in descending order by default', () => { const result = sortByDate(testData, (item) => item.date); expect(result.map((item) => item.id)).toEqual([2, 3, 1]); }); it('should sort by date in ascending order when specified', () => { const result = sortByDate(testData, (item) => item.date, 'asc'); expect(result.map((item) => item.id)).toEqual([1, 3, 2]); }); it('should sort by date in descending order when explicitly specified', () => { const result = sortByDate(testData, (item) => item.date, 'desc'); expect(result.map((item) => item.id)).toEqual([2, 3, 1]); }); it('should handle Date objects', () => { const dateData = [ { id: 1, date: new Date('2024-01-01T10:00:00Z') }, { id: 2, date: new Date('2024-01-03T10:00:00Z') }, { id: 3, date: new Date('2024-01-02T10:00:00Z') }, ]; const result = sortByDate(dateData, (item) => item.date); expect(result.map((item) => item.id)).toEqual([2, 3, 1]); }); }); describe('filterByDateRange', () => { const testData = [ { id: 1, date: '2024-01-01' }, { id: 2, date: '2024-01-02' }, { id: 3, date: '2024-01-03' }, { id: 4, date: '2024-01-04' }, { id: 5, date: '2024-01-05' }, ]; it('should return all items when no date filters are provided', () => { const result = filterByDateRange(testData, (item) => item.date); expect(result).toEqual(testData); }); it('should filter by since date', () => { const result = filterByDateRange(testData, (item) => item.date, '20240103'); expect(result.map((item) => item.id)).toEqual([3, 4, 5]); }); it('should filter by until date', () => { const result = filterByDateRange(testData, (item) => item.date, undefined, '20240103'); expect(result.map((item) => item.id)).toEqual([1, 2, 3]); }); it('should filter by both since and until dates', () => { const result = filterByDateRange(testData, (item) => item.date, '20240102', '20240104'); expect(result.map((item) => item.id)).toEqual([2, 3, 4]); }); it('should handle timestamp format dates', () => { const timestampData = [ { id: 1, date: '2024-01-01T10:00:00Z' }, { id: 2, date: '2024-01-02T10:00:00Z' }, { id: 3, date: '2024-01-03T10:00:00Z' }, ]; const result = filterByDateRange(timestampData, (item) => item.date, '20240102'); expect(result.map((item) => item.id)).toEqual([2, 3]); }); }); describe('getDateWeek', () => { it('should get the first day of week starting from Sunday', () => { const date = new Date('2024-01-03T10:00:00Z'); // Wednesday const result = getDateWeek(date, 0); // Sunday start expect(result).toBe(createWeeklyDate('2023-12-31')); // Previous Sunday }); it('should get the first day of week starting from Monday', () => { const date = new Date('2024-01-03T10:00:00Z'); // Wednesday const result = getDateWeek(date, 1); // Monday start expect(result).toBe(createWeeklyDate('2024-01-01')); // Monday of same week }); it('should handle when the date is already the start of the week', () => { const date = new Date('2024-01-01T10:00:00Z'); // Monday const result = getDateWeek(date, 1); // Monday start expect(result).toBe(createWeeklyDate('2024-01-01')); // Same Monday }); it('should handle Sunday as start of week when date is Sunday', () => { const date = new Date('2023-12-31T10:00:00Z'); // Sunday const result = getDateWeek(date, 0); // Sunday start expect(result).toBe(createWeeklyDate('2023-12-31')); // Same Sunday }); }); describe('getDayNumber', () => { it('should convert day names to correct numbers', () => { expect(getDayNumber('sunday')).toBe(0); expect(getDayNumber('monday')).toBe(1); expect(getDayNumber('tuesday')).toBe(2); expect(getDayNumber('wednesday')).toBe(3); expect(getDayNumber('thursday')).toBe(4); expect(getDayNumber('friday')).toBe(5); expect(getDayNumber('saturday')).toBe(6); }); }); } ================================================ FILE: apps/ccusage/src/_jq-processor.ts ================================================ import { Result } from '@praha/byethrow'; import spawn from 'nano-spawn'; /** * Process JSON data with a jq command * @param jsonData - The JSON data to process * @param jqCommand - The jq command/filter to apply * @returns The processed output from jq */ export async function processWithJq( jsonData: unknown, jqCommand: string, ): Result.ResultAsync { // Convert JSON data to string const jsonString = JSON.stringify(jsonData); // Use Result.try with object form to wrap spawn call const result = Result.try({ try: async () => { const spawnResult = await spawn('jq', [jqCommand], { stdin: { string: jsonString }, }); return spawnResult.output.trim(); }, catch: (error: unknown) => { if (error instanceof Error) { // Check if jq is not installed if (error.message.includes('ENOENT') || error.message.includes('not found')) { return new Error('jq command not found. Please install jq to use the --jq option.'); } // Return other errors (e.g., invalid jq syntax) return new Error(`jq processing failed: ${error.message}`); } return new Error('Unknown error during jq processing'); }, }); return result(); } // In-source tests if (import.meta.vitest != null) { describe('processWithJq', () => { it('should process JSON with simple filter', async () => { const data = { name: 'test', value: 42 }; const result = await processWithJq(data, '.name'); const unwrapped = Result.unwrap(result); expect(unwrapped).toBe('"test"'); }); it('should process JSON with complex filter', async () => { const data = { items: [ { id: 1, name: 'apple' }, { id: 2, name: 'banana' }, ], }; const result = await processWithJq(data, '.items | map(.name)'); const unwrapped = Result.unwrap(result); const parsed = JSON.parse(unwrapped) as string[]; expect(parsed).toEqual(['apple', 'banana']); }); it('should handle raw output', async () => { const data = { message: 'hello world' }; const result = await processWithJq(data, '.message | @text'); const unwrapped = Result.unwrap(result); expect(unwrapped).toBe('"hello world"'); }); it('should return error for invalid jq syntax', async () => { const data = { test: 'value' }; const result = await processWithJq(data, 'invalid syntax {'); const error = Result.unwrapError(result); expect(error.message).toContain('jq processing failed'); }); it('should handle complex jq operations', async () => { const data = { users: [ { name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }, { name: 'Charlie', age: 35 }, ], }; const result = await processWithJq(data, '.users | sort_by(.age) | .[0].name'); const unwrapped = Result.unwrap(result); expect(unwrapped).toBe('"Bob"'); }); it('should handle numeric output', async () => { const data = { values: [1, 2, 3, 4, 5] }; const result = await processWithJq(data, '.values | add'); const unwrapped = Result.unwrap(result); expect(unwrapped).toBe('15'); }); }); } ================================================ FILE: apps/ccusage/src/_json-output-types.ts ================================================ /** * @fileoverview JSON output interface types for daily command groupByProject function * * This module provides TypeScript interfaces for the JSON output structure * used by the daily command's groupByProject function, replacing the * unsafe Record type with proper type definitions. * * @module json-output-types */ import type { DailyDate, ModelName } from './_types.ts'; import type { ModelBreakdown } from './data-loader.ts'; /** * Interface for daily command JSON output structure (groupByProject) * Used in src/commands/daily.ts */ export type DailyProjectOutput = { date: DailyDate; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; totalCost: number; modelsUsed: ModelName[]; modelBreakdowns: ModelBreakdown[]; }; ================================================ FILE: apps/ccusage/src/_macro.ts ================================================ import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; import { createPricingDataset, fetchLiteLLMPricingDataset, filterPricingDataset, } from '@ccusage/internal/pricing-fetch-utils'; function isClaudeModel(modelName: string, _pricing: LiteLLMModelPricing): boolean { return ( modelName.startsWith('claude-') || modelName.startsWith('anthropic.claude-') || modelName.startsWith('anthropic/claude-') ); } export async function prefetchClaudePricing(): Promise> { try { const dataset = await fetchLiteLLMPricingDataset(); return filterPricingDataset(dataset, isClaudeModel); } catch (error) { console.warn('Failed to prefetch Claude pricing data, proceeding with empty cache.', error); return createPricingDataset(); } } ================================================ FILE: apps/ccusage/src/_pricing-fetcher.ts ================================================ import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import { Result } from '@praha/byethrow'; import { prefetchClaudePricing } from './_macro.ts' with { type: 'macro' }; import { logger } from './logger.ts'; const CLAUDE_PROVIDER_PREFIXES = [ 'anthropic/', 'claude-3-5-', 'claude-3-', 'claude-', 'openrouter/openai/', ]; const PREFETCHED_CLAUDE_PRICING = prefetchClaudePricing(); export class PricingFetcher extends LiteLLMPricingFetcher { constructor(offline = false) { super({ offline, offlineLoader: async () => PREFETCHED_CLAUDE_PRICING, logger, providerPrefixes: CLAUDE_PROVIDER_PREFIXES, }); } } if (import.meta.vitest != null) { describe('PricingFetcher', () => { it('loads offline pricing when offline flag is true', async () => { using fetcher = new PricingFetcher(true); const pricing = await Result.unwrap(fetcher.fetchModelPricing()); expect(pricing.size).toBeGreaterThan(0); }); it('calculates cost for Claude model tokens', async () => { using fetcher = new PricingFetcher(true); const pricing = await Result.unwrap(fetcher.getModelPricing('claude-sonnet-4-20250514')); const cost = fetcher.calculateCostFromPricing( { input_tokens: 1000, output_tokens: 500, cache_read_input_tokens: 300, }, pricing!, ); expect(cost).toBeGreaterThan(0); }); }); } ================================================ FILE: apps/ccusage/src/_project-names.ts ================================================ /** * @fileoverview Project name formatting and alias utilities * * Provides utilities for formatting raw project directory names into user-friendly * display names with support for custom aliases and improved path parsing. * * @module project-names */ /** * Extract meaningful project name from directory-style project paths * Uses improved heuristics to handle complex project structures * * @param projectName - Raw project name from directory path * @returns Cleaned and formatted project name * * @example * ```typescript * // Basic cleanup * parseProjectName('-Users-phaedrus-Development-ccusage') * // → 'ccusage' * * // Complex project with feature branch * parseProjectName('-Users-phaedrus-Development-adminifi-edugakko-api--feature-ticket-002-configure-dependabot') * // → 'configure-dependabot' * * // Handle unknown projects * parseProjectName('unknown') * // → 'Unknown Project' * ``` */ function parseProjectName(projectName: string): string { if (projectName === 'unknown' || projectName === '') { return 'Unknown Project'; } // Remove common directory prefixes let cleaned = projectName; // Handle Windows-style paths: C:\Users\... or \Users\... if (cleaned.match(/^[A-Z]:\\Users\\|^\\Users\\/) != null) { const segments = cleaned.split('\\'); const userIndex = segments.findIndex((seg) => seg === 'Users'); if (userIndex !== -1 && userIndex + 3 < segments.length) { // Take everything after Users/username/Projects or similar cleaned = segments.slice(userIndex + 3).join('-'); } } // Handle Unix-style paths: /Users/... or -Users-... if (cleaned.startsWith('-Users-') || cleaned.startsWith('/Users/')) { const separator = cleaned.startsWith('-Users-') ? '-' : '/'; const segments = cleaned.split(separator).filter((s) => s.length > 0); const userIndex = segments.findIndex((seg) => seg === 'Users'); if (userIndex !== -1 && userIndex + 3 < segments.length) { // Take everything after Users/username/Development or similar cleaned = segments.slice(userIndex + 3).join('-'); } } // If no path cleanup occurred, use original name if (cleaned === projectName) { // Just basic cleanup for non-path names cleaned = projectName.replace(/^[/\\-]+|[/\\-]+$/g, ''); } // Handle UUID-like patterns if (cleaned.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i) != null) { // Extract last two segments of UUID for brevity const parts = cleaned.split('-'); if (parts.length >= 5) { // Take the last two segments, which may include file extension in the last segment cleaned = parts.slice(-2).join('-'); } } // Improved project name extraction for complex names if (cleaned.includes('--')) { // Handle project--feature patterns like "adminifi-edugakko-api--feature-ticket-002" const parts = cleaned.split('--'); if (parts.length >= 2 && parts[0] != null) { // Take the main project part before the first -- cleaned = parts[0]; } } // For compound project names, try to extract the most meaningful part if (cleaned.includes('-') && cleaned.length > 20) { const segments = cleaned.split('-'); // Look for common meaningful patterns const meaningfulSegments = segments.filter( (seg) => seg.length > 2 && seg.match( /^(?:dev|development|feat|feature|fix|bug|test|staging|prod|production|main|master|branch)$/i, ) == null, ); // If we have compound project names like "adminifi-edugakko-api" // Try to find the last 2-3 meaningful segments if (meaningfulSegments.length >= 2) { // Take last 2-3 segments to get "edugakko-api" from "adminifi-edugakko-api" const lastSegments = meaningfulSegments.slice(-2); if (lastSegments.join('-').length >= 6) { cleaned = lastSegments.join('-'); } else if (meaningfulSegments.length >= 3) { cleaned = meaningfulSegments.slice(-3).join('-'); } } } // Final cleanup cleaned = cleaned.replace(/^[/\\-]+|[/\\-]+$/g, ''); return cleaned !== '' ? cleaned : projectName !== '' ? projectName : 'Unknown Project'; } /** * Format project name for display with custom alias support * * @param projectName - Raw project name from directory path * @param aliases - Optional map of project names to their aliases * @returns User-friendly project name with alias support * * @example * ```typescript * // Without aliases * formatProjectName('-Users-phaedrus-Development-ccusage') * // → 'ccusage' * * // With alias * const aliases = new Map([['ccusage', 'Usage Tracker']]); * formatProjectName('-Users-phaedrus-Development-ccusage', aliases) * // → 'Usage Tracker' * ``` */ export function formatProjectName(projectName: string, aliases?: Map): string { // Check for custom alias first if (aliases != null && aliases.has(projectName)) { return aliases.get(projectName)!; } // Parse the project name using improved logic const parsed = parseProjectName(projectName); // Check if parsed name has an alias if (aliases != null && aliases.has(parsed)) { return aliases.get(parsed)!; } return parsed; } if (import.meta.vitest != null) { const { describe, it, expect } = import.meta.vitest; describe('project name formatting', () => { describe('parseProjectName', () => { it('handles unknown project names', () => { expect(formatProjectName('unknown')).toBe('Unknown Project'); expect(formatProjectName('')).toBe('Unknown Project'); }); it('extracts project names from Unix-style paths', () => { expect(formatProjectName('-Users-phaedrus-Development-ccusage')).toBe('ccusage'); expect(formatProjectName('/Users/phaedrus/Development/ccusage')).toBe('ccusage'); }); it('handles complex project names with features', () => { const complexName = '-Users-phaedrus-Development-adminifi-edugakko-api--feature-ticket-002-configure-dependabot'; const result = formatProjectName(complexName); // Current logic processes the name and extracts meaningful segments expect(result).toBe('configure-dependabot'); }); it('handles UUID-based project names', () => { const uuidName = 'a2cd99ed-a586-4fe4-8f59-b0026409ec09.jsonl'; const result = formatProjectName(uuidName); expect(result).toBe('8f59-b0026409ec09.jsonl'); }); it('returns original name for simple names', () => { expect(formatProjectName('simple-project')).toBe('simple-project'); expect(formatProjectName('project')).toBe('project'); }); }); describe('custom aliases', () => { it('uses configured aliases', () => { const aliases = new Map([ ['ccusage', 'Usage Tracker'], ['test', 'Test Project'], ]); expect(formatProjectName('ccusage', aliases)).toBe('Usage Tracker'); expect(formatProjectName('test', aliases)).toBe('Test Project'); expect(formatProjectName('other', aliases)).toBe('other'); }); it('applies aliases to parsed project names', () => { const aliases = new Map([['ccusage', 'Usage Tracker']]); expect(formatProjectName('-Users-phaedrus-Development-ccusage', aliases)).toBe( 'Usage Tracker', ); }); it('works without aliases', () => { expect(formatProjectName('test')).toBe('test'); expect(formatProjectName('test', undefined)).toBe('test'); expect(formatProjectName('test', new Map())).toBe('test'); }); }); }); } ================================================ FILE: apps/ccusage/src/_session-blocks.ts ================================================ import { uniq } from 'es-toolkit'; import { DEFAULT_RECENT_DAYS } from './_consts.ts'; import { getTotalTokens } from './_token-utils.ts'; /** * Default session duration in hours (Claude's billing block duration) */ export const DEFAULT_SESSION_DURATION_HOURS = 5; /** * Floors a timestamp to the beginning of the hour in UTC * @param timestamp - The timestamp to floor * @returns New Date object floored to the UTC hour */ function floorToHour(timestamp: Date): Date { const floored = new Date(timestamp); floored.setUTCMinutes(0, 0, 0); return floored; } /** * Represents a single usage data entry loaded from JSONL files */ export type LoadedUsageEntry = { timestamp: Date; usage: { inputTokens: number; outputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; }; costUSD: number | null; model: string; version?: string; usageLimitResetTime?: Date; // Claude API usage limit reset time }; /** * Aggregated token counts for different token types */ type TokenCounts = { inputTokens: number; outputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; }; /** * Represents a session block (typically 5-hour billing period) with usage data */ export type SessionBlock = { id: string; // ISO string of block start time startTime: Date; endTime: Date; // startTime + 5 hours (for normal blocks) or gap end time (for gap blocks) actualEndTime?: Date; // Last activity in block isActive: boolean; isGap?: boolean; // True if this is a gap block entries: LoadedUsageEntry[]; tokenCounts: TokenCounts; costUSD: number; models: string[]; usageLimitResetTime?: Date; // Claude API usage limit reset time }; /** * Represents usage burn rate calculations */ type BurnRate = { tokensPerMinute: number; tokensPerMinuteForIndicator: number; costPerHour: number; }; /** * Represents projected usage for remaining time in a session block */ type ProjectedUsage = { totalTokens: number; totalCost: number; remainingMinutes: number; }; /** * Identifies and creates session blocks from usage entries * Groups entries into time-based blocks (typically 5-hour periods) with gap detection * @param entries - Array of usage entries to process * @param sessionDurationHours - Duration of each session block in hours * @returns Array of session blocks with aggregated usage data */ export function identifySessionBlocks( entries: LoadedUsageEntry[], sessionDurationHours = DEFAULT_SESSION_DURATION_HOURS, ): SessionBlock[] { if (entries.length === 0) { return []; } const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; const blocks: SessionBlock[] = []; const sortedEntries = [...entries].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); let currentBlockStart: Date | null = null; let currentBlockEntries: LoadedUsageEntry[] = []; const now = new Date(); for (const entry of sortedEntries) { const entryTime = entry.timestamp; if (currentBlockStart == null) { // First entry - start a new block (floored to the hour) currentBlockStart = floorToHour(entryTime); currentBlockEntries = [entry]; } else { const timeSinceBlockStart = entryTime.getTime() - currentBlockStart.getTime(); const lastEntry = currentBlockEntries.at(-1); if (lastEntry == null) { continue; } const lastEntryTime = lastEntry.timestamp; const timeSinceLastEntry = entryTime.getTime() - lastEntryTime.getTime(); if (timeSinceBlockStart > sessionDurationMs || timeSinceLastEntry > sessionDurationMs) { // Close current block const block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs); blocks.push(block); // Add gap block if there's a significant gap if (timeSinceLastEntry > sessionDurationMs) { const gapBlock = createGapBlock(lastEntryTime, entryTime, sessionDurationMs); if (gapBlock != null) { blocks.push(gapBlock); } } // Start new block (floored to the hour) currentBlockStart = floorToHour(entryTime); currentBlockEntries = [entry]; } else { // Add to current block currentBlockEntries.push(entry); } } } // Close the last block if (currentBlockStart != null && currentBlockEntries.length > 0) { const block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs); blocks.push(block); } return blocks; } /** * Creates a session block from a start time and usage entries * @param startTime - When the block started * @param entries - Usage entries in this block * @param now - Current time for active block detection * @param sessionDurationMs - Session duration in milliseconds * @returns Session block with aggregated data */ function createBlock( startTime: Date, entries: LoadedUsageEntry[], now: Date, sessionDurationMs: number, ): SessionBlock { const endTime = new Date(startTime.getTime() + sessionDurationMs); const lastEntry = entries[entries.length - 1]; const actualEndTime = lastEntry != null ? lastEntry.timestamp : startTime; const isActive = now.getTime() - actualEndTime.getTime() < sessionDurationMs && now < endTime; // Aggregate token counts const tokenCounts: TokenCounts = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }; let costUSD = 0; const models: string[] = []; let usageLimitResetTime: Date | undefined; for (const entry of entries) { tokenCounts.inputTokens += entry.usage.inputTokens; tokenCounts.outputTokens += entry.usage.outputTokens; tokenCounts.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens; tokenCounts.cacheReadInputTokens += entry.usage.cacheReadInputTokens; costUSD += entry.costUSD ?? 0; usageLimitResetTime = entry.usageLimitResetTime ?? usageLimitResetTime; models.push(entry.model); } return { id: startTime.toISOString(), startTime, endTime, actualEndTime, isActive, entries, tokenCounts, costUSD, models: uniq(models), usageLimitResetTime, }; } /** * Creates a gap block representing periods with no activity * @param lastActivityTime - Time of last activity before gap * @param nextActivityTime - Time of next activity after gap * @param sessionDurationMs - Session duration in milliseconds * @returns Gap block or null if gap is too short */ function createGapBlock( lastActivityTime: Date, nextActivityTime: Date, sessionDurationMs: number, ): SessionBlock | null { // Only create gap blocks for gaps longer than the session duration const gapDuration = nextActivityTime.getTime() - lastActivityTime.getTime(); if (gapDuration <= sessionDurationMs) { return null; } const gapStart = new Date(lastActivityTime.getTime() + sessionDurationMs); const gapEnd = nextActivityTime; return { id: `gap-${gapStart.toISOString()}`, startTime: gapStart, endTime: gapEnd, isActive: false, isGap: true, entries: [], tokenCounts: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0, models: [], }; } /** * Calculates the burn rate (tokens/minute and cost/hour) for a session block * @param block - Session block to analyze * @returns Burn rate calculations or null if block has no activity */ export function calculateBurnRate(block: SessionBlock): BurnRate | null { if (block.entries.length === 0 || (block.isGap ?? false)) { return null; } const firstEntryData = block.entries[0]; const lastEntryData = block.entries[block.entries.length - 1]; if (firstEntryData == null || lastEntryData == null) { return null; } const firstEntry = firstEntryData.timestamp; const lastEntry = lastEntryData.timestamp; const durationMinutes = (lastEntry.getTime() - firstEntry.getTime()) / (1000 * 60); if (durationMinutes <= 0) { return null; } const totalTokens = getTotalTokens(block.tokenCounts); const tokensPerMinute = totalTokens / durationMinutes; // For burn rate indicator (HIGH/MODERATE/NORMAL), use only input and output tokens // to maintain consistent thresholds with pre-cache behavior const nonCacheTokens = (block.tokenCounts.inputTokens ?? 0) + (block.tokenCounts.outputTokens ?? 0); const tokensPerMinuteForIndicator = nonCacheTokens / durationMinutes; const costPerHour = (block.costUSD / durationMinutes) * 60; return { tokensPerMinute, tokensPerMinuteForIndicator, costPerHour, }; } /** * Projects total usage for an active session block based on current burn rate * @param block - Active session block to project * @returns Projected usage totals or null if block is inactive or has no burn rate */ export function projectBlockUsage(block: SessionBlock): ProjectedUsage | null { if (!block.isActive || (block.isGap ?? false)) { return null; } const burnRate = calculateBurnRate(block); if (burnRate == null) { return null; } const now = new Date(); const remainingTime = block.endTime.getTime() - now.getTime(); const remainingMinutes = Math.max(0, remainingTime / (1000 * 60)); const currentTokens = getTotalTokens(block.tokenCounts); const projectedAdditionalTokens = burnRate.tokensPerMinute * remainingMinutes; const totalTokens = currentTokens + projectedAdditionalTokens; const projectedAdditionalCost = (burnRate.costPerHour / 60) * remainingMinutes; const totalCost = block.costUSD + projectedAdditionalCost; return { totalTokens: Math.round(totalTokens), totalCost: Math.round(totalCost * 100) / 100, remainingMinutes: Math.round(remainingMinutes), }; } /** * Filters session blocks to include only recent ones and active blocks * @param blocks - Array of session blocks to filter * @param days - Number of recent days to include (default: 3) * @returns Filtered array of recent or active blocks */ export function filterRecentBlocks( blocks: SessionBlock[], days: number = DEFAULT_RECENT_DAYS, ): SessionBlock[] { const now = new Date(); const cutoffTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); return blocks.filter((block) => { // Include block if it started after cutoff or if it's still active return block.startTime >= cutoffTime || block.isActive; }); } if (import.meta.vitest != null) { const SESSION_DURATION_MS = 5 * 60 * 60 * 1000; function createMockEntry( timestamp: Date, inputTokens = 1000, outputTokens = 500, model = 'claude-sonnet-4-20250514', costUSD = 0.01, ): LoadedUsageEntry { return { timestamp, usage: { inputTokens, outputTokens, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD, model, }; } describe('identifySessionBlocks', () => { it('returns empty array for empty entries', () => { const result = identifySessionBlocks([]); expect(result).toEqual([]); }); it('creates single block for entries within 5 hours', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000)), // 1 hour later createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later ]; const blocks = identifySessionBlocks(entries); expect(blocks).toHaveLength(1); expect(blocks[0]?.startTime).toEqual(baseTime); expect(blocks[0]?.entries).toHaveLength(3); expect(blocks[0]?.tokenCounts.inputTokens).toBe(3000); expect(blocks[0]?.tokenCounts.outputTokens).toBe(1500); expect(blocks[0]?.costUSD).toBe(0.03); }); it('creates multiple blocks when entries span more than 5 hours', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 6 * 60 * 60 * 1000)), // 6 hours later ]; const blocks = identifySessionBlocks(entries); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(1); expect(blocks[1]?.isGap).toBe(true); // gap block expect(blocks[2]?.entries).toHaveLength(1); }); it('creates gap block when there is a gap longer than 5 hours', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later createMockEntry(new Date(baseTime.getTime() + 8 * 60 * 60 * 1000)), // 8 hours later ]; const blocks = identifySessionBlocks(entries); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(2); expect(blocks[1]?.isGap).toBe(true); expect(blocks[1]?.entries).toHaveLength(0); expect(blocks[2]?.entries).toHaveLength(1); }); it('sorts entries by timestamp before processing', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later createMockEntry(baseTime), // earlier createMockEntry(new Date(baseTime.getTime() + 1 * 60 * 60 * 1000)), // 1 hour later ]; const blocks = identifySessionBlocks(entries); expect(blocks).toHaveLength(1); expect(blocks[0]?.entries[0]?.timestamp).toEqual(baseTime); expect(blocks[0]?.entries[1]?.timestamp).toEqual( new Date(baseTime.getTime() + 1 * 60 * 60 * 1000), ); expect(blocks[0]?.entries[2]?.timestamp).toEqual( new Date(baseTime.getTime() + 2 * 60 * 60 * 1000), ); }); it('aggregates different models correctly', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514'), createMockEntry( new Date(baseTime.getTime() + 60 * 60 * 1000), 2000, 1000, 'claude-opus-4-20250514', ), ]; const blocks = identifySessionBlocks(entries); expect(blocks).toHaveLength(1); expect(blocks[0]?.models).toEqual(['claude-sonnet-4-20250514', 'claude-opus-4-20250514']); }); it('handles null costUSD correctly', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514', 0.01), { ...createMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000)), costUSD: null }, ]; const blocks = identifySessionBlocks(entries); expect(blocks).toHaveLength(1); expect(blocks[0]?.costUSD).toBe(0.01); // Only the first entry's cost }); it('sets correct block ID as ISO string', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [createMockEntry(baseTime)]; const blocks = identifySessionBlocks(entries); expect(blocks[0]?.id).toBe(baseTime.toISOString()); }); it('sets correct endTime as startTime + 5 hours', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [createMockEntry(baseTime)]; const blocks = identifySessionBlocks(entries); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + SESSION_DURATION_MS)); }); it('handles cache tokens correctly', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entry: LoadedUsageEntry = { timestamp: baseTime, usage: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 100, cacheReadInputTokens: 200, }, costUSD: 0.01, model: 'claude-sonnet-4-20250514', }; const blocks = identifySessionBlocks([entry]); expect(blocks[0]?.tokenCounts.cacheCreationInputTokens).toBe(100); expect(blocks[0]?.tokenCounts.cacheReadInputTokens).toBe(200); }); it('floors block start time to nearest hour', () => { const entryTime = new Date('2024-01-01T10:55:30Z'); // 10:55:30 AM const expectedStartTime = new Date('2024-01-01T10:00:00Z'); // Should floor to 10:00:00 AM const entries: LoadedUsageEntry[] = [createMockEntry(entryTime)]; const blocks = identifySessionBlocks(entries); expect(blocks).toHaveLength(1); expect(blocks[0]?.startTime).toEqual(expectedStartTime); expect(blocks[0]?.id).toBe(expectedStartTime.toISOString()); }); }); describe('calculateBurnRate', () => { it('returns null for empty entries', () => { const block: SessionBlock = { id: '2024-01-01T10:00:00.000Z', startTime: new Date('2024-01-01T10:00:00Z'), endTime: new Date('2024-01-01T15:00:00Z'), isActive: true, entries: [], tokenCounts: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0, models: [], }; const result = calculateBurnRate(block); expect(result).toBeNull(); }); it('returns null for gap blocks', () => { const block: SessionBlock = { id: 'gap-2024-01-01T10:00:00.000Z', startTime: new Date('2024-01-01T10:00:00Z'), endTime: new Date('2024-01-01T15:00:00Z'), isActive: false, isGap: true, entries: [], tokenCounts: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0, models: [], }; const result = calculateBurnRate(block); expect(result).toBeNull(); }); it('returns null when duration is zero or negative', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const block: SessionBlock = { id: baseTime.toISOString(), startTime: baseTime, endTime: new Date(baseTime.getTime() + SESSION_DURATION_MS), isActive: true, entries: [ createMockEntry(baseTime), createMockEntry(baseTime), // Same timestamp ], tokenCounts: { inputTokens: 2000, outputTokens: 1000, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.02, models: ['claude-sonnet-4-20250514'], }; const result = calculateBurnRate(block); expect(result).toBeNull(); }); it('calculates burn rate correctly', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const laterTime = new Date(baseTime.getTime() + 60 * 1000); // 1 minute later const block: SessionBlock = { id: baseTime.toISOString(), startTime: baseTime, endTime: new Date(baseTime.getTime() + SESSION_DURATION_MS), isActive: true, entries: [ createMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514', 0.01), createMockEntry(laterTime, 2000, 1000, 'claude-sonnet-4-20250514', 0.02), ], tokenCounts: { inputTokens: 3000, outputTokens: 1500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.03, models: ['claude-sonnet-4-20250514'], }; const result = calculateBurnRate(block); expect(result).not.toBeNull(); expect(result?.tokensPerMinute).toBe(4500); // 4500 tokens / 1 minute (includes all tokens) expect(result?.tokensPerMinuteForIndicator).toBe(4500); // 4500 tokens / 1 minute (non-cache only) expect(result?.costPerHour).toBeCloseTo(1.8, 2); // 0.03 / 1 minute * 60 minutes }); it('correctly separates cache and non-cache tokens in burn rate calculation', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const block: SessionBlock = { id: baseTime.toISOString(), startTime: baseTime, endTime: new Date(baseTime.getTime() + SESSION_DURATION_MS), isActive: true, entries: [ { timestamp: baseTime, usage: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.01, model: 'claude-sonnet-4-20250514', }, { timestamp: new Date(baseTime.getTime() + 60 * 1000), usage: { inputTokens: 500, outputTokens: 200, cacheCreationInputTokens: 2000, cacheReadInputTokens: 8000, }, costUSD: 0.02, model: 'claude-sonnet-4-20250514', }, ], tokenCounts: { inputTokens: 1500, outputTokens: 700, cacheCreationInputTokens: 2000, cacheReadInputTokens: 8000, }, costUSD: 0.03, models: ['claude-sonnet-4-20250514'], }; const result = calculateBurnRate(block); expect(result).not.toBeNull(); expect(result?.tokensPerMinute).toBe(12200); // 1500 + 700 + 2000 + 8000 = 12200 tokens / 1 minute expect(result?.tokensPerMinuteForIndicator).toBe(2200); // 1500 + 700 = 2200 tokens / 1 minute (non-cache only) expect(result?.costPerHour).toBeCloseTo(1.8, 2); // 0.03 / 1 minute * 60 minutes }); }); describe('projectBlockUsage', () => { it('returns null for inactive blocks', () => { const block: SessionBlock = { id: '2024-01-01T10:00:00.000Z', startTime: new Date('2024-01-01T10:00:00Z'), endTime: new Date('2024-01-01T15:00:00Z'), isActive: false, entries: [], tokenCounts: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.01, models: [], }; const result = projectBlockUsage(block); expect(result).toBeNull(); }); it('returns null for gap blocks', () => { const block: SessionBlock = { id: 'gap-2024-01-01T10:00:00.000Z', startTime: new Date('2024-01-01T10:00:00Z'), endTime: new Date('2024-01-01T15:00:00Z'), isActive: true, isGap: true, entries: [], tokenCounts: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0, models: [], }; const result = projectBlockUsage(block); expect(result).toBeNull(); }); it('returns null when burn rate cannot be calculated', () => { const block: SessionBlock = { id: '2024-01-01T10:00:00.000Z', startTime: new Date('2024-01-01T10:00:00Z'), endTime: new Date('2024-01-01T15:00:00Z'), isActive: true, entries: [], // Empty entries tokenCounts: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.01, models: [], }; const result = projectBlockUsage(block); expect(result).toBeNull(); }); it('projects usage correctly for active block', () => { const now = new Date(); const startTime = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago const endTime = new Date(startTime.getTime() + SESSION_DURATION_MS); const pastTime = new Date(startTime.getTime() + 30 * 60 * 1000); // 30 minutes after start const block: SessionBlock = { id: startTime.toISOString(), startTime, endTime, isActive: true, entries: [ createMockEntry(startTime, 1000, 500, 'claude-sonnet-4-20250514', 0.01), createMockEntry(pastTime, 2000, 1000, 'claude-sonnet-4-20250514', 0.02), ], tokenCounts: { inputTokens: 3000, outputTokens: 1500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.03, models: ['claude-sonnet-4-20250514'], }; const result = projectBlockUsage(block); expect(result).not.toBeNull(); expect(result?.totalTokens).toBeGreaterThan(4500); // Current tokens + projected expect(result?.totalCost).toBeGreaterThan(0.03); // Current cost + projected expect(result?.remainingMinutes).toBeGreaterThan(0); }); }); describe('filterRecentBlocks', () => { it('filters blocks correctly with default 3 days', () => { const now = new Date(); const recentTime = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); // 2 days ago const oldTime = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago const blocks: SessionBlock[] = [ { id: recentTime.toISOString(), startTime: recentTime, endTime: new Date(recentTime.getTime() + SESSION_DURATION_MS), isActive: false, entries: [], tokenCounts: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.01, models: [], }, { id: oldTime.toISOString(), startTime: oldTime, endTime: new Date(oldTime.getTime() + SESSION_DURATION_MS), isActive: false, entries: [], tokenCounts: { inputTokens: 2000, outputTokens: 1000, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.02, models: [], }, ]; const result = filterRecentBlocks(blocks); expect(result).toHaveLength(1); expect(result[0]?.startTime).toEqual(recentTime); }); it('includes active blocks regardless of age', () => { const now = new Date(); const oldTime = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); // 10 days ago const blocks: SessionBlock[] = [ { id: oldTime.toISOString(), startTime: oldTime, endTime: new Date(oldTime.getTime() + SESSION_DURATION_MS), isActive: true, // Active block entries: [], tokenCounts: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.01, models: [], }, ]; const result = filterRecentBlocks(blocks); expect(result).toHaveLength(1); expect(result[0]?.isActive).toBe(true); }); it('supports custom days parameter', () => { const now = new Date(); const withinCustomRange = new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000); // 4 days ago const outsideCustomRange = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000); // 8 days ago const blocks: SessionBlock[] = [ { id: withinCustomRange.toISOString(), startTime: withinCustomRange, endTime: new Date(withinCustomRange.getTime() + SESSION_DURATION_MS), isActive: false, entries: [], tokenCounts: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.01, models: [], }, { id: outsideCustomRange.toISOString(), startTime: outsideCustomRange, endTime: new Date(outsideCustomRange.getTime() + SESSION_DURATION_MS), isActive: false, entries: [], tokenCounts: { inputTokens: 2000, outputTokens: 1000, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.02, models: [], }, ]; const result = filterRecentBlocks(blocks, 7); // 7 days expect(result).toHaveLength(1); expect(result[0]?.startTime).toEqual(withinCustomRange); }); it('returns empty array when no blocks match criteria', () => { const now = new Date(); const oldTime = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); // 10 days ago const blocks: SessionBlock[] = [ { id: oldTime.toISOString(), startTime: oldTime, endTime: new Date(oldTime.getTime() + SESSION_DURATION_MS), isActive: false, entries: [], tokenCounts: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, costUSD: 0.01, models: [], }, ]; const result = filterRecentBlocks(blocks, 3); expect(result).toHaveLength(0); }); }); describe('identifySessionBlocks with configurable duration', () => { it('creates single block for entries within custom 3-hour duration', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000)), // 1 hour later createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later ]; const blocks = identifySessionBlocks(entries, 3); expect(blocks).toHaveLength(1); expect(blocks[0]?.startTime).toEqual(baseTime); expect(blocks[0]?.entries).toHaveLength(3); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 3 * 60 * 60 * 1000)); }); it('creates multiple blocks with custom 2-hour duration', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 3 * 60 * 60 * 1000)), // 3 hours later (beyond 2h limit) ]; const blocks = identifySessionBlocks(entries, 2); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(1); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)); expect(blocks[1]?.isGap).toBe(true); // gap block expect(blocks[2]?.entries).toHaveLength(1); }); it('creates gap block with custom 1-hour duration', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 30 * 60 * 1000)), // 30 minutes later (within 1h) createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later (beyond 1h) ]; const blocks = identifySessionBlocks(entries, 1); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(2); expect(blocks[1]?.isGap).toBe(true); expect(blocks[2]?.entries).toHaveLength(1); }); it('works with fractional hours (2.5 hours)', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later (within 2.5h) createMockEntry(new Date(baseTime.getTime() + 6 * 60 * 60 * 1000)), // 6 hours later (4 hours from last entry, beyond 2.5h) ]; const blocks = identifySessionBlocks(entries, 2.5); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(2); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 2.5 * 60 * 60 * 1000)); expect(blocks[1]?.isGap).toBe(true); expect(blocks[2]?.entries).toHaveLength(1); }); it('works with very short duration (0.5 hours)', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 20 * 60 * 1000)), // 20 minutes later (within 0.5h) createMockEntry(new Date(baseTime.getTime() + 80 * 60 * 1000)), // 80 minutes later (60 minutes from last entry, beyond 0.5h) ]; const blocks = identifySessionBlocks(entries, 0.5); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(2); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 0.5 * 60 * 60 * 1000)); expect(blocks[1]?.isGap).toBe(true); expect(blocks[2]?.entries).toHaveLength(1); }); it('works with very long duration (24 hours)', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 12 * 60 * 60 * 1000)), // 12 hours later (within 24h) createMockEntry(new Date(baseTime.getTime() + 20 * 60 * 60 * 1000)), // 20 hours later (within 24h) ]; const blocks = identifySessionBlocks(entries, 24); expect(blocks).toHaveLength(1); // single block expect(blocks[0]?.entries).toHaveLength(3); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 24 * 60 * 60 * 1000)); }); it('gap detection respects custom duration', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 1 * 60 * 60 * 1000)), // 1 hour later createMockEntry(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)), // 5 hours later (4h from last entry, beyond 3h) ]; const blocks = identifySessionBlocks(entries, 3); expect(blocks).toHaveLength(3); // first block, gap block, second block // Gap block should start 3 hours after last activity in first block const gapBlock = blocks[1]; expect(gapBlock?.isGap).toBe(true); expect(gapBlock?.startTime).toEqual( new Date(baseTime.getTime() + 1 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000), ); // 1h + 3h expect(gapBlock?.endTime).toEqual(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)); // 5h }); it('no gap created when gap is exactly equal to session duration', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [ createMockEntry(baseTime), createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // exactly 2 hours later (equal to session duration) ]; const blocks = identifySessionBlocks(entries, 2); expect(blocks).toHaveLength(1); // single block (entries are exactly at session boundary) expect(blocks[0]?.entries).toHaveLength(2); }); it('defaults to 5 hours when no duration specified', () => { const baseTime = new Date('2024-01-01T10:00:00Z'); const entries: LoadedUsageEntry[] = [createMockEntry(baseTime)]; const blocksDefault = identifySessionBlocks(entries); const blocksExplicit = identifySessionBlocks(entries, 5); expect(blocksDefault).toHaveLength(1); expect(blocksExplicit).toHaveLength(1); expect(blocksDefault[0]!.endTime).toEqual(blocksExplicit[0]!.endTime); expect(blocksDefault[0]!.endTime).toEqual(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)); }); }); } ================================================ FILE: apps/ccusage/src/_shared-args.ts ================================================ import type { Args } from 'gunshi'; import type { CostMode, SortOrder } from './_types.ts'; import * as v from 'valibot'; import { DEFAULT_LOCALE } from './_consts.ts'; import { CostModes, filterDateSchema, SortOrders } from './_types.ts'; /** * Parses and validates a date argument in YYYYMMDD format * @param value - Date string to parse * @returns Validated date string */ function parseDateArg(value: string): string { return v.parse(filterDateSchema, value); } /** * Shared command line arguments used across multiple CLI commands */ export const sharedArgs = { since: { type: 'custom', short: 's', description: 'Filter from date (YYYYMMDD format)', parse: parseDateArg, }, until: { type: 'custom', short: 'u', description: 'Filter until date (YYYYMMDD format)', parse: parseDateArg, }, json: { type: 'boolean', short: 'j', description: 'Output in JSON format', default: false, }, mode: { type: 'enum', short: 'm', description: 'Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)', default: 'auto' as const satisfies CostMode, choices: CostModes, }, debug: { type: 'boolean', short: 'd', description: 'Show pricing mismatch information for debugging', default: false, }, debugSamples: { type: 'number', description: 'Number of sample discrepancies to show in debug output (default: 5)', default: 5, }, order: { type: 'enum', short: 'o', description: 'Sort order: desc (newest first) or asc (oldest first)', default: 'asc' as const satisfies SortOrder, choices: SortOrders, }, breakdown: { type: 'boolean', short: 'b', description: 'Show per-model cost breakdown', default: false, }, offline: { type: 'boolean', negatable: true, short: 'O', description: 'Use cached pricing data for Claude models instead of fetching from API', default: false, }, color: { // --color and FORCE_COLOR=1 is handled by picocolors type: 'boolean', description: 'Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.', }, noColor: { // --no-color and NO_COLOR=1 is handled by picocolors type: 'boolean', description: 'Disable colored output (default: auto). NO_COLOR=1 has the same effect.', }, timezone: { type: 'string', short: 'z', description: 'Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone', }, locale: { type: 'string', short: 'l', description: 'Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)', default: DEFAULT_LOCALE, }, jq: { type: 'string', short: 'q', description: 'Process JSON output with jq command (requires jq binary, implies --json)', }, config: { type: 'string', description: 'Path to configuration file (default: auto-discovery)', }, compact: { type: 'boolean', description: 'Force compact mode for narrow displays (better for screenshots)', default: false, }, } as const satisfies Args; /** * Shared command configuration for Gunshi CLI commands */ export const sharedCommandConfig = { args: sharedArgs, toKebab: true, } as const; ================================================ FILE: apps/ccusage/src/_token-utils.ts ================================================ /** * @fileoverview Token calculation utilities * * This module provides shared utilities for calculating token totals * across different token types. Used throughout the application to * ensure consistent token counting logic. */ /** * Token counts structure for raw usage data (uses InputTokens suffix) */ export type TokenCounts = { inputTokens: number; outputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; }; /** * Token counts structure for aggregated data (uses shorter names) */ export type AggregatedTokenCounts = { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; }; /** * Union type that supports both token count formats */ export type AnyTokenCounts = TokenCounts | AggregatedTokenCounts; /** * Calculates the total number of tokens across all token types * Supports both raw usage data format and aggregated data format * @param tokenCounts - Object containing counts for each token type * @returns Total number of tokens */ export function getTotalTokens(tokenCounts: AnyTokenCounts): number { // Support both property naming conventions const cacheCreation = 'cacheCreationInputTokens' in tokenCounts ? tokenCounts.cacheCreationInputTokens : tokenCounts.cacheCreationTokens; const cacheRead = 'cacheReadInputTokens' in tokenCounts ? tokenCounts.cacheReadInputTokens : tokenCounts.cacheReadTokens; return tokenCounts.inputTokens + tokenCounts.outputTokens + cacheCreation + cacheRead; } // In-source testing if (import.meta.vitest != null) { describe('getTotalTokens', () => { it('should sum all token types correctly (raw format)', () => { const tokens: TokenCounts = { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 2000, cacheReadInputTokens: 300, }; expect(getTotalTokens(tokens)).toBe(3800); }); it('should sum all token types correctly (aggregated format)', () => { const tokens: AggregatedTokenCounts = { inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 2000, cacheReadTokens: 300, }; expect(getTotalTokens(tokens)).toBe(3800); }); it('should handle zero values (raw format)', () => { const tokens: TokenCounts = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }; expect(getTotalTokens(tokens)).toBe(0); }); it('should handle zero values (aggregated format)', () => { const tokens: AggregatedTokenCounts = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, }; expect(getTotalTokens(tokens)).toBe(0); }); it('should handle missing cache tokens (raw format)', () => { const tokens: TokenCounts = { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }; expect(getTotalTokens(tokens)).toBe(1500); }); it('should handle missing cache tokens (aggregated format)', () => { const tokens: AggregatedTokenCounts = { inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 0, cacheReadTokens: 0, }; expect(getTotalTokens(tokens)).toBe(1500); }); }); } ================================================ FILE: apps/ccusage/src/_types.ts ================================================ import type { TupleToUnion } from 'type-fest'; import * as v from 'valibot'; /** * Branded Valibot schemas for type safety using brand markers. */ // Core identifier schemas export const modelNameSchema = v.pipe( v.string(), v.minLength(1, 'Model name cannot be empty'), v.brand('ModelName'), ); export const sessionIdSchema = v.pipe( v.string(), v.minLength(1, 'Session ID cannot be empty'), v.brand('SessionId'), ); export const requestIdSchema = v.pipe( v.string(), v.minLength(1, 'Request ID cannot be empty'), v.brand('RequestId'), ); export const messageIdSchema = v.pipe( v.string(), v.minLength(1, 'Message ID cannot be empty'), v.brand('MessageId'), ); // Date and timestamp schemas const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; export const isoTimestampSchema = v.pipe( v.string(), v.regex(isoTimestampRegex, 'Invalid ISO timestamp'), v.brand('ISOTimestamp'), ); const yyyymmddRegex = /^\d{4}-\d{2}-\d{2}$/; export const dailyDateSchema = v.pipe( v.string(), v.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'), v.brand('DailyDate'), ); export const activityDateSchema = v.pipe( v.string(), v.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'), v.brand('ActivityDate'), ); const yyyymmRegex = /^\d{4}-\d{2}$/; export const monthlyDateSchema = v.pipe( v.string(), v.regex(yyyymmRegex, 'Date must be in YYYY-MM format'), v.brand('MonthlyDate'), ); export const weeklyDateSchema = v.pipe( v.string(), v.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'), v.brand('WeeklyDate'), ); const filterDateRegex = /^\d{8}$/; export const filterDateSchema = v.pipe( v.string(), v.regex(filterDateRegex, 'Date must be in YYYYMMDD format'), v.brand('FilterDate'), ); // Other domain-specific schemas export const projectPathSchema = v.pipe( v.string(), v.minLength(1, 'Project path cannot be empty'), v.brand('ProjectPath'), ); const versionRegex = /^\d+\.\d+\.\d+/; export const versionSchema = v.pipe( v.string(), v.regex(versionRegex, 'Invalid version format'), v.brand('Version'), ); /** * Inferred branded types from schemas */ export type ModelName = v.InferOutput; export type SessionId = v.InferOutput; export type RequestId = v.InferOutput; export type MessageId = v.InferOutput; export type ISOTimestamp = v.InferOutput; export type DailyDate = v.InferOutput; export type ActivityDate = v.InferOutput; export type MonthlyDate = v.InferOutput; export type WeeklyDate = v.InferOutput; export type Bucket = MonthlyDate | WeeklyDate; export type FilterDate = v.InferOutput; export type ProjectPath = v.InferOutput; export type Version = v.InferOutput; /** * Helper functions to create branded values by parsing and validating input strings * These functions should be used when converting plain strings to branded types */ export const createModelName = (value: string): ModelName => v.parse(modelNameSchema, value); export const createSessionId = (value: string): SessionId => v.parse(sessionIdSchema, value); export const createRequestId = (value: string): RequestId => v.parse(requestIdSchema, value); export const createMessageId = (value: string): MessageId => v.parse(messageIdSchema, value); export function createISOTimestamp(value: string): ISOTimestamp { return v.parse(isoTimestampSchema, value); } export const createDailyDate = (value: string): DailyDate => v.parse(dailyDateSchema, value); export function createActivityDate(value: string): ActivityDate { return v.parse(activityDateSchema, value); } export const createMonthlyDate = (value: string): MonthlyDate => v.parse(monthlyDateSchema, value); export const createWeeklyDate = (value: string): WeeklyDate => v.parse(weeklyDateSchema, value); export const createFilterDate = (value: string): FilterDate => v.parse(filterDateSchema, value); export const createProjectPath = (value: string): ProjectPath => v.parse(projectPathSchema, value); export const createVersion = (value: string): Version => v.parse(versionSchema, value); export function createBucket(value: string): Bucket { const weeklyResult = v.safeParse(weeklyDateSchema, value); if (weeklyResult.success) { return weeklyResult.output; } return createMonthlyDate(value); } /** * Available cost calculation modes * - auto: Use pre-calculated costs when available, otherwise calculate from tokens * - calculate: Always calculate costs from token counts using model pricing * - display: Always use pre-calculated costs, show 0 for missing costs */ export const CostModes = ['auto', 'calculate', 'display'] as const; /** * Union type for cost calculation modes */ export type CostMode = TupleToUnion; /** * Available sort orders for data presentation */ export const SortOrders = ['desc', 'asc'] as const; /** * Union type for sort order options */ export type SortOrder = TupleToUnion; /** * Valibot schema for Claude Code statusline hook JSON data */ export const statuslineHookJsonSchema = v.object({ session_id: v.string(), transcript_path: v.string(), cwd: v.string(), model: v.object({ id: v.string(), display_name: v.string(), }), workspace: v.object({ current_dir: v.string(), project_dir: v.string(), }), version: v.optional(v.string()), cost: v.optional( v.object({ total_cost_usd: v.number(), total_duration_ms: v.optional(v.number()), total_api_duration_ms: v.optional(v.number()), total_lines_added: v.optional(v.number()), total_lines_removed: v.optional(v.number()), }), ), context_window: v.optional( v.object({ total_input_tokens: v.number(), total_output_tokens: v.optional(v.number()), context_window_size: v.number(), }), ), }); /** * Type definition for Claude Code statusline hook JSON data */ export type StatuslineHookJson = v.InferOutput; /** * Type definition for transcript usage data from Claude messages */ ================================================ FILE: apps/ccusage/src/_utils.ts ================================================ import { stat, utimes, writeFile } from 'node:fs/promises'; import { Result } from '@praha/byethrow'; import { createFixture } from 'fs-fixture'; export function unreachable(value: never): never { throw new Error(`Unreachable code reached with value: ${value as any}`); } /** * Gets the last modified time of a file using Result pattern * @param filePath - Path to the file * @returns Modification time in milliseconds, or 0 if file doesn't exist */ export async function getFileModifiedTime(filePath: string): Promise { return Result.pipe( Result.try({ try: stat(filePath), catch: (error) => error, }), Result.map((stats) => stats.mtime.getTime()), Result.unwrap(0), // Default to 0 if file doesn't exist or can't be accessed ); } if (import.meta.vitest != null) { describe('unreachable', () => { it('should throw an error when called', () => { expect(() => unreachable('test' as never)).toThrow( 'Unreachable code reached with value: test', ); }); }); describe('getFileModifiedTime', () => { it('returns specific modification time when set', async () => { await using fixture = await createFixture({ 'test.txt': 'content', }); // Set specific time (2024-01-01 12:00:00 UTC) const specificTime = new Date('2024-01-01T12:00:00.000Z'); await utimes(`${fixture.path}/test.txt`, specificTime, specificTime); const mtime = await getFileModifiedTime(fixture.getPath('test.txt')); expect(mtime).toBe(specificTime.getTime()); expect(typeof mtime).toBe('number'); }); it('returns 0 for non-existent file', async () => { const mtime = await getFileModifiedTime('/non/existent/file.txt'); expect(mtime).toBe(0); }); it('detects file modification correctly', async () => { await using fixture = await createFixture({ 'test.txt': 'content', }); // Set first time const firstTime = new Date('2024-01-01T10:00:00.000Z'); await utimes(`${fixture.path}/test.txt`, firstTime, firstTime); const mtime1 = await getFileModifiedTime(`${fixture.path}/test.txt`); expect(mtime1).toBe(firstTime.getTime()); // Modify file and set second time const secondTime = new Date('2024-01-01T11:00:00.000Z'); await writeFile(fixture.getPath('test.txt'), 'modified content'); await utimes(fixture.getPath('test.txt'), secondTime, secondTime); const mtime2 = await getFileModifiedTime(fixture.getPath('test.txt')); expect(mtime2).toBe(secondTime.getTime()); expect(mtime2).toBeGreaterThan(mtime1); }); }); } ================================================ FILE: apps/ccusage/src/calculate-cost.ts ================================================ /** * @fileoverview Cost calculation utilities for usage data analysis * * This module provides functions for calculating costs and aggregating token usage * across different time periods and models. It handles both pre-calculated costs * and dynamic cost calculations based on model pricing. * * @module calculate-cost */ import type { AggregatedTokenCounts } from './_token-utils.ts'; import type { DailyUsage, MonthlyUsage, SessionUsage, WeeklyUsage } from './data-loader.ts'; import { getTotalTokens } from './_token-utils.ts'; import { createActivityDate, createDailyDate, createModelName, createProjectPath, createSessionId, createVersion, } from './_types.ts'; /** * Alias for AggregatedTokenCounts from shared utilities * @deprecated Use AggregatedTokenCounts from _token-utils.ts instead */ type TokenData = AggregatedTokenCounts; /** * Token totals including cost information */ type TokenTotals = TokenData & { totalCost: number; }; /** * Complete totals object with token counts, cost, and total token sum */ type TotalsObject = TokenTotals & { totalTokens: number; }; /** * Calculates total token usage and cost across multiple usage data entries * @param data - Array of daily, monthly, or session usage data * @returns Aggregated token totals and cost */ export function calculateTotals( data: Array, ): TokenTotals { return data.reduce( (acc, item) => ({ inputTokens: acc.inputTokens + item.inputTokens, outputTokens: acc.outputTokens + item.outputTokens, cacheCreationTokens: acc.cacheCreationTokens + item.cacheCreationTokens, cacheReadTokens: acc.cacheReadTokens + item.cacheReadTokens, totalCost: acc.totalCost + item.totalCost, }), { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0, }, ); } // Re-export getTotalTokens from shared utilities for backward compatibility export { getTotalTokens }; /** * Creates a complete totals object by adding total token count to existing totals * @param totals - Token totals with cost information * @returns Complete totals object including total token sum */ export function createTotalsObject(totals: TokenTotals): TotalsObject { return { ...totals, totalTokens: getTotalTokens(totals), }; } if (import.meta.vitest != null) { describe('token aggregation utilities', () => { it('calculateTotals should aggregate daily usage data', () => { const dailyData: DailyUsage[] = [ { date: createDailyDate('2024-01-01'), inputTokens: 100, outputTokens: 50, cacheCreationTokens: 25, cacheReadTokens: 10, totalCost: 0.01, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], }, { date: createDailyDate('2024-01-02'), inputTokens: 200, outputTokens: 100, cacheCreationTokens: 50, cacheReadTokens: 20, totalCost: 0.02, modelsUsed: [createModelName('claude-opus-4-20250514')], modelBreakdowns: [], }, ]; const totals = calculateTotals(dailyData); expect(totals.inputTokens).toBe(300); expect(totals.outputTokens).toBe(150); expect(totals.cacheCreationTokens).toBe(75); expect(totals.cacheReadTokens).toBe(30); expect(totals.totalCost).toBeCloseTo(0.03); }); it('calculateTotals should aggregate session usage data', () => { const sessionData: SessionUsage[] = [ { sessionId: createSessionId('session-1'), projectPath: createProjectPath('project/path'), inputTokens: 100, outputTokens: 50, cacheCreationTokens: 25, cacheReadTokens: 10, totalCost: 0.01, lastActivity: createActivityDate('2024-01-01'), versions: [createVersion('1.0.3')], modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], }, { sessionId: createSessionId('session-2'), projectPath: createProjectPath('project/path'), inputTokens: 200, outputTokens: 100, cacheCreationTokens: 50, cacheReadTokens: 20, totalCost: 0.02, lastActivity: createActivityDate('2024-01-02'), versions: [createVersion('1.0.3'), createVersion('1.0.4')], modelsUsed: [createModelName('claude-opus-4-20250514')], modelBreakdowns: [], }, ]; const totals = calculateTotals(sessionData); expect(totals.inputTokens).toBe(300); expect(totals.outputTokens).toBe(150); expect(totals.cacheCreationTokens).toBe(75); expect(totals.cacheReadTokens).toBe(30); expect(totals.totalCost).toBeCloseTo(0.03); }); it('getTotalTokens should sum all token types', () => { const tokens = { inputTokens: 100, outputTokens: 50, cacheCreationTokens: 25, cacheReadTokens: 10, }; const total = getTotalTokens(tokens); expect(total).toBe(185); }); it('getTotalTokens should handle zero values', () => { const tokens = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, }; const total = getTotalTokens(tokens); expect(total).toBe(0); }); it('createTotalsObject should create complete totals object', () => { const totals = { inputTokens: 100, outputTokens: 50, cacheCreationTokens: 25, cacheReadTokens: 10, totalCost: 0.01, }; const totalsObject = createTotalsObject(totals); expect(totalsObject).toEqual({ inputTokens: 100, outputTokens: 50, cacheCreationTokens: 25, cacheReadTokens: 10, totalTokens: 185, totalCost: 0.01, }); }); it('calculateTotals should handle empty array', () => { const totals = calculateTotals([]); expect(totals).toEqual({ inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0, }); }); }); } ================================================ FILE: apps/ccusage/src/commands/_session_id.ts ================================================ import type { CostMode } from '../_types.ts'; import type { UsageData } from '../data-loader.ts'; import process from 'node:process'; import { formatCurrency, formatNumber, ResponsiveTable } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { formatDateCompact } from '../_date-utils.ts'; import { processWithJq } from '../_jq-processor.ts'; import { loadSessionUsageById } from '../data-loader.ts'; import { log, logger } from '../logger.ts'; export type SessionIdContext = { values: { id: string; mode: CostMode; offline: boolean; jq?: string; timezone?: string; locale: string; // normalized to non-optional to avoid touching data-loader }; }; /** * Handles the session ID lookup and displays usage data. */ export async function handleSessionIdLookup( ctx: SessionIdContext, useJson: boolean, ): Promise { const sessionUsage = await loadSessionUsageById(ctx.values.id, { mode: ctx.values.mode, offline: ctx.values.offline, }); if (sessionUsage == null) { if (useJson) { log(JSON.stringify(null)); } else { logger.warn(`No session found with ID: ${ctx.values.id}`); } process.exit(0); } if (useJson) { const jsonOutput = { sessionId: ctx.values.id, totalCost: sessionUsage.totalCost, totalTokens: calculateSessionTotalTokens(sessionUsage.entries), entries: sessionUsage.entries.map((entry) => ({ timestamp: entry.timestamp, inputTokens: entry.message.usage.input_tokens, outputTokens: entry.message.usage.output_tokens, cacheCreationTokens: entry.message.usage.cache_creation_input_tokens ?? 0, cacheReadTokens: entry.message.usage.cache_read_input_tokens ?? 0, model: entry.message.model ?? 'unknown', costUSD: entry.costUSD ?? 0, })), }; if (ctx.values.jq != null) { const jqResult = await processWithJq(jsonOutput, ctx.values.jq); if (Result.isFailure(jqResult)) { logger.error(jqResult.error.message); process.exit(1); } log(jqResult.value); } else { log(JSON.stringify(jsonOutput, null, 2)); } } else { logger.box(`Claude Code Session Usage - ${ctx.values.id}`); const totalTokens = calculateSessionTotalTokens(sessionUsage.entries); log(`Total Cost: ${formatCurrency(sessionUsage.totalCost)}`); log(`Total Tokens: ${formatNumber(totalTokens)}`); log(`Total Entries: ${sessionUsage.entries.length}`); log(''); if (sessionUsage.entries.length > 0) { const table = new ResponsiveTable({ head: ['Timestamp', 'Model', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Cost (USD)'], style: { head: ['cyan'] }, colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right'], }); for (const entry of sessionUsage.entries) { table.push([ formatDateCompact(entry.timestamp, ctx.values.timezone, ctx.values.locale), entry.message.model ?? 'unknown', formatNumber(entry.message.usage.input_tokens), formatNumber(entry.message.usage.output_tokens), formatNumber(entry.message.usage.cache_creation_input_tokens ?? 0), formatNumber(entry.message.usage.cache_read_input_tokens ?? 0), formatCurrency(entry.costUSD ?? 0), ]); } log(table.toString()); } } } function calculateSessionTotalTokens(entries: UsageData[]): number { return entries.reduce((sum, entry) => { const usage = entry.message.usage; return ( sum + usage.input_tokens + usage.output_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) ); }, 0); } ================================================ FILE: apps/ccusage/src/commands/blocks.ts ================================================ import type { SessionBlock } from '../_session-blocks.ts'; import process from 'node:process'; import { formatCurrency, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import pc from 'picocolors'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; import { BLOCKS_COMPACT_WIDTH_THRESHOLD, BLOCKS_DEFAULT_TERMINAL_WIDTH, BLOCKS_WARNING_THRESHOLD, DEFAULT_RECENT_DAYS, } from '../_consts.ts'; import { processWithJq } from '../_jq-processor.ts'; import { calculateBurnRate, DEFAULT_SESSION_DURATION_HOURS, filterRecentBlocks, projectBlockUsage, } from '../_session-blocks.ts'; import { sharedCommandConfig } from '../_shared-args.ts'; import { getTotalTokens } from '../_token-utils.ts'; import { loadSessionBlockData } from '../data-loader.ts'; import { log, logger } from '../logger.ts'; /** * Formats the time display for a session block * @param block - Session block to format * @param compact - Whether to use compact formatting for narrow terminals * @param locale - Locale for date/time formatting * @returns Formatted time string with duration and status information */ function formatBlockTime(block: SessionBlock, compact = false, locale?: string): string { const start = compact ? block.startTime.toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) : block.startTime.toLocaleString(locale); if (block.isGap ?? false) { const end = compact ? block.endTime.toLocaleString(locale, { hour: '2-digit', minute: '2-digit', }) : block.endTime.toLocaleString(locale); const duration = Math.round( (block.endTime.getTime() - block.startTime.getTime()) / (1000 * 60 * 60), ); return compact ? `${start}-${end}\n(${duration}h gap)` : `${start} - ${end} (${duration}h gap)`; } const duration = block.actualEndTime != null ? Math.round((block.actualEndTime.getTime() - block.startTime.getTime()) / (1000 * 60)) : 0; if (block.isActive) { const now = new Date(); const elapsed = Math.round((now.getTime() - block.startTime.getTime()) / (1000 * 60)); const remaining = Math.round((block.endTime.getTime() - now.getTime()) / (1000 * 60)); const elapsedHours = Math.floor(elapsed / 60); const elapsedMins = elapsed % 60; const remainingHours = Math.floor(remaining / 60); const remainingMins = remaining % 60; if (compact) { return `${start}\n(${elapsedHours}h${elapsedMins}m/${remainingHours}h${remainingMins}m)`; } return `${start} (${elapsedHours}h ${elapsedMins}m elapsed, ${remainingHours}h ${remainingMins}m remaining)`; } const hours = Math.floor(duration / 60); const mins = duration % 60; if (compact) { return hours > 0 ? `${start}\n(${hours}h${mins}m)` : `${start}\n(${mins}m)`; } if (hours > 0) { return `${start} (${hours}h ${mins}m)`; } return `${start} (${mins}m)`; } /** * Formats the list of models used in a block for display * @param models - Array of model names * @returns Formatted model names string */ function formatModels(models: string[]): string { if (models.length === 0) { return '-'; } // Use consistent multiline format across all commands return formatModelsDisplayMultiline(models); } /** * Parses token limit argument, supporting 'max' keyword * @param value - Token limit string value * @param maxFromAll - Maximum token count found in all blocks * @returns Parsed token limit or undefined if invalid */ function parseTokenLimit(value: string | undefined, maxFromAll: number): number | undefined { if (value == null || value === '' || value === 'max') { return maxFromAll > 0 ? maxFromAll : undefined; } const limit = Number.parseInt(value, 10); return Number.isNaN(limit) ? undefined : limit; } export const blocksCommand = define({ name: 'blocks', description: 'Show usage report grouped by session billing blocks', args: { ...sharedCommandConfig.args, active: { type: 'boolean', short: 'a', description: 'Show only active block with projections', default: false, }, recent: { type: 'boolean', short: 'r', description: `Show blocks from last ${DEFAULT_RECENT_DAYS} days (including active)`, default: false, }, tokenLimit: { type: 'string', short: 't', description: 'Token limit for quota warnings (e.g., 500000 or "max")', }, sessionLength: { type: 'number', short: 'n', description: `Session block duration in hours (default: ${DEFAULT_SESSION_DURATION_HOURS})`, default: DEFAULT_SESSION_DURATION_HOURS, }, }, toKebab: true, async run(ctx) { // Load configuration and merge with CLI arguments const config = loadConfig(ctx.values.config, ctx.values.debug); const mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug); // --jq implies --json const useJson = mergedOptions.json || mergedOptions.jq != null; if (useJson) { logger.level = 0; } // Validate session length if (ctx.values.sessionLength <= 0) { logger.error('Session length must be a positive number'); process.exit(1); } let blocks = await loadSessionBlockData({ since: ctx.values.since, until: ctx.values.until, mode: ctx.values.mode, order: ctx.values.order, offline: ctx.values.offline, sessionDurationHours: ctx.values.sessionLength, timezone: ctx.values.timezone, locale: ctx.values.locale, }); if (blocks.length === 0) { if (useJson) { log(JSON.stringify({ blocks: [] })); } else { logger.warn('No Claude usage data found.'); } process.exit(0); } // Calculate max tokens from ALL blocks before applying filters let maxTokensFromAll = 0; if ( ctx.values.tokenLimit === 'max' || ctx.values.tokenLimit == null || ctx.values.tokenLimit === '' ) { for (const block of blocks) { if (!(block.isGap ?? false) && !block.isActive) { const blockTokens = getTotalTokens(block.tokenCounts); if (blockTokens > maxTokensFromAll) { maxTokensFromAll = blockTokens; } } } if (!useJson && maxTokensFromAll > 0) { logger.info(`Using max tokens from previous sessions: ${formatNumber(maxTokensFromAll)}`); } } // Apply filters if (ctx.values.recent) { blocks = filterRecentBlocks(blocks, DEFAULT_RECENT_DAYS); } if (ctx.values.active) { blocks = blocks.filter((block: SessionBlock) => block.isActive); if (blocks.length === 0) { if (useJson) { log(JSON.stringify({ blocks: [], message: 'No active block' })); } else { logger.info('No active session block found.'); } process.exit(0); } } if (useJson) { // JSON output const jsonOutput = { blocks: blocks.map((block: SessionBlock) => { const burnRate = block.isActive ? calculateBurnRate(block) : null; const projection = block.isActive ? projectBlockUsage(block) : null; return { id: block.id, startTime: block.startTime.toISOString(), endTime: block.endTime.toISOString(), actualEndTime: block.actualEndTime?.toISOString() ?? null, isActive: block.isActive, isGap: block.isGap ?? false, entries: block.entries.length, tokenCounts: block.tokenCounts, totalTokens: getTotalTokens(block.tokenCounts), costUSD: block.costUSD, models: block.models, burnRate, projection, tokenLimitStatus: projection != null && ctx.values.tokenLimit != null ? (() => { const limit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll); return limit != null ? { limit, projectedUsage: projection.totalTokens, percentUsed: (projection.totalTokens / limit) * 100, status: projection.totalTokens > limit ? 'exceeds' : projection.totalTokens > limit * BLOCKS_WARNING_THRESHOLD ? 'warning' : 'ok', } : undefined; })() : undefined, usageLimitResetTime: block.usageLimitResetTime, }; }), }; // Process with jq if specified if (ctx.values.jq != null) { const jqResult = await processWithJq(jsonOutput, ctx.values.jq); if (Result.isFailure(jqResult)) { logger.error(jqResult.error.message); process.exit(1); } log(jqResult.value); } else { log(JSON.stringify(jsonOutput, null, 2)); } } else { // Table output if (ctx.values.active && blocks.length === 1) { // Detailed active block view const block = blocks[0] as SessionBlock; if (block == null) { logger.warn('No active block found.'); process.exit(0); } const burnRate = calculateBurnRate(block); const projection = projectBlockUsage(block); logger.box('Current Session Block Status'); const now = new Date(); const elapsed = Math.round((now.getTime() - block.startTime.getTime()) / (1000 * 60)); const remaining = Math.round((block.endTime.getTime() - now.getTime()) / (1000 * 60)); log( `Block Started: ${pc.cyan(block.startTime.toLocaleString())} (${pc.yellow(`${Math.floor(elapsed / 60)}h ${elapsed % 60}m`)} ago)`, ); log(`Time Remaining: ${pc.green(`${Math.floor(remaining / 60)}h ${remaining % 60}m`)}\n`); log(pc.bold('Current Usage:')); log(` Input Tokens: ${formatNumber(block.tokenCounts.inputTokens)}`); log(` Output Tokens: ${formatNumber(block.tokenCounts.outputTokens)}`); log(` Total Cost: ${formatCurrency(block.costUSD)}\n`); if (burnRate != null) { log(pc.bold('Burn Rate:')); log(` Tokens/minute: ${formatNumber(burnRate.tokensPerMinute)}`); log(` Cost/hour: ${formatCurrency(burnRate.costPerHour)}\n`); } if (projection != null) { log(pc.bold('Projected Usage (if current rate continues):')); log(` Total Tokens: ${formatNumber(projection.totalTokens)}`); log(` Total Cost: ${formatCurrency(projection.totalCost)}\n`); if (ctx.values.tokenLimit != null) { // Parse token limit const limit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll); if (limit != null && limit > 0) { const currentTokens = getTotalTokens(block.tokenCounts); const remainingTokens = Math.max(0, limit - currentTokens); const percentUsed = (projection.totalTokens / limit) * 100; const status = percentUsed > 100 ? pc.red('EXCEEDS LIMIT') : percentUsed > BLOCKS_WARNING_THRESHOLD * 100 ? pc.yellow('WARNING') : pc.green('OK'); log(pc.bold('Token Limit Status:')); log(` Limit: ${formatNumber(limit)} tokens`); log( ` Current Usage: ${formatNumber(currentTokens)} (${((currentTokens / limit) * 100).toFixed(1)}%)`, ); log(` Remaining: ${formatNumber(remainingTokens)} tokens`); log(` Projected Usage: ${percentUsed.toFixed(1)}% ${status}`); } } } } else { // Table view for multiple blocks logger.box('Claude Code Token Usage Report - Session Blocks'); // Calculate token limit if "max" is specified const actualTokenLimit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll); const tableHeaders = ['Block Start', 'Duration/Status', 'Models', 'Tokens']; const tableAligns: ('left' | 'right' | 'center')[] = ['left', 'left', 'left', 'right']; // Add % column if token limit is set if (actualTokenLimit != null && actualTokenLimit > 0) { tableHeaders.push('%'); tableAligns.push('right'); } tableHeaders.push('Cost'); tableAligns.push('right'); const table = new ResponsiveTable({ head: tableHeaders, style: { head: ['cyan'] }, colAligns: tableAligns, }); // Detect if we need compact formatting // Use compact format if: // 1. User explicitly requested it with --compact flag // 2. Terminal width is below threshold const terminalWidth = process.stdout.columns || BLOCKS_DEFAULT_TERMINAL_WIDTH; const isNarrowTerminal = terminalWidth < BLOCKS_COMPACT_WIDTH_THRESHOLD; const useCompactFormat = ctx.values.compact || isNarrowTerminal; for (const block of blocks) { if (block.isGap ?? false) { // Gap row const gapRow = [ pc.gray(formatBlockTime(block, useCompactFormat, ctx.values.locale)), pc.gray('(inactive)'), pc.gray('-'), pc.gray('-'), ]; if (actualTokenLimit != null && actualTokenLimit > 0) { gapRow.push(pc.gray('-')); } gapRow.push(pc.gray('-')); table.push(gapRow); } else { const totalTokens = getTotalTokens(block.tokenCounts); const status = block.isActive ? pc.green('ACTIVE') : ''; const row = [ formatBlockTime(block, useCompactFormat, ctx.values.locale), status, formatModels(block.models), formatNumber(totalTokens), ]; // Add percentage if token limit is set if (actualTokenLimit != null && actualTokenLimit > 0) { const percentage = (totalTokens / actualTokenLimit) * 100; const percentText = `${percentage.toFixed(1)}%`; row.push(percentage > 100 ? pc.red(percentText) : percentText); } row.push(formatCurrency(block.costUSD)); table.push(row); // Add REMAINING and PROJECTED rows for active blocks if (block.isActive) { // REMAINING row - only show if token limit is set if (actualTokenLimit != null && actualTokenLimit > 0) { const currentTokens = getTotalTokens(block.tokenCounts); const remainingTokens = Math.max(0, actualTokenLimit - currentTokens); const remainingText = remainingTokens > 0 ? formatNumber(remainingTokens) : pc.red('0'); // Calculate remaining percentage (how much of limit is left) const remainingPercent = ((actualTokenLimit - currentTokens) / actualTokenLimit) * 100; const remainingPercentText = remainingPercent > 0 ? `${remainingPercent.toFixed(1)}%` : pc.red('0.0%'); const remainingRow = [ { content: pc.gray(`(assuming ${formatNumber(actualTokenLimit)} token limit)`), hAlign: 'right' as const, }, pc.blue('REMAINING'), '', remainingText, remainingPercentText, '', // No cost for remaining - it's about token limit, not cost ]; table.push(remainingRow); } // PROJECTED row const projection = projectBlockUsage(block); if (projection != null) { const projectedTokens = formatNumber(projection.totalTokens); const projectedText = actualTokenLimit != null && actualTokenLimit > 0 && projection.totalTokens > actualTokenLimit ? pc.red(projectedTokens) : projectedTokens; const projectedRow = [ { content: pc.gray('(assuming current burn rate)'), hAlign: 'right' as const }, pc.yellow('PROJECTED'), '', projectedText, ]; // Add percentage if token limit is set if (actualTokenLimit != null && actualTokenLimit > 0) { const percentage = (projection.totalTokens / actualTokenLimit) * 100; const percentText = `${percentage.toFixed(1)}%`; projectedRow.push(percentText); } projectedRow.push(formatCurrency(projection.totalCost)); table.push(projectedRow); } } } } log(table.toString()); } } }, }); ================================================ FILE: apps/ccusage/src/commands/daily.ts ================================================ import type { UsageReportConfig } from '@ccusage/terminal/table'; import process from 'node:process'; import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import pc from 'picocolors'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; import { groupByProject, groupDataByProject } from '../_daily-grouping.ts'; import { formatDateCompact } from '../_date-utils.ts'; import { processWithJq } from '../_jq-processor.ts'; import { formatProjectName } from '../_project-names.ts'; import { sharedCommandConfig } from '../_shared-args.ts'; import { calculateTotals, createTotalsObject, getTotalTokens } from '../calculate-cost.ts'; import { loadDailyUsageData } from '../data-loader.ts'; import { detectMismatches, printMismatchReport } from '../debug.ts'; import { log, logger } from '../logger.ts'; export const dailyCommand = define({ name: 'daily', description: 'Show usage report grouped by date', ...sharedCommandConfig, args: { ...sharedCommandConfig.args, instances: { type: 'boolean', short: 'i', description: 'Show usage breakdown by project/instance', default: false, }, project: { type: 'string', short: 'p', description: 'Filter to specific project name', }, projectAliases: { type: 'string', description: "Comma-separated project aliases (e.g., 'ccusage=Usage Tracker,myproject=My Project')", hidden: true, }, }, async run(ctx) { // Load configuration and merge with CLI arguments const config = loadConfig(ctx.values.config, ctx.values.debug); const mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug); // Convert projectAliases to Map if it exists // Parse comma-separated key=value pairs let projectAliases: Map | undefined; if (mergedOptions.projectAliases != null && typeof mergedOptions.projectAliases === 'string') { projectAliases = new Map(); const pairs = mergedOptions.projectAliases .split(',') .map((pair) => pair.trim()) .filter((pair) => pair !== ''); for (const pair of pairs) { const parts = pair.split('=').map((s) => s.trim()); const rawName = parts[0]; const alias = parts[1]; if (rawName != null && alias != null && rawName !== '' && alias !== '') { projectAliases.set(rawName, alias); } } } // --jq implies --json const useJson = Boolean(mergedOptions.json) || mergedOptions.jq != null; if (useJson) { logger.level = 0; } const dailyData = await loadDailyUsageData({ ...mergedOptions, groupByProject: mergedOptions.instances, }); if (dailyData.length === 0) { if (useJson) { log(JSON.stringify([])); } else { logger.warn('No Claude usage data found.'); } process.exit(0); } // Calculate totals const totals = calculateTotals(dailyData); // Show debug information if requested if (mergedOptions.debug && !useJson) { const mismatchStats = await detectMismatches(undefined); printMismatchReport(mismatchStats, mergedOptions.debugSamples as number | undefined); } if (useJson) { // Output JSON format - group by project if instances flag is used const jsonOutput = Boolean(mergedOptions.instances) && dailyData.some((d) => d.project != null) ? { projects: groupByProject(dailyData), totals: createTotalsObject(totals), } : { daily: dailyData.map((data) => ({ date: data.date, inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalTokens: getTotalTokens(data), totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, ...(data.project != null && { project: data.project }), })), totals: createTotalsObject(totals), }; // Process with jq if specified if (mergedOptions.jq != null) { const jqResult = await processWithJq(jsonOutput, mergedOptions.jq); if (Result.isFailure(jqResult)) { logger.error(jqResult.error.message); process.exit(1); } log(jqResult.value); } else { log(JSON.stringify(jsonOutput, null, 2)); } } else { // Print header logger.box('Claude Code Token Usage Report - Daily'); // Create table with compact mode support const tableConfig: UsageReportConfig = { firstColumnName: 'Date', dateFormatter: (dateStr: string) => formatDateCompact(dateStr, mergedOptions.timezone, mergedOptions.locale ?? undefined), forceCompact: ctx.values.compact, }; const table = createUsageReportTable(tableConfig); // Add daily data - group by project if instances flag is used if (Boolean(mergedOptions.instances) && dailyData.some((d) => d.project != null)) { // Group data by project for visual separation const projectGroups = groupDataByProject(dailyData); let isFirstProject = true; for (const [projectName, projectData] of Object.entries(projectGroups)) { // Add project section header if (!isFirstProject) { // Add empty row for visual separation between projects table.push(['', '', '', '', '', '', '', '']); } // Add project header row table.push([ pc.cyan(`Project: ${formatProjectName(projectName, projectAliases)}`), '', '', '', '', '', '', '', ]); // Add data rows for this project for (const data of projectData) { const row = formatUsageDataRow(data.date, { inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, }); table.push(row); // Add model breakdown rows if flag is set if (mergedOptions.breakdown) { pushBreakdownRows(table, data.modelBreakdowns); } } isFirstProject = false; } } else { // Standard display without project grouping for (const data of dailyData) { // Main row const row = formatUsageDataRow(data.date, { inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, }); table.push(row); // Add model breakdown rows if flag is set if (mergedOptions.breakdown) { pushBreakdownRows(table, data.modelBreakdowns); } } } // Add empty row for visual separation before totals addEmptySeparatorRow(table, 8); // Add totals const totalsRow = formatTotalsRow({ inputTokens: totals.inputTokens, outputTokens: totals.outputTokens, cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, }); table.push(totalsRow); log(table.toString()); // Show guidance message if in compact mode if (table.isCompactMode()) { logger.info('\nRunning in Compact Mode'); logger.info('Expand terminal width to see cache metrics and total tokens'); } } }, }); ================================================ FILE: apps/ccusage/src/commands/index.ts ================================================ import process from 'node:process'; import { cli } from 'gunshi'; import { description, name, version } from '../../package.json'; import { blocksCommand } from './blocks.ts'; import { dailyCommand } from './daily.ts'; import { monthlyCommand } from './monthly.ts'; import { sessionCommand } from './session.ts'; import { statuslineCommand } from './statusline.ts'; import { weeklyCommand } from './weekly.ts'; // Re-export all commands for easy importing export { blocksCommand, dailyCommand, monthlyCommand, sessionCommand, statuslineCommand, weeklyCommand, }; /** * Command entries as tuple array */ export const subCommandUnion = [ ['daily', dailyCommand], ['monthly', monthlyCommand], ['weekly', weeklyCommand], ['session', sessionCommand], ['blocks', blocksCommand], ['statusline', statuslineCommand], ] as const; /** * Available command names extracted from union */ export type CommandName = (typeof subCommandUnion)[number][0]; /** * Map of available CLI subcommands */ const subCommands = new Map(); for (const [name, command] of subCommandUnion) { subCommands.set(name, command); } /** * Default command when no subcommand is specified (defaults to daily) */ const mainCommand = dailyCommand; export async function run(): Promise { // When invoked through npx, the binary name might be passed as the first argument // Filter it out if it matches the expected binary name let args = process.argv.slice(2); if (args[0] === 'ccusage') { args = args.slice(1); } await cli(args, mainCommand, { name, version, description, subCommands, renderHeader: null, }); } ================================================ FILE: apps/ccusage/src/commands/monthly.ts ================================================ import type { UsageReportConfig } from '@ccusage/terminal/table'; import process from 'node:process'; import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; import { DEFAULT_LOCALE } from '../_consts.ts'; import { formatDateCompact } from '../_date-utils.ts'; import { processWithJq } from '../_jq-processor.ts'; import { sharedCommandConfig } from '../_shared-args.ts'; import { calculateTotals, createTotalsObject, getTotalTokens } from '../calculate-cost.ts'; import { loadMonthlyUsageData } from '../data-loader.ts'; import { detectMismatches, printMismatchReport } from '../debug.ts'; import { log, logger } from '../logger.ts'; export const monthlyCommand = define({ name: 'monthly', description: 'Show usage report grouped by month', ...sharedCommandConfig, async run(ctx) { // Load configuration and merge with CLI arguments const config = loadConfig(ctx.values.config, ctx.values.debug); const mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug); // --jq implies --json const useJson = Boolean(mergedOptions.json) || mergedOptions.jq != null; if (useJson) { logger.level = 0; } const monthlyData = await loadMonthlyUsageData(mergedOptions); if (monthlyData.length === 0) { if (useJson) { const emptyOutput = { monthly: [], totals: { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0, }, }; log(JSON.stringify(emptyOutput, null, 2)); } else { logger.warn('No Claude usage data found.'); } process.exit(0); } // Calculate totals const totals = calculateTotals(monthlyData); // Show debug information if requested if (mergedOptions.debug && !useJson) { const mismatchStats = await detectMismatches(undefined); printMismatchReport(mismatchStats, mergedOptions.debugSamples as number | undefined); } if (useJson) { // Output JSON format const jsonOutput = { monthly: monthlyData.map((data) => ({ month: data.month, inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalTokens: getTotalTokens(data), totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, })), totals: createTotalsObject(totals), }; // Process with jq if specified if (mergedOptions.jq != null) { const jqResult = await processWithJq(jsonOutput, mergedOptions.jq); if (Result.isFailure(jqResult)) { logger.error(jqResult.error.message); process.exit(1); } log(jqResult.value); } else { log(JSON.stringify(jsonOutput, null, 2)); } } else { // Print header logger.box('Claude Code Token Usage Report - Monthly'); // Create table with compact mode support const tableConfig: UsageReportConfig = { firstColumnName: 'Month', dateFormatter: (dateStr: string) => formatDateCompact( dateStr, mergedOptions.timezone, mergedOptions.locale ?? DEFAULT_LOCALE, ), forceCompact: ctx.values.compact, }; const table = createUsageReportTable(tableConfig); // Add monthly data for (const data of monthlyData) { // Main row const row = formatUsageDataRow(data.month, { inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, }); table.push(row); // Add model breakdown rows if flag is set if (mergedOptions.breakdown) { pushBreakdownRows(table, data.modelBreakdowns); } } // Add empty row for visual separation before totals addEmptySeparatorRow(table, 8); // Add totals const totalsRow = formatTotalsRow({ inputTokens: totals.inputTokens, outputTokens: totals.outputTokens, cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, }); table.push(totalsRow); log(table.toString()); // Show guidance message if in compact mode if (table.isCompactMode()) { logger.info('\nRunning in Compact Mode'); logger.info('Expand terminal width to see cache metrics and total tokens'); } } }, }); ================================================ FILE: apps/ccusage/src/commands/session.ts ================================================ import type { UsageReportConfig } from '@ccusage/terminal/table'; import process from 'node:process'; import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; import { DEFAULT_LOCALE } from '../_consts.ts'; import { formatDateCompact } from '../_date-utils.ts'; import { processWithJq } from '../_jq-processor.ts'; import { sharedCommandConfig } from '../_shared-args.ts'; import { calculateTotals, createTotalsObject, getTotalTokens } from '../calculate-cost.ts'; import { loadSessionData } from '../data-loader.ts'; import { detectMismatches, printMismatchReport } from '../debug.ts'; import { log, logger } from '../logger.ts'; import { handleSessionIdLookup } from './_session_id.ts'; // eslint-disable-next-line ts/no-unused-vars const { order: _, ...sharedArgs } = sharedCommandConfig.args; export const sessionCommand = define({ name: 'session', description: 'Show usage report grouped by conversation session', ...sharedCommandConfig, args: { ...sharedArgs, id: { type: 'string', short: 'i', description: 'Load usage data for a specific session ID', }, }, toKebab: true, async run(ctx): Promise { // Load configuration and merge with CLI arguments const config = loadConfig(ctx.values.config, ctx.values.debug); const mergedOptions: typeof ctx.values = mergeConfigWithArgs(ctx, config, ctx.values.debug); // --jq implies --json const useJson = mergedOptions.json || mergedOptions.jq != null; if (useJson) { logger.level = 0; } // Handle specific session ID lookup if (mergedOptions.id != null) { return handleSessionIdLookup( { values: { id: mergedOptions.id, mode: mergedOptions.mode, offline: mergedOptions.offline, jq: mergedOptions.jq, timezone: mergedOptions.timezone, locale: mergedOptions.locale ?? DEFAULT_LOCALE, }, }, useJson, ); } // Original session listing logic const sessionData = await loadSessionData({ since: ctx.values.since, until: ctx.values.until, mode: ctx.values.mode, offline: ctx.values.offline, timezone: ctx.values.timezone, locale: ctx.values.locale, }); if (sessionData.length === 0) { if (useJson) { log(JSON.stringify([])); } else { logger.warn('No Claude usage data found.'); } process.exit(0); } // Calculate totals const totals = calculateTotals(sessionData); // Show debug information if requested if (ctx.values.debug && !useJson) { const mismatchStats = await detectMismatches(undefined); printMismatchReport(mismatchStats, ctx.values.debugSamples); } if (useJson) { // Output JSON format const jsonOutput = { sessions: sessionData.map((data) => ({ sessionId: data.sessionId, inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalTokens: getTotalTokens(data), totalCost: data.totalCost, lastActivity: data.lastActivity, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, projectPath: data.projectPath, })), totals: createTotalsObject(totals), }; // Process with jq if specified if (ctx.values.jq != null) { const jqResult = await processWithJq(jsonOutput, ctx.values.jq); if (Result.isFailure(jqResult)) { logger.error(jqResult.error.message); process.exit(1); } log(jqResult.value); } else { log(JSON.stringify(jsonOutput, null, 2)); } } else { // Print header logger.box('Claude Code Token Usage Report - By Session'); // Create table with compact mode support const tableConfig: UsageReportConfig = { firstColumnName: 'Session', includeLastActivity: true, dateFormatter: (dateStr: string) => formatDateCompact(dateStr, ctx.values.timezone, ctx.values.locale), forceCompact: ctx.values.compact, }; const table = createUsageReportTable(tableConfig); // Add session data let maxSessionLength = 0; for (const data of sessionData) { const sessionDisplay = data.sessionId.split('-').slice(-2).join('-'); // Display last two parts of session ID maxSessionLength = Math.max(maxSessionLength, sessionDisplay.length); // Main row const row = formatUsageDataRow( sessionDisplay, { inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, }, data.lastActivity, ); table.push(row); // Add model breakdown rows if flag is set if (ctx.values.breakdown) { // Session has 1 extra column before data and 1 trailing column pushBreakdownRows(table, data.modelBreakdowns, 1, 1); } } // Add empty row for visual separation before totals addEmptySeparatorRow(table, 9); // Add totals const totalsRow = formatTotalsRow( { inputTokens: totals.inputTokens, outputTokens: totals.outputTokens, cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, }, true, ); // Include Last Activity column table.push(totalsRow); log(table.toString()); // Show guidance message if in compact mode if (table.isCompactMode()) { logger.info('\nRunning in Compact Mode'); logger.info('Expand terminal width to see cache metrics and total tokens'); } } }, }); // Note: Tests for --id functionality are covered by the existing loadSessionUsageById tests // in data-loader.ts, since this command directly uses that function. ================================================ FILE: apps/ccusage/src/commands/statusline.ts ================================================ import type { Formatter } from 'picocolors/types'; import { mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; import { formatCurrency } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { createLimoJson } from '@ryoppippi/limo'; import getStdin from 'get-stdin'; import { define } from 'gunshi'; import pc from 'picocolors'; import * as v from 'valibot'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; import { DEFAULT_CONTEXT_USAGE_THRESHOLDS, DEFAULT_REFRESH_INTERVAL_SECONDS } from '../_consts.ts'; import { calculateBurnRate } from '../_session-blocks.ts'; import { sharedArgs } from '../_shared-args.ts'; import { statuslineHookJsonSchema } from '../_types.ts'; import { getFileModifiedTime, unreachable } from '../_utils.ts'; import { calculateTotals } from '../calculate-cost.ts'; import { calculateContextTokens, loadDailyUsageData, loadSessionBlockData, loadSessionUsageById, } from '../data-loader.ts'; import { log, logger } from '../logger.ts'; /** * Formats the remaining time for display * @param remaining - Remaining minutes * @returns Formatted time string */ function formatRemainingTime(remaining: number): string { const remainingHours = Math.floor(remaining / 60); const remainingMins = remaining % 60; if (remainingHours > 0) { return `${remainingHours}h ${remainingMins}m left`; } return `${remainingMins}m left`; } /** * Gets semaphore file for session-specific caching and process coordination * Uses time-based expiry and transcript file modification detection for cache invalidation */ function getSemaphore( sessionId: string, ): ReturnType> { const semaphoreDir = join(tmpdir(), 'ccusage-semaphore'); const semaphorePath = join(semaphoreDir, `${sessionId}.lock`); // Ensure semaphore directory exists mkdirSync(semaphoreDir, { recursive: true }); const semaphore = createLimoJson(semaphorePath); return semaphore; } /** * Semaphore structure for hybrid caching system * Combines time-based expiry with transcript file modification detection */ type SemaphoreType = { /** ISO timestamp of last update */ date: string; /** Cached status line output */ lastOutput: string; /** Timestamp (milliseconds) of last successful update for time-based expiry */ lastUpdateTime: number; /** Last processed transcript file path */ transcriptPath: string; /** Last modification time of transcript file for change detection */ transcriptMtime: number; /** Whether another process is currently updating (prevents concurrent updates) */ isUpdating?: boolean; /** Process ID of updating process for deadlock detection */ pid?: number; }; const visualBurnRateChoices = ['off', 'emoji', 'text', 'emoji-text'] as const; const costSourceChoices = ['auto', 'ccusage', 'cc', 'both'] as const; // Valibot schema for context threshold validation const contextThresholdSchema = v.pipe( v.union([ v.number(), v.pipe( v.string(), v.trim(), v.check((value) => /^-?\d+$/u.test(value), 'Context threshold must be an integer'), v.transform((value) => Number.parseInt(value, 10)), ), ]), v.number('Context threshold must be a number'), v.integer('Context threshold must be an integer'), v.minValue(0, 'Context threshold must be at least 0'), v.maxValue(100, 'Context threshold must be at most 100'), ); function parseContextThreshold(value: string): number { return v.parse(contextThresholdSchema, value); } export const statuslineCommand = define({ name: 'statusline', description: 'Display compact status line for Claude Code hooks with hybrid time+file caching (Beta)', toKebab: true, args: { offline: { ...sharedArgs.offline, default: true, // Default to offline mode for faster performance }, visualBurnRate: { type: 'enum', choices: visualBurnRateChoices, description: 'Controls the visualization of the burn rate status', default: 'off', // Use capital 'B' to avoid conflicts and follow 1-letter short alias rule short: 'B', negatable: false, toKebab: true, }, costSource: { type: 'enum', choices: costSourceChoices, description: 'Session cost source: auto (prefer CC then ccusage), ccusage (always calculate), cc (always use Claude Code cost), both (show both costs)', default: 'auto', negatable: false, toKebab: true, }, cache: { type: 'boolean', description: 'Enable cache for status line output (default: true)', negatable: true, default: true, }, refreshInterval: { type: 'number', description: `Refresh interval in seconds for cache expiry (default: ${DEFAULT_REFRESH_INTERVAL_SECONDS})`, default: DEFAULT_REFRESH_INTERVAL_SECONDS, }, contextLowThreshold: { type: 'custom', description: 'Context usage percentage below which status is shown in green (0-100)', parse: (value) => parseContextThreshold(value), default: DEFAULT_CONTEXT_USAGE_THRESHOLDS.LOW, }, contextMediumThreshold: { type: 'custom', description: 'Context usage percentage below which status is shown in yellow (0-100)', parse: (value) => parseContextThreshold(value), default: DEFAULT_CONTEXT_USAGE_THRESHOLDS.MEDIUM, }, config: sharedArgs.config, debug: sharedArgs.debug, }, async run(ctx) { // Set logger to silent for statusline output logger.level = 0; // Validate threshold ordering constraint: LOW must be less than MEDIUM if (ctx.values.contextLowThreshold >= ctx.values.contextMediumThreshold) { throw new Error( `Context low threshold (${ctx.values.contextLowThreshold}) must be less than medium threshold (${ctx.values.contextMediumThreshold})`, ); } // Load configuration and merge with CLI args const config = loadConfig(ctx.values.config, ctx.values.debug); const mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug); // Use refresh interval from merged options const refreshInterval = mergedOptions.refreshInterval; // Read input from stdin const stdin = await getStdin(); if (stdin.length === 0) { log('❌ No input provided'); process.exit(1); } // Parse input as JSON const hookDataJson: unknown = JSON.parse(stdin.trim()); const hookDataParseResult = v.safeParse(statuslineHookJsonSchema, hookDataJson); if (!hookDataParseResult.success) { log('❌ Invalid input format:', v.flatten(hookDataParseResult.issues)); process.exit(1); } const hookData = hookDataParseResult.output; // Extract session ID from hook data const sessionId = hookData.session_id; /** * Read initial semaphore state for cache validation and process checking * This is a snapshot taken at the beginning to avoid race conditions */ const initialSemaphoreState = Result.pipe( Result.succeed(getSemaphore(sessionId)), Result.map((semaphore) => semaphore.data), Result.unwrap(undefined), ); // Get current file modification time for cache validation and semaphore update const currentMtime = await getFileModifiedTime(hookData.transcript_path); if (mergedOptions.cache && initialSemaphoreState != null) { /** * Hybrid cache validation: * 1. Time-based expiry: Cache expires after refreshInterval seconds * 2. File modification: Immediate invalidation when transcript file is modified * This ensures real-time updates while maintaining good performance */ const now = Date.now(); const timeElapsed = now - (initialSemaphoreState.lastUpdateTime ?? 0); const isExpired = timeElapsed >= refreshInterval * 1000; const isFileModified = initialSemaphoreState.transcriptMtime !== currentMtime; if (!isExpired && !isFileModified) { // Cache is still valid, return cached output log(initialSemaphoreState.lastOutput); return; } // If another process is updating, return stale output if (initialSemaphoreState.isUpdating === true) { // Check if the updating process is still alive (optional deadlock protection) const pid = initialSemaphoreState.pid; let isProcessAlive = false; if (pid != null) { try { process.kill(pid, 0); // Signal 0 doesn't kill, just checks if process exists isProcessAlive = true; } catch { // Process doesn't exist, likely dead isProcessAlive = false; } } if (isProcessAlive) { // Another process is actively updating, return stale output log(initialSemaphoreState.lastOutput); return; } // Process is dead, continue to update ourselves } } // Acquisition phase: Mark as updating { const currentPid = process.pid; using semaphore = getSemaphore(sessionId); if (semaphore.data != null) { semaphore.data = { ...semaphore.data, isUpdating: true, pid: currentPid, } as const satisfies SemaphoreType; } else { const currentMtimeForInit = await getFileModifiedTime(hookData.transcript_path); semaphore.data = { date: new Date().toISOString(), lastOutput: '', lastUpdateTime: 0, transcriptPath: hookData.transcript_path, transcriptMtime: currentMtimeForInit, isUpdating: true, pid: currentPid, } as const satisfies SemaphoreType; } } const mainProcessingResult = Result.pipe( await Result.try({ try: async () => { // Determine session cost based on cost source const { sessionCost, ccCost, ccusageCost } = await (async (): Promise<{ sessionCost?: number; ccCost?: number; ccusageCost?: number; }> => { const costSource = ctx.values.costSource; // Helper function to get ccusage cost const getCcusageCost = async (): Promise => { return Result.pipe( Result.try({ try: async () => loadSessionUsageById(sessionId, { mode: 'auto', offline: mergedOptions.offline, }), catch: (error) => error, })(), Result.map((sessionCost) => sessionCost?.totalCost), Result.inspectError((error) => logger.error('Failed to load session data:', error)), Result.unwrap(undefined), ); }; // If 'both' mode, calculate both costs if (costSource === 'both') { const ccCost = hookData.cost?.total_cost_usd; const ccusageCost = await getCcusageCost(); return { ccCost, ccusageCost }; } // If 'cc' mode and cost is available from Claude Code, use it if (costSource === 'cc') { return { sessionCost: hookData.cost?.total_cost_usd }; } // If 'ccusage' mode, always calculate using ccusage if (costSource === 'ccusage') { const cost = await getCcusageCost(); return { sessionCost: cost }; } // If 'auto' mode (default), prefer Claude Code cost, fallback to ccusage if (costSource === 'auto') { if (hookData.cost?.total_cost_usd != null) { return { sessionCost: hookData.cost.total_cost_usd }; } // Fallback to ccusage calculation const cost = await getCcusageCost(); return { sessionCost: cost }; } unreachable(costSource); return {}; // This line should never be reached })(); // Load today's usage data const today = new Date(); const todayStr = today.toISOString().split('T')[0]?.replace(/-/g, '') ?? ''; // Convert to YYYYMMDD format const todayCost = await Result.pipe( Result.try({ try: async () => loadDailyUsageData({ since: todayStr, until: todayStr, mode: 'auto', offline: mergedOptions.offline, }), catch: (error) => error, })(), Result.map((dailyData) => { if (dailyData.length > 0) { const totals = calculateTotals(dailyData); return totals.totalCost; } return 0; }), Result.inspectError((error) => logger.error('Failed to load daily data:', error)), Result.unwrap(0), ); // Load session block data to find active block const { blockInfo, burnRateInfo } = await Result.pipe( Result.try({ try: async () => loadSessionBlockData({ mode: 'auto', offline: mergedOptions.offline, }), catch: (error) => error, })(), Result.map((blocks) => { // Only identify blocks if we have data if (blocks.length === 0) { return { blockInfo: 'No active block', burnRateInfo: '' }; } // Find active block that contains our session const activeBlock = blocks.find((block) => { if (!block.isActive) { return false; } // Check if any entry in this block matches our session // Since we don't have direct session mapping in entries, // we use the active block that's currently running return true; }); if (activeBlock != null) { const now = new Date(); const remaining = Math.round( (activeBlock.endTime.getTime() - now.getTime()) / (1000 * 60), ); const blockCost = activeBlock.costUSD; const blockInfo = `${formatCurrency(blockCost)} block (${formatRemainingTime(remaining)})`; // Calculate burn rate const burnRate = calculateBurnRate(activeBlock); const burnRateInfo = burnRate != null ? (() => { const renderEmojiStatus = ctx.values.visualBurnRate === 'emoji' || ctx.values.visualBurnRate === 'emoji-text'; const renderTextStatus = ctx.values.visualBurnRate === 'text' || ctx.values.visualBurnRate === 'emoji-text'; const costPerHour = burnRate.costPerHour; const costPerHourStr = `${formatCurrency(costPerHour)}/hr`; type BurnStatus = 'normal' | 'moderate' | 'high'; const burnStatus: BurnStatus = burnRate.tokensPerMinuteForIndicator < 2000 ? 'normal' : burnRate.tokensPerMinuteForIndicator < 5000 ? 'moderate' : 'high'; const burnStatusMappings: Record< BurnStatus, { emoji: string; textValue: string; coloredString: Formatter } > = { normal: { emoji: '🟢', textValue: 'Normal', coloredString: pc.green }, moderate: { emoji: '⚠️', textValue: 'Moderate', coloredString: pc.yellow, }, high: { emoji: '🚨', textValue: 'High', coloredString: pc.red }, }; const { emoji, textValue, coloredString } = burnStatusMappings[burnStatus]; const burnRateOutputSegments: string[] = [coloredString(costPerHourStr)]; if (renderEmojiStatus) { burnRateOutputSegments.push(emoji); } if (renderTextStatus) { burnRateOutputSegments.push(coloredString(`(${textValue})`)); } return ` | 🔥 ${burnRateOutputSegments.join(' ')}`; })() : ''; return { blockInfo, burnRateInfo }; } return { blockInfo: 'No active block', burnRateInfo: '' }; }), Result.inspectError((error) => logger.error('Failed to load block data:', error)), Result.unwrap({ blockInfo: 'No active block', burnRateInfo: '' }), ); // Helper function to format context info with color coding const formatContextInfo = (inputTokens: number, contextLimit: number): string => { const percentage = Math.round((inputTokens / contextLimit) * 100); const color = percentage < ctx.values.contextLowThreshold ? pc.green : percentage < ctx.values.contextMediumThreshold ? pc.yellow : pc.red; const coloredPercentage = color(`${percentage}%`); const tokenDisplay = inputTokens.toLocaleString(); return `${tokenDisplay} (${coloredPercentage})`; }; // Get context tokens from Claude Code hook data, or fall back to calculating from transcript const contextDataResult = hookData.context_window != null ? // Prefer context_window data from Claude Code hook if available Result.succeed({ inputTokens: hookData.context_window.total_input_tokens, contextLimit: hookData.context_window.context_window_size, }) : // Fall back to calculating context tokens from transcript await Result.try({ try: async () => calculateContextTokens( hookData.transcript_path, hookData.model.id, mergedOptions.offline, ), catch: (error) => error, })(); const contextInfo = Result.pipe( contextDataResult, Result.inspectError((error) => logger.debug( `Failed to calculate context tokens: ${error instanceof Error ? error.message : String(error)}`, ), ), Result.map((contextResult) => { if (contextResult == null) { return undefined; } return formatContextInfo(contextResult.inputTokens, contextResult.contextLimit); }), Result.unwrap(undefined), ); // Get model display name const modelName = hookData.model.display_name; // Format and output the status line // Format: 🤖 model | 💰 session / today / block | 🔥 burn | 🧠 context const sessionDisplay = (() => { // If both costs are available, show them side by side if (ccCost != null || ccusageCost != null) { const ccDisplay = ccCost != null ? formatCurrency(ccCost) : 'N/A'; const ccusageDisplay = ccusageCost != null ? formatCurrency(ccusageCost) : 'N/A'; return `(${ccDisplay} cc / ${ccusageDisplay} ccusage)`; } // Single cost display return sessionCost != null ? formatCurrency(sessionCost) : 'N/A'; })(); const statusLine = `🤖 ${modelName} | 💰 ${sessionDisplay} session / ${formatCurrency(todayCost)} today / ${blockInfo}${burnRateInfo} | 🧠 ${contextInfo ?? 'N/A'}`; return statusLine; }, catch: (error) => error, })(), ); if (Result.isSuccess(mainProcessingResult)) { const statusLine = mainProcessingResult.value; log(statusLine); if (!mergedOptions.cache) { return; } // update semaphore with result (use mtime from cache validation time) using semaphore = getSemaphore(sessionId); semaphore.data = { date: new Date().toISOString(), lastOutput: statusLine, lastUpdateTime: Date.now(), transcriptPath: hookData.transcript_path, transcriptMtime: currentMtime, // Use mtime from when we started processing isUpdating: false, pid: undefined, }; return; } // Handle processing result if (Result.isFailure(mainProcessingResult)) { // Reset updating flag on error to prevent deadlock // If we have a cached output from previous run, use it if (initialSemaphoreState?.lastOutput != null && initialSemaphoreState.lastOutput !== '') { log(initialSemaphoreState.lastOutput); } else { // Fallback minimal output log('❌ Error generating status'); } logger.error('Error in statusline command:', mainProcessingResult.error); if (!mergedOptions.cache) { return; } // Release semaphore and reset updating flag using semaphore = getSemaphore(sessionId); if (semaphore.data != null) { semaphore.data.isUpdating = false; semaphore.data.pid = undefined; } } }, }); ================================================ FILE: apps/ccusage/src/commands/weekly.ts ================================================ import type { UsageReportConfig } from '@ccusage/terminal/table'; import process from 'node:process'; import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; import { WEEK_DAYS } from '../_consts.ts'; import { formatDateCompact } from '../_date-utils.ts'; import { processWithJq } from '../_jq-processor.ts'; import { sharedArgs } from '../_shared-args.ts'; import { calculateTotals, createTotalsObject, getTotalTokens } from '../calculate-cost.ts'; import { loadWeeklyUsageData } from '../data-loader.ts'; import { detectMismatches, printMismatchReport } from '../debug.ts'; import { log, logger } from '../logger.ts'; export const weeklyCommand = define({ name: 'weekly', description: 'Show usage report grouped by week', args: { ...sharedArgs, startOfWeek: { type: 'enum', short: 'w', description: 'Day to start the week on', default: 'sunday' as const, choices: WEEK_DAYS, }, }, toKebab: true, async run(ctx) { // Load configuration and merge with CLI arguments const config = loadConfig(ctx.values.config, ctx.values.debug); const mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug); // --jq implies --json const useJson = Boolean(mergedOptions.json) || mergedOptions.jq != null; if (useJson) { logger.level = 0; } const weeklyData = await loadWeeklyUsageData(mergedOptions); if (weeklyData.length === 0) { if (useJson) { const emptyOutput = { weekly: [], totals: { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0, }, }; log(JSON.stringify(emptyOutput, null, 2)); } else { logger.warn('No Claude usage data found.'); } process.exit(0); } // Calculate totals const totals = calculateTotals(weeklyData); // Show debug information if requested if (mergedOptions.debug && !useJson) { const mismatchStats = await detectMismatches(undefined); printMismatchReport(mismatchStats, mergedOptions.debugSamples as number | undefined); } if (useJson) { // Output JSON format const jsonOutput = { weekly: weeklyData.map((data) => ({ week: data.week, inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalTokens: getTotalTokens(data), totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, })), totals: createTotalsObject(totals), }; // Process with jq if specified if (mergedOptions.jq != null) { const jqResult = await processWithJq(jsonOutput, mergedOptions.jq); if (Result.isFailure(jqResult)) { logger.error(jqResult.error.message); process.exit(1); } log(jqResult.value); } else { log(JSON.stringify(jsonOutput, null, 2)); } } else { // Print header logger.box('Claude Code Token Usage Report - Weekly'); // Create table with compact mode support const tableConfig: UsageReportConfig = { firstColumnName: 'Week', dateFormatter: (dateStr: string) => formatDateCompact(dateStr, mergedOptions.timezone, mergedOptions.locale ?? undefined), forceCompact: ctx.values.compact, }; const table = createUsageReportTable(tableConfig); // Add weekly data for (const data of weeklyData) { // Main row const row = formatUsageDataRow(data.week, { inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, }); table.push(row); // Add model breakdown rows if flag is set if (mergedOptions.breakdown) { pushBreakdownRows(table, data.modelBreakdowns); } } // Add empty row for visual separation before totals addEmptySeparatorRow(table, 8); // Add totals const totalsRow = formatTotalsRow({ inputTokens: totals.inputTokens, outputTokens: totals.outputTokens, cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, }); table.push(totalsRow); log(table.toString()); // Show guidance message if in compact mode if (table.isCompactMode()) { logger.info('\nRunning in Compact Mode'); logger.info('Expand terminal width to see cache metrics and total tokens'); } } }, }); ================================================ FILE: apps/ccusage/src/data-loader.ts ================================================ /** * @fileoverview Data loading utilities for Claude Code usage analysis * * This module provides functions for loading and parsing Claude Code usage data * from JSONL files stored in Claude data directories. It handles data aggregation * for daily, monthly, and session-based reporting. * * @module data-loader */ import type { WeekDay } from './_consts.ts'; import type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts'; import type { ActivityDate, Bucket, CostMode, ModelName, SortOrder, Version } from './_types.ts'; import { Buffer } from 'node:buffer'; import { createReadStream, createWriteStream } from 'node:fs'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { createInterface } from 'node:readline'; import { toArray } from '@antfu/utils'; import { Result } from '@praha/byethrow'; import { groupBy, uniq } from 'es-toolkit'; // TODO: after node20 is deprecated, switch to native Object.groupBy import { createFixture } from 'fs-fixture'; import { isDirectorySync } from 'path-type'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; import { CLAUDE_CONFIG_DIR_ENV, CLAUDE_PROJECTS_DIR_NAME, DEFAULT_CLAUDE_CODE_PATH, DEFAULT_CLAUDE_CONFIG_PATH, DEFAULT_LOCALE, USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR, } from './_consts.ts'; import { filterByDateRange, formatDate, formatDateCompact, getDateWeek, getDayNumber, sortByDate, } from './_date-utils.ts'; import { PricingFetcher } from './_pricing-fetcher.ts'; import { identifySessionBlocks } from './_session-blocks.ts'; import { activityDateSchema, createBucket, createDailyDate, createISOTimestamp, createMessageId, createModelName, createMonthlyDate, createProjectPath, createRequestId, createSessionId, createVersion, dailyDateSchema, isoTimestampSchema, messageIdSchema, modelNameSchema, monthlyDateSchema, projectPathSchema, requestIdSchema, sessionIdSchema, versionSchema, weeklyDateSchema, } from './_types.ts'; import { unreachable } from './_utils.ts'; import { logger } from './logger.ts'; /** * Get Claude data directories to search for usage data * When CLAUDE_CONFIG_DIR is set: uses only those paths * When not set: uses default paths (~/.config/claude and ~/.claude) * @returns Array of valid Claude data directory paths */ export function getClaudePaths(): string[] { const paths = []; const normalizedPaths = new Set(); // Check environment variable first (supports comma-separated paths) const envPaths = (process.env[CLAUDE_CONFIG_DIR_ENV] ?? '').trim(); if (envPaths !== '') { const envPathList = envPaths .split(',') .map((p) => p.trim()) .filter((p) => p !== ''); for (const envPath of envPathList) { const normalizedPath = path.resolve(envPath); if (isDirectorySync(normalizedPath)) { const projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME); if (isDirectorySync(projectsPath)) { // Avoid duplicates using normalized paths if (!normalizedPaths.has(normalizedPath)) { normalizedPaths.add(normalizedPath); paths.push(normalizedPath); } } } } // If environment variable is set, return only those paths (or error if none valid) if (paths.length > 0) { return paths; } // If environment variable is set but no valid paths found, throw error throw new Error( `No valid Claude data directories found in CLAUDE_CONFIG_DIR. Please ensure the following exists: - ${envPaths}/${CLAUDE_PROJECTS_DIR_NAME}`.trim(), ); } // Only check default paths if no environment variable is set const defaultPaths = [ DEFAULT_CLAUDE_CONFIG_PATH, // New default: XDG config directory path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH), // Old default: ~/.claude ]; for (const defaultPath of defaultPaths) { const normalizedPath = path.resolve(defaultPath); if (isDirectorySync(normalizedPath)) { const projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME); if (isDirectorySync(projectsPath)) { // Avoid duplicates using normalized paths if (!normalizedPaths.has(normalizedPath)) { normalizedPaths.add(normalizedPath); paths.push(normalizedPath); } } } } if (paths.length === 0) { throw new Error( `No valid Claude data directories found. Please ensure at least one of the following exists: - ${path.join(DEFAULT_CLAUDE_CONFIG_PATH, CLAUDE_PROJECTS_DIR_NAME)} - ${path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH, CLAUDE_PROJECTS_DIR_NAME)} - Or set ${CLAUDE_CONFIG_DIR_ENV} environment variable to valid directory path(s) containing a '${CLAUDE_PROJECTS_DIR_NAME}' subdirectory`.trim(), ); } return paths; } /** * Extract project name from Claude JSONL file path * @param jsonlPath - Absolute path to JSONL file * @returns Project name extracted from path, or "unknown" if malformed */ export function extractProjectFromPath(jsonlPath: string): string { // Normalize path separators for cross-platform compatibility const normalizedPath = jsonlPath.replace(/[/\\]/g, path.sep); const segments = normalizedPath.split(path.sep); const projectsIndex = segments.findIndex((segment) => segment === CLAUDE_PROJECTS_DIR_NAME); if (projectsIndex === -1 || projectsIndex + 1 >= segments.length) { return 'unknown'; } const projectName = segments[projectsIndex + 1]; return projectName != null && projectName.trim() !== '' ? projectName : 'unknown'; } /** * Valibot schema for validating Claude usage data from JSONL files */ export const usageDataSchema = v.object({ cwd: v.optional(v.string()), // Claude Code version, optional for compatibility sessionId: v.optional(sessionIdSchema), // Session ID for deduplication timestamp: isoTimestampSchema, version: v.optional(versionSchema), // Claude Code version message: v.object({ usage: v.object({ input_tokens: v.number(), output_tokens: v.number(), cache_creation_input_tokens: v.optional(v.number()), cache_read_input_tokens: v.optional(v.number()), speed: v.optional(v.picklist(['standard', 'fast'])), }), model: v.optional(modelNameSchema), // Model is inside message object id: v.optional(messageIdSchema), // Message ID for deduplication content: v.optional( v.array( v.object({ text: v.optional(v.string()), }), ), ), }), costUSD: v.optional(v.number()), // Made optional for new schema requestId: v.optional(requestIdSchema), // Request ID for deduplication isApiErrorMessage: v.optional(v.boolean()), }); /** * Valibot schema for transcript usage data from Claude messages */ export const transcriptUsageSchema = v.object({ input_tokens: v.optional(v.number()), cache_creation_input_tokens: v.optional(v.number()), cache_read_input_tokens: v.optional(v.number()), output_tokens: v.optional(v.number()), }); /** * Valibot schema for transcript message data */ export const transcriptMessageSchema = v.object({ type: v.optional(v.string()), message: v.optional( v.object({ usage: v.optional(transcriptUsageSchema), }), ), }); /** * Type definition for Claude usage data entries from JSONL files */ export type UsageData = v.InferOutput; /** * Valibot schema for model-specific usage breakdown data */ export const modelBreakdownSchema = v.object({ modelName: modelNameSchema, inputTokens: v.number(), outputTokens: v.number(), cacheCreationTokens: v.number(), cacheReadTokens: v.number(), cost: v.number(), }); /** * Type definition for model-specific usage breakdown */ export type ModelBreakdown = v.InferOutput; /** * Valibot schema for daily usage aggregation data */ export const dailyUsageSchema = v.object({ date: dailyDateSchema, // YYYY-MM-DD format inputTokens: v.number(), outputTokens: v.number(), cacheCreationTokens: v.number(), cacheReadTokens: v.number(), totalCost: v.number(), modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), project: v.optional(v.string()), // Project name when groupByProject is enabled }); /** * Type definition for daily usage aggregation */ export type DailyUsage = v.InferOutput; /** * Valibot schema for session-based usage aggregation data */ export const sessionUsageSchema = v.object({ sessionId: sessionIdSchema, projectPath: projectPathSchema, inputTokens: v.number(), outputTokens: v.number(), cacheCreationTokens: v.number(), cacheReadTokens: v.number(), totalCost: v.number(), lastActivity: activityDateSchema, versions: v.array(versionSchema), // List of unique versions used in this session modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), }); /** * Type definition for session-based usage aggregation */ export type SessionUsage = v.InferOutput; /** * Valibot schema for monthly usage aggregation data */ export const monthlyUsageSchema = v.object({ month: monthlyDateSchema, // YYYY-MM format inputTokens: v.number(), outputTokens: v.number(), cacheCreationTokens: v.number(), cacheReadTokens: v.number(), totalCost: v.number(), modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), project: v.optional(v.string()), // Project name when groupByProject is enabled }); /** * Type definition for monthly usage aggregation */ export type MonthlyUsage = v.InferOutput; /** * Valibot schema for weekly usage aggregation data */ export const weeklyUsageSchema = v.object({ week: weeklyDateSchema, // YYYY-MM-DD format inputTokens: v.number(), outputTokens: v.number(), cacheCreationTokens: v.number(), cacheReadTokens: v.number(), totalCost: v.number(), modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), project: v.optional(v.string()), // Project name when groupByProject is enabled }); /** * Type definition for weekly usage aggregation */ export type WeeklyUsage = v.InferOutput; /** * Valibot schema for bucket usage aggregation data */ export const bucketUsageSchema = v.object({ bucket: v.union([weeklyDateSchema, monthlyDateSchema]), // WeeklyDate or MonthlyDate inputTokens: v.number(), outputTokens: v.number(), cacheCreationTokens: v.number(), cacheReadTokens: v.number(), totalCost: v.number(), modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), project: v.optional(v.string()), // Project name when groupByProject is enabled }); /** * Type definition for bucket usage aggregation */ export type BucketUsage = v.InferOutput; /** * Internal type for aggregating token statistics and costs */ type TokenStats = { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; }; function getDisplayModelName(data: UsageData): string | undefined { const model = data.message.model; if (model == null) { return undefined; } return data.message.usage.speed === 'fast' ? `${model}-fast` : model; } /** * Aggregates token counts and costs by model name */ function aggregateByModel( entries: T[], getModel: (entry: T) => string | undefined, getUsage: (entry: T) => UsageData['message']['usage'], getCost: (entry: T) => number, ): Map { const modelAggregates = new Map(); const defaultStats: TokenStats = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0, }; for (const entry of entries) { const modelName = getModel(entry) ?? 'unknown'; // Skip synthetic model if (modelName === '') { continue; } const usage = getUsage(entry); const cost = getCost(entry); const existing = modelAggregates.get(modelName) ?? defaultStats; modelAggregates.set(modelName, { inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), cacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: existing.cost + cost, }); } return modelAggregates; } /** * Aggregates model breakdowns from multiple sources */ function aggregateModelBreakdowns(breakdowns: ModelBreakdown[]): Map { const modelAggregates = new Map(); const defaultStats: TokenStats = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0, }; for (const breakdown of breakdowns) { // Skip synthetic model if (breakdown.modelName === '') { continue; } const existing = modelAggregates.get(breakdown.modelName) ?? defaultStats; modelAggregates.set(breakdown.modelName, { inputTokens: existing.inputTokens + breakdown.inputTokens, outputTokens: existing.outputTokens + breakdown.outputTokens, cacheCreationTokens: existing.cacheCreationTokens + breakdown.cacheCreationTokens, cacheReadTokens: existing.cacheReadTokens + breakdown.cacheReadTokens, cost: existing.cost + breakdown.cost, }); } return modelAggregates; } /** * Converts model aggregates to sorted model breakdowns */ function createModelBreakdowns(modelAggregates: Map): ModelBreakdown[] { return Array.from(modelAggregates.entries()) .map(([modelName, stats]) => ({ modelName: modelName as ModelName, ...stats, })) .sort((a, b) => b.cost - a.cost); // Sort by cost descending } /** * Calculates total token counts and costs from entries */ function calculateTotals( entries: T[], getUsage: (entry: T) => UsageData['message']['usage'], getCost: (entry: T) => number, ): TokenStats & { totalCost: number } { return entries.reduce( (acc, entry) => { const usage = getUsage(entry); const cost = getCost(entry); return { inputTokens: acc.inputTokens + (usage.input_tokens ?? 0), outputTokens: acc.outputTokens + (usage.output_tokens ?? 0), cacheCreationTokens: acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: acc.cost + cost, totalCost: acc.totalCost + cost, }; }, { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0, totalCost: 0, }, ); } /** * Filters items by project name */ function filterByProject( items: T[], getProject: (item: T) => string | undefined, projectFilter?: string, ): T[] { if (projectFilter == null) { return items; } return items.filter((item) => { const projectName = getProject(item); return projectName === projectFilter; }); } /** * Checks if an entry is a duplicate based on hash */ function isDuplicateEntry(uniqueHash: string | null, processedHashes: Set): boolean { if (uniqueHash == null) { return false; } return processedHashes.has(uniqueHash); } /** * Marks an entry as processed */ function markAsProcessed(uniqueHash: string | null, processedHashes: Set): void { if (uniqueHash != null) { processedHashes.add(uniqueHash); } } /** * Extracts unique models from entries, excluding synthetic model */ function extractUniqueModels( entries: T[], getModel: (entry: T) => string | undefined, ): string[] { return uniq(entries.map(getModel).filter((m): m is string => m != null && m !== '')); } /** * Create a unique identifier for deduplication using message ID and request ID */ export function createUniqueHash(data: UsageData): string | null { const messageId = data.message.id; const requestId = data.requestId; if (messageId == null || requestId == null) { return null; } // Create a hash using simple concatenation return `${messageId}:${requestId}`; } /** * Process a JSONL file line by line using streams to avoid memory issues with large files * @param filePath - Path to the JSONL file * @param processLine - Callback function to process each line */ async function processJSONLFileByLine( filePath: string, processLine: (line: string, lineNumber: number) => void | Promise, ): Promise { const fileStream = createReadStream(filePath, { encoding: 'utf-8' }); const rl = createInterface({ input: fileStream, crlfDelay: Number.POSITIVE_INFINITY, }); let lineNumber = 0; for await (const line of rl) { lineNumber++; if (line.trim().length === 0) { continue; } await processLine(line, lineNumber); } } /** * Extract the earliest timestamp from a JSONL file * Scans through the file until it finds a valid timestamp * Uses streaming to handle large files without loading entire content into memory */ export async function getEarliestTimestamp(filePath: string): Promise { try { let earliestDate: Date | null = null; await processJSONLFileByLine(filePath, (line) => { try { const json = JSON.parse(line) as Record; if (json.timestamp != null && typeof json.timestamp === 'string') { const date = new Date(json.timestamp); if (!Number.isNaN(date.getTime())) { if (earliestDate == null || date < earliestDate) { earliestDate = date; } } } } catch { // Skip invalid JSON lines } }); return earliestDate; } catch (error) { // Log file access errors for diagnostics, but continue processing // This ensures files without timestamps or with access issues are sorted to the end logger.debug(`Failed to get earliest timestamp for ${filePath}:`, error); return null; } } /** * Sort files by their earliest timestamp * Files without valid timestamps are placed at the end */ export async function sortFilesByTimestamp(files: string[]): Promise { const filesWithTimestamps = await Promise.all( files.map(async (file) => ({ file, timestamp: await getEarliestTimestamp(file), })), ); return filesWithTimestamps .sort((a, b) => { // Files without timestamps go to the end if (a.timestamp == null && b.timestamp == null) { return 0; } if (a.timestamp == null) { return 1; } if (b.timestamp == null) { return -1; } // Sort by timestamp (oldest first) return a.timestamp.getTime() - b.timestamp.getTime(); }) .map((item) => item.file); } /** * Calculates cost for a single usage data entry based on the specified cost calculation mode * @param data - Usage data entry * @param mode - Cost calculation mode (auto, calculate, or display) * @param fetcher - Pricing fetcher instance for calculating costs from tokens * @returns Calculated cost in USD */ export async function calculateCostForEntry( data: UsageData, mode: CostMode, fetcher: PricingFetcher, ): Promise { const speed = data.message.usage.speed; if (mode === 'display') { // Always use costUSD, even if undefined return data.costUSD ?? 0; } if (mode === 'calculate') { // Always calculate from tokens if (data.message.model != null) { return Result.unwrap( fetcher.calculateCostFromTokens(data.message.usage, data.message.model, { speed }), 0, ); } return 0; } if (mode === 'auto') { // Auto mode: use costUSD if available, otherwise calculate if (data.costUSD != null) { return data.costUSD; } if (data.message.model != null) { return Result.unwrap( fetcher.calculateCostFromTokens(data.message.usage, data.message.model, { speed }), 0, ); } return 0; } unreachable(mode); } /** * Get Claude Code usage limit expiration date * @param data - Usage data entry * @returns Usage limit expiration date */ export function getUsageLimitResetTime(data: UsageData): Date | null { let resetTime: Date | null = null; if (data.isApiErrorMessage === true) { const timestampMatch = data.message?.content ?.find((c) => c.text != null && c.text.includes('Claude AI usage limit reached')) ?.text?.match(/\|(\d+)/) ?? null; if (timestampMatch?.[1] != null) { const resetTimestamp = Number.parseInt(timestampMatch[1]); resetTime = resetTimestamp > 0 ? new Date(resetTimestamp * 1000) : null; } } return resetTime; } /** * Result of glob operation with base directory information */ export type GlobResult = { file: string; baseDir: string; }; /** * Glob files from multiple Claude paths in parallel * @param claudePaths - Array of Claude base paths * @returns Array of file paths with their base directories */ export async function globUsageFiles(claudePaths: string[]): Promise { const filePromises = claudePaths.map(async (claudePath) => { const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); const files = await glob([USAGE_DATA_GLOB_PATTERN], { cwd: claudeDir, absolute: true, }).catch(() => []); // Gracefully handle errors for individual paths // Map each file to include its base directory return files.map((file) => ({ file, baseDir: claudeDir })); }); return (await Promise.all(filePromises)).flat(); } /** * Date range filter for limiting usage data by date */ export type DateFilter = { since?: string; // YYYYMMDD format until?: string; // YYYYMMDD format }; /** * Configuration options for loading usage data */ export type LoadOptions = { claudePath?: string; // Custom path to Claude data directory mode?: CostMode; // Cost calculation mode order?: SortOrder; // Sort order for dates offline?: boolean; // Use offline mode for pricing sessionDurationHours?: number; // Session block duration in hours groupByProject?: boolean; // Group data by project instead of aggregating project?: string; // Filter to specific project name startOfWeek?: WeekDay; // Start of week for weekly aggregation timezone?: string; // Timezone for date grouping (e.g., 'UTC', 'America/New_York'). Defaults to system timezone locale?: string; // Locale for date/time formatting (e.g., 'en-US', 'ja-JP'). Defaults to 'en-US' } & DateFilter; /** * Loads and aggregates Claude usage data by day * Processes all JSONL files in the Claude projects directory and groups usage by date * @param options - Optional configuration for loading and filtering data * @returns Array of daily usage summaries sorted by date */ export async function loadDailyUsageData(options?: LoadOptions): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); // Collect files from all paths in parallel const allFiles = await globUsageFiles(claudePaths); const fileList = allFiles.map((f) => f.file); if (fileList.length === 0) { return []; } // Filter by project if specified const projectFilteredFiles = filterByProject( fileList, (filePath) => extractProjectFromPath(filePath), options?.project, ); // Sort files by timestamp to ensure chronological processing const sortedFiles = await sortFilesByTimestamp(projectFilteredFiles); // Fetch pricing data for cost calculation only when needed const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); // Collect all valid data entries first const allEntries: { data: UsageData; date: string; cost: number; model: string | undefined; project: string; }[] = []; for (const file of sortedFiles) { // Extract project name from file path once per file const project = extractProjectFromPath(file); await processJSONLFileByLine(file, async (line) => { try { const parsed = JSON.parse(line) as unknown; const result = v.safeParse(usageDataSchema, parsed); if (!result.success) { return; } const data = result.output; // Check for duplicate message + request ID combination const uniqueHash = createUniqueHash(data); if (isDuplicateEntry(uniqueHash, processedHashes)) { // Skip duplicate message return; } // Mark this combination as processed markAsProcessed(uniqueHash, processedHashes); // Always use DEFAULT_LOCALE for date grouping to ensure YYYY-MM-DD format const date = formatDate(data.timestamp, options?.timezone, DEFAULT_LOCALE); // If fetcher is available, calculate cost based on mode and tokens // If fetcher is null, use pre-calculated costUSD or default to 0 const cost = fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0); allEntries.push({ data, date, cost, model: getDisplayModelName(data), project }); } catch { // Skip invalid JSON lines } }); } // Group by date, optionally including project // Automatically enable project grouping when project filter is specified const needsProjectGrouping = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping ? (entry: (typeof allEntries)[0]) => `${entry.date}\x00${entry.project}` : (entry: (typeof allEntries)[0]) => entry.date; const groupedData = groupBy(allEntries, groupingKey); // Aggregate each group const results = Object.entries(groupedData) .map(([groupKey, entries]) => { if (entries == null) { return undefined; } // Extract date and project from groupKey (format: "date" or "date\x00project") const parts = groupKey.split('\x00'); const date = parts[0] ?? groupKey; const project = parts.length > 1 ? parts[1] : undefined; // Aggregate by model first const modelAggregates = aggregateByModel( entries, (entry) => entry.model, (entry) => entry.data.message.usage, (entry) => entry.cost, ); // Create model breakdowns const modelBreakdowns = createModelBreakdowns(modelAggregates); // Calculate totals const totals = calculateTotals( entries, (entry) => entry.data.message.usage, (entry) => entry.cost, ); const modelsUsed = extractUniqueModels(entries, (e) => e.model); return { date: createDailyDate(date), ...totals, modelsUsed: modelsUsed as ModelName[], modelBreakdowns, ...(project != null && { project }), }; }) .filter((item) => item != null); // Filter by date range if specified const dateFiltered = filterByDateRange( results, (item) => item.date, options?.since, options?.until, ); // Filter by project if specified const finalFiltered = filterByProject(dateFiltered, (item) => item.project, options?.project); // Sort by date based on order option (default to descending) return sortByDate(finalFiltered, (item) => item.date, options?.order); } /** * Loads and aggregates Claude usage data by session * Groups usage data by project path and session ID based on file structure * @param options - Optional configuration for loading and filtering data * @returns Array of session usage summaries sorted by last activity */ export async function loadSessionData(options?: LoadOptions): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); // Collect files from all paths with their base directories in parallel const filesWithBase = await globUsageFiles(claudePaths); if (filesWithBase.length === 0) { return []; } // Filter by project if specified const projectFilteredWithBase = filterByProject( filesWithBase, (item) => extractProjectFromPath(item.file), options?.project, ); // Sort files by timestamp to ensure chronological processing // Create a map for O(1) lookup instead of O(N) find operations const fileToBaseMap = new Map(projectFilteredWithBase.map((f) => [f.file, f.baseDir])); const sortedFilesWithBase = await sortFilesByTimestamp( projectFilteredWithBase.map((f) => f.file), ).then((sortedFiles) => sortedFiles.map((file) => ({ file, baseDir: fileToBaseMap.get(file) ?? '', })), ); // Fetch pricing data for cost calculation only when needed const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); // Collect all valid data entries with session info first const allEntries: Array<{ data: UsageData; sessionKey: string; sessionId: string; projectPath: string; cost: number; timestamp: string; model: string | undefined; }> = []; for (const { file, baseDir } of sortedFilesWithBase) { // Extract session info from file path using its specific base directory const relativePath = path.relative(baseDir, file); const parts = relativePath.split(path.sep); // Session ID is the directory name containing the JSONL file const sessionId = parts[parts.length - 2] ?? 'unknown'; // Project path is everything before the session ID const joinedPath = parts.slice(0, -2).join(path.sep); const projectPath = joinedPath.length > 0 ? joinedPath : 'Unknown Project'; await processJSONLFileByLine(file, async (line) => { try { const parsed = JSON.parse(line) as unknown; const result = v.safeParse(usageDataSchema, parsed); if (!result.success) { return; } const data = result.output; // Check for duplicate message + request ID combination const uniqueHash = createUniqueHash(data); if (isDuplicateEntry(uniqueHash, processedHashes)) { // Skip duplicate message return; } // Mark this combination as processed markAsProcessed(uniqueHash, processedHashes); const sessionKey = `${projectPath}/${sessionId}`; const cost = fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0); allEntries.push({ data, sessionKey, sessionId, projectPath, cost, timestamp: data.timestamp, model: getDisplayModelName(data), }); } catch { // Skip invalid JSON lines } }); } // Group by session using Object.groupBy const groupedBySessions = groupBy(allEntries, (entry) => entry.sessionKey); // Aggregate each session group const results = Object.entries(groupedBySessions) .map(([_, entries]) => { if (entries == null) { return undefined; } // Find the latest timestamp for lastActivity const latestEntry = entries.reduce((latest, current) => current.timestamp > latest.timestamp ? current : latest, ); // Collect all unique versions const versions: string[] = []; for (const entry of entries) { if (entry.data.version != null) { versions.push(entry.data.version); } } // Aggregate by model const modelAggregates = aggregateByModel( entries, (entry) => entry.model, (entry) => entry.data.message.usage, (entry) => entry.cost, ); // Create model breakdowns const modelBreakdowns = createModelBreakdowns(modelAggregates); // Calculate totals const totals = calculateTotals( entries, (entry) => entry.data.message.usage, (entry) => entry.cost, ); const modelsUsed = extractUniqueModels(entries, (e) => e.model); return { sessionId: createSessionId(latestEntry.sessionId), projectPath: createProjectPath(latestEntry.projectPath), ...totals, // Always use DEFAULT_LOCALE for date storage to ensure YYYY-MM-DD format lastActivity: formatDate( latestEntry.timestamp, options?.timezone, DEFAULT_LOCALE, ) as ActivityDate, versions: uniq(versions).sort() as Version[], modelsUsed: modelsUsed as ModelName[], modelBreakdowns, }; }) .filter((item) => item != null); // Filter by date range if specified const dateFiltered = filterByDateRange( results, (item) => item.lastActivity, options?.since, options?.until, ); // Filter by project if specified const sessionFiltered = filterByProject( dateFiltered, (item) => item.projectPath, options?.project, ); return sortByDate(sessionFiltered, (item) => item.lastActivity, options?.order); } /** * Loads and aggregates Claude usage data by month * Uses daily usage data as the source and groups by month * @param options - Optional configuration for loading and filtering data * @returns Array of monthly usage summaries sorted by month */ export async function loadMonthlyUsageData(options?: LoadOptions): Promise { return loadBucketUsageData( (data: DailyUsage) => createMonthlyDate(data.date.slice(0, 7)), options, ).then((usages) => usages.map(({ bucket, ...rest }) => ({ month: v.parse(monthlyDateSchema, bucket), ...rest, })), ); } export async function loadWeeklyUsageData(options?: LoadOptions): Promise { const startDay = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); return loadBucketUsageData( (data: DailyUsage) => getDateWeek(new Date(data.date), startDay), options, ).then((usages) => usages.map(({ bucket, ...rest }) => ({ week: v.parse(weeklyDateSchema, bucket), ...rest, })), ); } /** * Load usage data for a specific session by sessionId * Searches for a JSONL file named {sessionId}.jsonl in all Claude project directories * @param sessionId - The session ID to load data for (matches the JSONL filename) * @param options - Options for loading data * @param options.mode - Cost calculation mode (auto, calculate, display) * @param options.offline - Whether to use offline pricing data * @returns Usage data for the specific session or null if not found */ export async function loadSessionUsageById( sessionId: string, options?: { mode?: CostMode; offline?: boolean }, ): Promise<{ totalCost: number; entries: UsageData[] } | null> { const claudePaths = getClaudePaths(); // Find the JSONL file for this session ID // On Windows, replace backslashes from path.join with forward slashes for tinyglobby compatibility const patterns = claudePaths.map((p) => path.join(p, 'projects', '**', `${sessionId}.jsonl`).replace(/\\/g, '/'), ); const jsonlFiles = await glob(patterns); if (jsonlFiles.length === 0) { return null; } const file = jsonlFiles[0]; if (file == null) { return null; } const mode = options?.mode ?? 'auto'; using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); const entries: UsageData[] = []; let totalCost = 0; await processJSONLFileByLine(file, async (line) => { try { const parsed = JSON.parse(line) as unknown; const result = v.safeParse(usageDataSchema, parsed); if (!result.success) { return; } const data = result.output; const cost = fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0); totalCost += cost; entries.push(data); } catch { // Skip invalid JSON lines } }); return { totalCost, entries }; } export async function loadBucketUsageData( groupingFn: (data: DailyUsage) => Bucket, options?: LoadOptions, ): Promise { const dailyData = await loadDailyUsageData(options); // Group daily data by week, optionally including project // Automatically enable project grouping when project filter is specified const needsProjectGrouping = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping ? (data: DailyUsage) => { const bucketValue = groupingFn(data); const projectSegment = data.project ?? 'unknown'; return `${bucketValue}\x00${projectSegment}`; } : (data: DailyUsage) => `${groupingFn(data)}`; const grouped = groupBy(dailyData, groupingKey); const buckets: BucketUsage[] = []; for (const [groupKey, dailyEntries] of Object.entries(grouped)) { if (dailyEntries == null) { continue; } const parts = groupKey.split('\x00'); const bucket = createBucket(parts[0] ?? groupKey); const project = parts.length > 1 ? parts[1] : undefined; // Aggregate model breakdowns across all days const allBreakdowns = dailyEntries.flatMap((daily) => daily.modelBreakdowns); const modelAggregates = aggregateModelBreakdowns(allBreakdowns); // Create model breakdowns const modelBreakdowns = createModelBreakdowns(modelAggregates); // Collect unique models const models: string[] = []; for (const data of dailyEntries) { for (const model of data.modelsUsed) { // Skip synthetic model if (model !== '') { models.push(model); } } } // Calculate totals from daily entries let totalInputTokens = 0; let totalOutputTokens = 0; let totalCacheCreationTokens = 0; let totalCacheReadTokens = 0; let totalCost = 0; for (const daily of dailyEntries) { totalInputTokens += daily.inputTokens; totalOutputTokens += daily.outputTokens; totalCacheCreationTokens += daily.cacheCreationTokens; totalCacheReadTokens += daily.cacheReadTokens; totalCost += daily.totalCost; } const bucketUsage: BucketUsage = { bucket, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cacheCreationTokens: totalCacheCreationTokens, cacheReadTokens: totalCacheReadTokens, totalCost, modelsUsed: uniq(models) as ModelName[], modelBreakdowns, ...(project != null && { project }), }; buckets.push(bucketUsage); } return sortByDate(buckets, (item) => item.bucket, options?.order); } /** * Calculate context tokens from transcript file using improved JSONL parsing * Based on the Python reference implementation for better accuracy * @param transcriptPath - Path to the transcript JSONL file * @returns Object with context tokens info or null if unavailable */ export async function calculateContextTokens( transcriptPath: string, modelId?: string, offline = false, ): Promise<{ inputTokens: number; percentage: number; contextLimit: number; } | null> { let content: string; try { content = await readFile(transcriptPath, 'utf-8'); } catch (error: unknown) { logger.debug(`Failed to read transcript file: ${String(error)}`); return null; } const lines = content.split('\n').reverse(); // Iterate from last line to first line for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine === '') { continue; } try { const parsed = JSON.parse(trimmedLine) as unknown; const result = v.safeParse(transcriptMessageSchema, parsed); if (!result.success) { continue; // Skip malformed JSON lines } const obj = result.output; // Check if this line contains the required token usage fields if ( obj.type === 'assistant' && obj.message != null && obj.message.usage != null && obj.message.usage.input_tokens != null ) { const usage = obj.message.usage; const inputTokens = usage.input_tokens! + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); // Get context limit from PricingFetcher let contextLimit = 200_000; // Fallback for when modelId is not provided if (modelId != null && modelId !== '') { using fetcher = new PricingFetcher(offline); const contextLimitResult = await fetcher.getModelContextLimit(modelId); if (Result.isSuccess(contextLimitResult) && contextLimitResult.value != null) { contextLimit = contextLimitResult.value; } else if (Result.isSuccess(contextLimitResult)) { // Context limit not available for this model in LiteLLM logger.debug(`No context limit data available for model ${modelId} in LiteLLM`); } else { // Error occurred logger.debug( `Failed to get context limit for model ${modelId}: ${contextLimitResult.error.message}`, ); } } const percentage = Math.min( 100, Math.max(0, Math.round((inputTokens / contextLimit) * 100)), ); return { inputTokens, percentage, contextLimit, }; } } catch { continue; // Skip malformed JSON lines } } // No valid usage information found logger.debug('No usage information found in transcript'); return null; } /** * Loads usage data and organizes it into session blocks (typically 5-hour billing periods) * Processes all usage data and groups it into time-based blocks for billing analysis * @param options - Optional configuration including session duration and filtering * @returns Array of session blocks with usage and cost information */ export async function loadSessionBlockData(options?: LoadOptions): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); // Collect files from all paths const allFiles: string[] = []; for (const claudePath of claudePaths) { const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); const files = await glob([USAGE_DATA_GLOB_PATTERN], { cwd: claudeDir, absolute: true, }); allFiles.push(...files); } if (allFiles.length === 0) { return []; } // Filter by project if specified const blocksFilteredFiles = filterByProject( allFiles, (filePath) => extractProjectFromPath(filePath), options?.project, ); // Sort files by timestamp to ensure chronological processing const sortedFiles = await sortFilesByTimestamp(blocksFilteredFiles); // Fetch pricing data for cost calculation only when needed const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); // Collect all valid data entries first const allEntries: LoadedUsageEntry[] = []; for (const file of sortedFiles) { await processJSONLFileByLine(file, async (line) => { try { const parsed = JSON.parse(line) as unknown; const result = v.safeParse(usageDataSchema, parsed); if (!result.success) { return; } const data = result.output; // Check for duplicate message + request ID combination const uniqueHash = createUniqueHash(data); if (isDuplicateEntry(uniqueHash, processedHashes)) { // Skip duplicate message return; } // Mark this combination as processed markAsProcessed(uniqueHash, processedHashes); const cost = fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0); // Get Claude Code usage limit expiration date const usageLimitResetTime = getUsageLimitResetTime(data); allEntries.push({ timestamp: new Date(data.timestamp), usage: { inputTokens: data.message.usage.input_tokens, outputTokens: data.message.usage.output_tokens, cacheCreationInputTokens: data.message.usage.cache_creation_input_tokens ?? 0, cacheReadInputTokens: data.message.usage.cache_read_input_tokens ?? 0, }, costUSD: cost, model: getDisplayModelName(data) ?? 'unknown', version: data.version, usageLimitResetTime: usageLimitResetTime ?? undefined, }); } catch (error) { // Skip invalid JSON lines but log for debugging purposes logger.debug( `Skipping invalid JSON line in 5-hour blocks: ${error instanceof Error ? error.message : String(error)}`, ); } }); } // Identify session blocks const blocks = identifySessionBlocks(allEntries, options?.sessionDurationHours); // Filter by date range if specified const dateFiltered = (options?.since != null && options.since !== '') || (options?.until != null && options.until !== '') ? blocks.filter((block) => { // Always use DEFAULT_LOCALE for date comparison to ensure YYYY-MM-DD format const blockDateStr = formatDate( block.startTime.toISOString(), options?.timezone, DEFAULT_LOCALE, ).replace(/-/g, ''); if (options.since != null && options.since !== '' && blockDateStr < options.since) { return false; } if (options.until != null && options.until !== '' && blockDateStr > options.until) { return false; } return true; }) : blocks; // Sort by start time based on order option return sortByDate(dateFiltered, (block) => block.startTime, options?.order); } if (import.meta.vitest != null) { describe('formatDate', () => { it('formats UTC timestamp to local date', () => { // Test with UTC timestamps - results depend on local timezone expect(formatDate('2024-01-01T00:00:00Z')).toBe('2024-01-01'); expect(formatDate('2024-12-31T23:59:59Z')).toBe('2024-12-31'); }); it('respects timezone parameter', () => { // Test date that crosses day boundary const testTimestamp = '2024-01-01T15:00:00Z'; // 3 PM UTC = midnight JST next day // Default behavior (no timezone) uses system timezone expect(formatDate(testTimestamp)).toMatch(/^\d{4}-\d{2}-\d{2}$/); // UTC timezone expect(formatDate(testTimestamp, 'UTC')).toBe('2024-01-01'); // Asia/Tokyo timezone (crosses to next day) expect(formatDate(testTimestamp, 'Asia/Tokyo')).toBe('2024-01-02'); // America/New_York timezone expect(formatDate('2024-01-02T03:00:00Z', 'America/New_York')).toBe('2024-01-01'); // 3 AM UTC = 10 PM EST previous day // Invalid timezone should throw a RangeError expect(() => formatDate(testTimestamp, 'Invalid/Timezone')).toThrow(RangeError); }); it('formatDateCompact respects timezone parameter', () => { const testTimestamp = '2024-01-01T15:00:00Z'; // UTC timezone expect(formatDateCompact(testTimestamp, 'UTC', 'en-US')).toBe('2024\n01-01'); // Asia/Tokyo timezone (crosses to next day) expect(formatDateCompact(testTimestamp, 'Asia/Tokyo', 'en-US')).toBe('2024\n01-02'); // Daily date defined as UTC is preserved expect(formatDateCompact('2024-01-01', 'UTC', 'en-US')).toBe('2024\n01-01'); // Daily date already in local time is preserved instead of being interpreted as UTC expect(formatDateCompact('2024-01-01', undefined, 'en-US')).toBe('2024\n01-01'); }); it('handles various date formats', () => { expect(formatDate('2024-01-01')).toBe('2024-01-01'); expect(formatDate('2024-01-01T12:00:00')).toBe('2024-01-01'); expect(formatDate('2024-01-01T12:00:00.000Z')).toBe('2024-01-01'); }); it('pads single digit months and days', () => { // Use UTC noon to avoid timezone issues expect(formatDate('2024-01-05T12:00:00Z')).toBe('2024-01-05'); expect(formatDate('2024-10-01T12:00:00Z')).toBe('2024-10-01'); }); it('respects locale parameter', () => { const testDate = '2024-08-04T12:00:00Z'; // Different locales format dates differently expect(formatDate(testDate, 'UTC', 'en-US')).toBe('08/04/2024'); expect(formatDate(testDate, 'UTC', 'en-CA')).toBe('2024-08-04'); expect(formatDate(testDate, 'UTC', 'ja-JP')).toBe('2024/08/04'); expect(formatDate(testDate, 'UTC', 'de-DE')).toBe('04.08.2024'); }); }); describe('loadSessionUsageById', async () => { const { createFixture } = await import('fs-fixture'); afterEach(() => { vi.unstubAllEnvs(); }); it('loads usage data for a specific session', async () => { await using fixture = await createFixture({ '.claude': { projects: { 'test-project': { 'session-123.jsonl': `${JSON.stringify({ timestamp: '2024-01-01T00:00:00Z', sessionId: 'session-123', message: { usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 10, cache_read_input_tokens: 20, }, model: 'claude-sonnet-4-20250514', }, costUSD: 0.5, })}\n${JSON.stringify({ timestamp: '2024-01-01T01:00:00Z', sessionId: 'session-123', message: { usage: { input_tokens: 200, output_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 40, }, model: 'claude-sonnet-4-20250514', }, costUSD: 1.0, })}`, }, }, }, }); vi.stubEnv('CLAUDE_CONFIG_DIR', fixture.getPath('.claude')); const result = await loadSessionUsageById('session-123', { mode: 'display' }); expect(result).not.toBeNull(); expect(result?.totalCost).toBe(1.5); expect(result?.entries).toHaveLength(2); }); it('returns null for non-existent session', async () => { await using fixture = await createFixture({ '.claude': { projects: { 'test-project': { 'other-session.jsonl': JSON.stringify({ timestamp: '2024-01-01T00:00:00Z', sessionId: 'other-session', message: { usage: { input_tokens: 100, output_tokens: 50, }, model: 'claude-sonnet-4-20250514', }, costUSD: 0.5, }), }, }, }, }); vi.stubEnv('CLAUDE_CONFIG_DIR', fixture.getPath('.claude')); const result = await loadSessionUsageById('non-existent', { mode: 'display' }); expect(result).toBeNull(); }); }); describe('formatDateCompact', () => { it('formats UTC timestamp to local date with line break', () => { expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe('2024\n01-01'); }); it('handles various date formats', () => { expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe('2024\n12-31'); expect(formatDateCompact('2024-01-01', undefined, 'en-US')).toBe('2024\n01-01'); expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe('2024\n01-01'); expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe('2024\n01-01'); }); it('pads single digit months and days', () => { // Use UTC noon to avoid timezone issues expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe('2024\n01-05'); expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe('2024\n10-01'); }); it('respects locale parameter', () => { const testDate = '2024-08-04T12:00:00Z'; // Different locales format dates differently expect(formatDateCompact(testDate, 'UTC', 'en-US')).toBe('2024\n08-04'); expect(formatDateCompact(testDate, 'UTC', 'en-CA')).toBe('2024\n08-04'); expect(formatDateCompact(testDate, 'UTC', 'ja-JP')).toBe('2024\n08-04'); // All locales should produce similar compact format }); }); describe('getDisplayModelName', () => { it('returns model name as-is for standard speed', () => { const data: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50, speed: 'standard' }, model: createModelName('claude-opus-4-6'), }, }; expect(getDisplayModelName(data)).toBe('claude-opus-4-6'); }); it('appends (fast) suffix for fast speed', () => { const data: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50, speed: 'fast' }, model: createModelName('claude-opus-4-6'), }, }; expect(getDisplayModelName(data)).toBe('claude-opus-4-6-fast'); }); it('returns model name as-is when speed is undefined', () => { const data: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 }, model: createModelName('claude-opus-4-6'), }, }; expect(getDisplayModelName(data)).toBe('claude-opus-4-6'); }); it('returns undefined when model is undefined', () => { const data: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50, speed: 'fast' }, }, }; expect(getDisplayModelName(data)).toBeUndefined(); }); }); describe('loadDailyUsageData', () => { it('returns empty array when no files found', async () => { await using fixture = await createFixture({ projects: {}, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); expect(result).toEqual([]); }); it('aggregates daily usage data correctly', async () => { // Use timestamps in the middle of the day to avoid timezone issues const mockData1: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, ]; const mockData2: UsageData = { timestamp: createISOTimestamp('2024-01-01T18:00:00Z'), message: { usage: { input_tokens: 300, output_tokens: 150 } }, costUSD: 0.03, }; await using fixture = await createFixture({ projects: { project1: { session1: { 'file1.jsonl': mockData1.map((d) => JSON.stringify(d)).join('\n'), }, session2: { 'file2.jsonl': JSON.stringify(mockData2), }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]?.date).toBe('2024-01-01'); expect(result[0]?.inputTokens).toBe(600); // 100 + 200 + 300 expect(result[0]?.outputTokens).toBe(300); // 50 + 100 + 150 expect(result[0]?.totalCost).toBe(0.06); // 0.01 + 0.02 + 0.03 }); it('handles cache tokens', async () => { const mockData: UsageData = { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 25, cache_read_input_tokens: 10, }, }, costUSD: 0.01, }; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': JSON.stringify(mockData), }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); expect(result[0]?.cacheCreationTokens).toBe(25); expect(result[0]?.cacheReadTokens).toBe(10); }); it('filters by date range', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 300, output_tokens: 150 } }, costUSD: 0.03, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path, since: '20240110', until: '20240125', }); expect(result).toHaveLength(1); expect(result[0]?.date).toBe('2024-01-15'); expect(result[0]?.inputTokens).toBe(200); }); it('sorts by date descending by default', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 300, output_tokens: 150 } }, costUSD: 0.03, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); expect(result[0]?.date).toBe('2024-01-31'); expect(result[1]?.date).toBe('2024-01-15'); expect(result[2]?.date).toBe('2024-01-01'); }); it("sorts by date ascending when order is 'asc'", async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 300, output_tokens: 150 } }, costUSD: 0.03, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'usage.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path, order: 'asc', }); expect(result).toHaveLength(3); expect(result[0]?.date).toBe('2024-01-01'); expect(result[1]?.date).toBe('2024-01-15'); expect(result[2]?.date).toBe('2024-01-31'); }); it("sorts by date descending when order is 'desc'", async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 300, output_tokens: 150 } }, costUSD: 0.03, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'usage.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path, order: 'desc', }); expect(result).toHaveLength(3); expect(result[0]?.date).toBe('2024-01-31'); expect(result[1]?.date).toBe('2024-01-15'); expect(result[2]?.date).toBe('2024-01-01'); }); it('handles invalid JSON lines gracefully', async () => { const mockData = ` {"timestamp":"2024-01-01T12:00:00Z","message":{"usage":{"input_tokens":100,"output_tokens":50}},"costUSD":0.01} invalid json line {"timestamp":"2024-01-01T12:00:00Z","message":{"usage":{"input_tokens":200,"output_tokens":100}},"costUSD":0.02} { broken json {"timestamp":"2024-01-01T18:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData, }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); // Should only process valid lines expect(result).toHaveLength(1); expect(result[0]?.inputTokens).toBe(600); // 100 + 200 + 300 expect(result[0]?.totalCost).toBe(0.06); // 0.01 + 0.02 + 0.03 }); it('skips data without required fields', async () => { const mockData = ` {"timestamp":"2024-01-01T12:00:00Z","message":{"usage":{"input_tokens":100,"output_tokens":50}},"costUSD":0.01} {"timestamp":"2024-01-01T14:00:00Z","message":{"usage":{}}} {"timestamp":"2024-01-01T18:00:00Z","message":{}} {"timestamp":"2024-01-01T20:00:00Z"} {"message":{"usage":{"input_tokens":200,"output_tokens":100}}} {"timestamp":"2024-01-01T22:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData, }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); // Should only include valid entries expect(result).toHaveLength(1); expect(result[0]?.inputTokens).toBe(400); // 100 + 300 expect(result[0]?.totalCost).toBe(0.04); // 0.01 + 0.03 }); }); describe('loadMonthlyUsageData', () => { it('aggregates daily data by month correctly', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-02-01T12:00:00Z'), message: { usage: { input_tokens: 150, output_tokens: 75 } }, costUSD: 0.015, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); // Should be sorted by month descending (2024-02 first) expect(result).toHaveLength(2); expect(result[0]).toEqual({ month: '2024-02', inputTokens: 150, outputTokens: 75, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0.015, modelsUsed: [], modelBreakdowns: [ { modelName: 'unknown', inputTokens: 150, outputTokens: 75, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.015, }, ], }); expect(result[1]).toEqual({ month: '2024-01', inputTokens: 300, outputTokens: 150, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0.03, modelsUsed: [], modelBreakdowns: [ { modelName: 'unknown', inputTokens: 300, outputTokens: 150, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.03, }, ], }); }); it('handles empty data', async () => { await using fixture = await createFixture({ projects: {}, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); expect(result).toEqual([]); }); it('handles single month data', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]).toEqual({ month: '2024-01', inputTokens: 300, outputTokens: 150, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0.03, modelsUsed: [], modelBreakdowns: [ { modelName: 'unknown', inputTokens: 300, outputTokens: 150, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.03, }, ], }); }); it('sorts months in descending order', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-03-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-02-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2023-12-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); const months = result.map((r) => r.month); expect(months).toEqual(['2024-03', '2024-02', '2024-01', '2023-12']); }); it("sorts months in ascending order when order is 'asc'", async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-03-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-02-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2023-12-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path, order: 'asc', }); const months = result.map((r) => r.month); expect(months).toEqual(['2023-12', '2024-01', '2024-02', '2024-03']); }); it('handles year boundaries correctly in sorting', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2023-12-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-02-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2023-11-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); // Descending order (default) const descResult = await loadMonthlyUsageData({ claudePath: fixture.path, order: 'desc', }); const descMonths = descResult.map((r) => r.month); expect(descMonths).toEqual(['2024-02', '2024-01', '2023-12', '2023-11']); // Ascending order const ascResult = await loadMonthlyUsageData({ claudePath: fixture.path, order: 'asc', }); const ascMonths = ascResult.map((r) => r.month); expect(ascMonths).toEqual(['2023-11', '2023-12', '2024-01', '2024-02']); }); it('respects date filters', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-02-15T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-03-01T12:00:00Z'), message: { usage: { input_tokens: 150, output_tokens: 75 } }, costUSD: 0.015, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path, since: '20240110', until: '20240225', }); // Should only include February data expect(result).toHaveLength(1); expect(result[0]?.month).toBe('2024-02'); expect(result[0]?.inputTokens).toBe(200); }); it('handles cache tokens correctly', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 25, cache_read_input_tokens: 10, }, }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100, cache_creation_input_tokens: 50, cache_read_input_tokens: 20, }, }, costUSD: 0.02, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]?.cacheCreationTokens).toBe(75); // 25 + 50 expect(result[0]?.cacheReadTokens).toBe(30); // 10 + 20 }); }); describe('loadWeeklyUsageData', () => { it('aggregates daily data by week correctly', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-02T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 150, output_tokens: 75 } }, costUSD: 0.015, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadWeeklyUsageData({ claudePath: fixture.path }); // Should be sorted by week descending (2024-01-15 first) expect(result).toHaveLength(2); expect(result[0]).toEqual({ week: '2024-01-14', inputTokens: 150, outputTokens: 75, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0.015, modelsUsed: [], modelBreakdowns: [ { modelName: 'unknown', inputTokens: 150, outputTokens: 75, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.015, }, ], }); expect(result[1]).toEqual({ week: '2023-12-31', inputTokens: 300, outputTokens: 150, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0.03, modelsUsed: [], modelBreakdowns: [ { modelName: 'unknown', inputTokens: 300, outputTokens: 150, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.03, }, ], }); }); it('handles empty data', async () => { await using fixture = await createFixture({ projects: {}, }); const result = await loadWeeklyUsageData({ claudePath: fixture.path }); expect(result).toEqual([]); }); it('handles single week data', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-03T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadWeeklyUsageData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]).toEqual({ week: '2023-12-31', inputTokens: 300, outputTokens: 150, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0.03, modelsUsed: [], modelBreakdowns: [ { modelName: 'unknown', inputTokens: 300, outputTokens: 150, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.03, }, ], }); }); it('sorts weeks in descending order', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-08T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-22T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadWeeklyUsageData({ claudePath: fixture.path }); const weeks = result.map((r) => r.week); expect(weeks).toEqual(['2024-01-21', '2024-01-14', '2024-01-07', '2023-12-31']); }); it("sorts weeks in ascending order when order is 'asc'", async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-08T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-22T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadWeeklyUsageData({ claudePath: fixture.path, order: 'asc' }); const weeks = result.map((r) => r.week); expect(weeks).toEqual(['2023-12-31', '2024-01-07', '2024-01-14', '2024-01-21']); }); it('handles year boundaries correctly in sorting', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2023-12-04T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-02-05T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2023-11-06T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); // Descending order (default) const descResult = await loadWeeklyUsageData({ claudePath: fixture.path, order: 'desc', }); const descWeeks = descResult.map((r) => r.week); expect(descWeeks).toEqual(['2024-02-04', '2023-12-31', '2023-12-03', '2023-11-05']); // Ascending order const ascResult = await loadWeeklyUsageData({ claudePath: fixture.path, order: 'asc', }); const ascWeeks = ascResult.map((r) => r.week); expect(ascWeeks).toEqual(['2023-11-05', '2023-12-03', '2023-12-31', '2024-02-04']); }); it('respects date filters', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-02T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-02-06T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-03-05T12:00:00Z'), message: { usage: { input_tokens: 150, output_tokens: 75 } }, costUSD: 0.015, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadWeeklyUsageData({ claudePath: fixture.path, since: '20240110', until: '20240225', }); // Should only include February data expect(result).toHaveLength(1); expect(result[0]?.week).toBe('2024-02-04'); expect(result[0]?.inputTokens).toBe(200); }); it('handles cache tokens correctly', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-02T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 25, cache_read_input_tokens: 10, }, }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-03T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100, cache_creation_input_tokens: 50, cache_read_input_tokens: 20, }, }, costUSD: 0.02, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadWeeklyUsageData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]?.cacheCreationTokens).toBe(75); // 25 + 50 expect(result[0]?.cacheReadTokens).toBe(30); // 10 + 20 }); }); describe('loadSessionData', () => { it('returns empty array when no files found', async () => { await using fixture = await createFixture({ projects: {}, }); const result = await loadSessionData({ claudePath: fixture.path }); expect(result).toEqual([]); }); it('extracts session info from file paths', async () => { const mockData: UsageData = { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }; await using fixture = await createFixture({ projects: { 'project1/subfolder': { session123: { 'chat.jsonl': JSON.stringify(mockData), }, }, project2: { session456: { 'chat.jsonl': JSON.stringify(mockData), }, }, }, }); const result = await loadSessionData({ claudePath: fixture.path }); expect(result).toHaveLength(2); expect(result.find((s) => s.sessionId === 'session123')).toBeTruthy(); expect(result.find((s) => s.projectPath === path.join('project1', 'subfolder'))).toBeTruthy(); expect(result.find((s) => s.sessionId === 'session456')).toBeTruthy(); expect(result.find((s) => s.projectPath === 'project2')).toBeTruthy(); }); it('aggregates session usage data', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 10, cache_read_input_tokens: 5, }, }, costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 10, }, }, costUSD: 0.02, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'chat.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadSessionData({ claudePath: fixture.path }); expect(result).toHaveLength(1); const session = result[0]; expect(session?.sessionId).toBe('session1'); expect(session?.projectPath).toBe('project1'); expect(session?.inputTokens).toBe(300); // 100 + 200 expect(session?.outputTokens).toBe(150); // 50 + 100 expect(session?.cacheCreationTokens).toBe(30); // 10 + 20 expect(session?.cacheReadTokens).toBe(15); // 5 + 10 expect(session?.totalCost).toBe(0.03); // 0.01 + 0.02 expect(session?.lastActivity).toBe('2024-01-01'); }); it('tracks versions', async () => { const mockData: UsageData[] = [ { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, version: createVersion('1.0.0'), costUSD: 0.01, }, { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 200, output_tokens: 100 } }, version: createVersion('1.1.0'), costUSD: 0.02, }, { timestamp: createISOTimestamp('2024-01-01T18:00:00Z'), message: { usage: { input_tokens: 300, output_tokens: 150 } }, version: createVersion('1.0.0'), // Duplicate version costUSD: 0.03, }, ]; await using fixture = await createFixture({ projects: { project1: { session1: { 'chat.jsonl': mockData.map((d) => JSON.stringify(d)).join('\n'), }, }, }, }); const result = await loadSessionData({ claudePath: fixture.path }); const session = result[0]; expect(session?.versions).toEqual(['1.0.0', '1.1.0']); // Sorted and unique }); it('sorts by last activity descending', async () => { const sessions = [ { sessionId: 'session1', data: { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, { sessionId: 'session2', data: { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, { sessionId: 'session3', data: { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, ]; await using fixture = await createFixture({ projects: { project1: Object.fromEntries( sessions.map((s) => [s.sessionId, { 'chat.jsonl': JSON.stringify(s.data) }]), ), }, }); const result = await loadSessionData({ claudePath: fixture.path }); expect(result[0]?.sessionId).toBe('session3'); expect(result[1]?.sessionId).toBe('session1'); expect(result[2]?.sessionId).toBe('session2'); }); it("sorts by last activity ascending when order is 'asc'", async () => { const sessions = [ { sessionId: 'session1', data: { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, { sessionId: 'session2', data: { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, { sessionId: 'session3', data: { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, ]; await using fixture = await createFixture({ projects: { project1: Object.fromEntries( sessions.map((s) => [s.sessionId, { 'chat.jsonl': JSON.stringify(s.data) }]), ), }, }); const result = await loadSessionData({ claudePath: fixture.path, order: 'asc', }); expect(result[0]?.sessionId).toBe('session2'); // oldest first expect(result[1]?.sessionId).toBe('session1'); expect(result[2]?.sessionId).toBe('session3'); // newest last }); it("sorts by last activity descending when order is 'desc'", async () => { const sessions = [ { sessionId: 'session1', data: { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, { sessionId: 'session2', data: { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, { sessionId: 'session3', data: { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, ]; await using fixture = await createFixture({ projects: { project1: Object.fromEntries( sessions.map((s) => [s.sessionId, { 'chat.jsonl': JSON.stringify(s.data) }]), ), }, }); const result = await loadSessionData({ claudePath: fixture.path, order: 'desc', }); expect(result[0]?.sessionId).toBe('session3'); // newest first (same as default) expect(result[1]?.sessionId).toBe('session1'); expect(result[2]?.sessionId).toBe('session2'); // oldest last }); it('filters by date range based on last activity', async () => { const sessions = [ { sessionId: 'session1', data: { timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, { sessionId: 'session2', data: { timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, { sessionId: 'session3', data: { timestamp: createISOTimestamp('2024-01-31T12:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }, }, ]; await using fixture = await createFixture({ projects: { project1: Object.fromEntries( sessions.map((s) => [s.sessionId, { 'chat.jsonl': JSON.stringify(s.data) }]), ), }, }); const result = await loadSessionData({ claudePath: fixture.path, since: '20240110', until: '20240125', }); expect(result).toHaveLength(1); expect(result[0]?.lastActivity).toBe('2024-01-15'); }); }); describe('loadDailyUsageData with fast mode', () => { it('should separate fast and standard entries into different model breakdowns', async () => { const standardEntry = JSON.stringify({ timestamp: '2024-01-01T10:00:00Z', message: { usage: { input_tokens: 100, output_tokens: 50, speed: 'standard' }, model: 'claude-opus-4-6', }, costUSD: 0.01, }); const fastEntry = JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', message: { usage: { input_tokens: 200, output_tokens: 100, speed: 'fast' }, model: 'claude-opus-4-6', }, costUSD: 0.05, }); await using fixture = await createFixture({ projects: { project1: { session1: { 'file1.jsonl': `${standardEntry}\n${fastEntry}`, }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]?.modelBreakdowns).toHaveLength(2); const standardBreakdown = result[0]?.modelBreakdowns.find( (b) => b.modelName === 'claude-opus-4-6', ); const fastBreakdown = result[0]?.modelBreakdowns.find( (b) => b.modelName === 'claude-opus-4-6-fast', ); expect(standardBreakdown).toBeDefined(); expect(fastBreakdown).toBeDefined(); expect(standardBreakdown?.inputTokens).toBe(100); expect(fastBreakdown?.inputTokens).toBe(200); }); it('should treat entries without speed field as standard', async () => { const noSpeedEntry = JSON.stringify({ timestamp: '2024-01-01T10:00:00Z', message: { usage: { input_tokens: 100, output_tokens: 50 }, model: 'claude-opus-4-6', }, costUSD: 0.01, }); await using fixture = await createFixture({ projects: { project1: { session1: { 'file1.jsonl': noSpeedEntry, }, }, }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]?.modelBreakdowns).toHaveLength(1); expect(result[0]?.modelBreakdowns[0]?.modelName).toBe('claude-opus-4-6'); }); }); describe('data-loader cost calculation with real pricing', () => { describe('loadDailyUsageData with mixed schemas', () => { it('should handle old schema with costUSD', async () => { const oldData = { timestamp: '2024-01-15T10:00:00Z', message: { usage: { input_tokens: 1000, output_tokens: 500, }, }, costUSD: 0.05, // Pre-calculated cost }; await using fixture = await createFixture({ projects: { 'test-project-old': { 'session-old': { 'usage.jsonl': `${JSON.stringify(oldData)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); expect(results).toHaveLength(1); expect(results[0]?.date).toBe('2024-01-15'); expect(results[0]?.inputTokens).toBe(1000); expect(results[0]?.outputTokens).toBe(500); expect(results[0]?.totalCost).toBe(0.05); }); it('should calculate cost for new schema with claude-sonnet-4-20250514', async () => { // Use a well-known Claude model const modelName = createModelName('claude-sonnet-4-20250514'); const newData = { timestamp: '2024-01-16T10:00:00Z', message: { usage: { input_tokens: 1000, output_tokens: 500, cache_creation_input_tokens: 200, cache_read_input_tokens: 300, }, model: modelName, }, }; await using fixture = await createFixture({ projects: { 'test-project-new': { 'session-new': { 'usage.jsonl': `${JSON.stringify(newData)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); expect(results).toHaveLength(1); expect(results[0]?.date).toBe('2024-01-16'); expect(results[0]?.inputTokens).toBe(1000); expect(results[0]?.outputTokens).toBe(500); expect(results[0]?.cacheCreationTokens).toBe(200); expect(results[0]?.cacheReadTokens).toBe(300); // Should have calculated some cost expect(results[0]?.totalCost).toBeGreaterThan(0); }); it('should calculate cost for new schema with claude-opus-4-20250514', async () => { // Use Claude 4 Opus model const modelName = createModelName('claude-opus-4-20250514'); const newData = { timestamp: '2024-01-16T10:00:00Z', message: { usage: { input_tokens: 1000, output_tokens: 500, cache_creation_input_tokens: 200, cache_read_input_tokens: 300, }, model: modelName, }, }; await using fixture = await createFixture({ projects: { 'test-project-opus': { 'session-opus': { 'usage.jsonl': `${JSON.stringify(newData)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); expect(results).toHaveLength(1); expect(results[0]?.date).toBe('2024-01-16'); expect(results[0]?.inputTokens).toBe(1000); expect(results[0]?.outputTokens).toBe(500); expect(results[0]?.cacheCreationTokens).toBe(200); expect(results[0]?.cacheReadTokens).toBe(300); // Should have calculated some cost expect(results[0]?.totalCost).toBeGreaterThan(0); }); it('should handle mixed data in same file', async () => { const data1 = { timestamp: '2024-01-17T10:00:00Z', message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }; const data2 = { timestamp: '2024-01-17T11:00:00Z', message: { usage: { input_tokens: 200, output_tokens: 100 }, model: createModelName('claude-4-sonnet-20250514'), }, }; const data3 = { timestamp: '2024-01-17T12:00:00Z', message: { usage: { input_tokens: 300, output_tokens: 150 } }, // No costUSD and no model - should be 0 cost }; await using fixture = await createFixture({ projects: { 'test-project-mixed': { 'session-mixed': { 'usage.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n${JSON.stringify(data3)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); expect(results).toHaveLength(1); expect(results[0]?.date).toBe('2024-01-17'); expect(results[0]?.inputTokens).toBe(600); // 100 + 200 + 300 expect(results[0]?.outputTokens).toBe(300); // 50 + 100 + 150 // Total cost should be at least the pre-calculated cost from data1 expect(results[0]?.totalCost).toBeGreaterThanOrEqual(0.01); }); it('should handle data without model or costUSD', async () => { const data = { timestamp: '2024-01-18T10:00:00Z', message: { usage: { input_tokens: 500, output_tokens: 250 } }, // No costUSD and no model }; await using fixture = await createFixture({ projects: { 'test-project-no-cost': { 'session-no-cost': { 'usage.jsonl': `${JSON.stringify(data)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); expect(results).toHaveLength(1); expect(results[0]?.inputTokens).toBe(500); expect(results[0]?.outputTokens).toBe(250); expect(results[0]?.totalCost).toBe(0); // 0 cost when no pricing info available }); }); describe('loadSessionData with mixed schemas', () => { it('should handle mixed cost sources in different sessions', async () => { const session1Data = { timestamp: '2024-01-15T10:00:00Z', message: { usage: { input_tokens: 1000, output_tokens: 500 } }, costUSD: 0.05, }; const session2Data = { timestamp: '2024-01-16T10:00:00Z', message: { usage: { input_tokens: 2000, output_tokens: 1000 }, model: createModelName('claude-4-sonnet-20250514'), }, }; await using fixture = await createFixture({ projects: { 'test-project': { session1: { 'usage.jsonl': JSON.stringify(session1Data), }, session2: { 'usage.jsonl': JSON.stringify(session2Data), }, }, }, }); const results = await loadSessionData({ claudePath: fixture.path }); expect(results).toHaveLength(2); // Check session 1 const session1 = results.find((s) => s.sessionId === 'session1'); expect(session1).toBeTruthy(); expect(session1?.totalCost).toBe(0.05); // Check session 2 const session2 = results.find((s) => s.sessionId === 'session2'); expect(session2).toBeTruthy(); expect(session2?.totalCost).toBeGreaterThan(0); }); it('should handle unknown models gracefully', async () => { const data = { timestamp: '2024-01-19T10:00:00Z', message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: 'unknown-model-xyz', }, }; await using fixture = await createFixture({ projects: { 'test-project-unknown': { 'session-unknown': { 'usage.jsonl': `${JSON.stringify(data)}\n`, }, }, }, }); const results = await loadSessionData({ claudePath: fixture.path }); expect(results).toHaveLength(1); expect(results[0]?.inputTokens).toBe(1000); expect(results[0]?.outputTokens).toBe(500); expect(results[0]?.totalCost).toBe(0); // 0 cost for unknown model }); }); describe('cached tokens cost calculation', () => { it('should correctly calculate costs for all token types with claude-sonnet-4-20250514', async () => { const data = { timestamp: '2024-01-20T10:00:00Z', message: { usage: { input_tokens: 1000, output_tokens: 500, cache_creation_input_tokens: 2000, cache_read_input_tokens: 1500, }, model: createModelName('claude-4-sonnet-20250514'), }, }; await using fixture = await createFixture({ projects: { 'test-project-cache': { 'session-cache': { 'usage.jsonl': `${JSON.stringify(data)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); expect(results).toHaveLength(1); expect(results[0]?.date).toBe('2024-01-20'); expect(results[0]?.inputTokens).toBe(1000); expect(results[0]?.outputTokens).toBe(500); expect(results[0]?.cacheCreationTokens).toBe(2000); expect(results[0]?.cacheReadTokens).toBe(1500); // Should have calculated cost including cache tokens expect(results[0]?.totalCost).toBeGreaterThan(0); }); it('should correctly calculate costs for all token types with claude-opus-4-20250514', async () => { const data = { timestamp: '2024-01-20T10:00:00Z', message: { usage: { input_tokens: 1000, output_tokens: 500, cache_creation_input_tokens: 2000, cache_read_input_tokens: 1500, }, model: createModelName('claude-opus-4-20250514'), }, }; await using fixture = await createFixture({ projects: { 'test-project-opus-cache': { 'session-opus-cache': { 'usage.jsonl': `${JSON.stringify(data)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); expect(results).toHaveLength(1); expect(results[0]?.date).toBe('2024-01-20'); expect(results[0]?.inputTokens).toBe(1000); expect(results[0]?.outputTokens).toBe(500); expect(results[0]?.cacheCreationTokens).toBe(2000); expect(results[0]?.cacheReadTokens).toBe(1500); // Should have calculated cost including cache tokens expect(results[0]?.totalCost).toBeGreaterThan(0); }); }); describe('cost mode functionality', () => { it('auto mode: uses costUSD when available, calculates otherwise', async () => { const data1 = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 } }, costUSD: 0.05, }; const data2 = { timestamp: '2024-01-01T11:00:00Z', message: { usage: { input_tokens: 2000, output_tokens: 1000 }, model: createModelName('claude-4-sonnet-20250514'), }, }; await using fixture = await createFixture({ projects: { 'test-project': { session: { 'usage.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path, mode: 'auto', }); expect(results).toHaveLength(1); expect(results[0]?.totalCost).toBeGreaterThan(0.05); // Should include both costs }); it('calculate mode: always calculates from tokens, ignores costUSD', async () => { const data = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-4-sonnet-20250514'), }, costUSD: 99.99, // This should be ignored }; await using fixture = await createFixture({ projects: { 'test-project': { session: { 'usage.jsonl': JSON.stringify(data), }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path, mode: 'calculate', }); expect(results).toHaveLength(1); expect(results[0]?.totalCost).toBeGreaterThan(0); expect(results[0]?.totalCost).toBeLessThan(1); // Much less than 99.99 }); it('display mode: always uses costUSD, even if undefined', async () => { const data1 = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-4-sonnet-20250514'), }, costUSD: 0.05, }; const data2 = { timestamp: '2024-01-01T11:00:00Z', message: { usage: { input_tokens: 2000, output_tokens: 1000 }, model: createModelName('claude-4-sonnet-20250514'), }, // No costUSD - should result in 0 cost }; await using fixture = await createFixture({ projects: { 'test-project': { session: { 'usage.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n`, }, }, }, }); const results = await loadDailyUsageData({ claudePath: fixture.path, mode: 'display', }); expect(results).toHaveLength(1); expect(results[0]?.totalCost).toBe(0.05); // Only the costUSD from data1 }); it('mode works with session data', async () => { const sessionData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-4-sonnet-20250514'), }, costUSD: 99.99, }; await using fixture = await createFixture({ projects: { 'test-project': { session1: { 'usage.jsonl': JSON.stringify(sessionData), }, }, }, }); // Test calculate mode const calculateResults = await loadSessionData({ claudePath: fixture.path, mode: 'calculate', }); expect(calculateResults[0]?.totalCost).toBeLessThan(1); // Test display mode const displayResults = await loadSessionData({ claudePath: fixture.path, mode: 'display', }); expect(displayResults[0]?.totalCost).toBe(99.99); }); }); describe('pricing data fetching optimization', () => { it('should not require model pricing when mode is display', async () => { const data = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-4-sonnet-20250514'), }, costUSD: 0.05, }; await using fixture = await createFixture({ projects: { 'test-project': { session: { 'usage.jsonl': JSON.stringify(data), }, }, }, }); // In display mode, only pre-calculated costUSD should be used const results = await loadDailyUsageData({ claudePath: fixture.path, mode: 'display', }); expect(results).toHaveLength(1); expect(results[0]?.totalCost).toBe(0.05); }); it('should fetch pricing data when mode is calculate', async () => { const data = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-4-sonnet-20250514'), }, costUSD: 0.05, }; await using fixture = await createFixture({ projects: { 'test-project': { session: { 'usage.jsonl': JSON.stringify(data), }, }, }, }); // This should fetch pricing data (will call real fetch) const results = await loadDailyUsageData({ claudePath: fixture.path, mode: 'calculate', }); expect(results).toHaveLength(1); expect(results[0]?.totalCost).toBeGreaterThan(0); expect(results[0]?.totalCost).not.toBe(0.05); // Should calculate, not use costUSD }); it('should fetch pricing data when mode is auto', async () => { const data = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-4-sonnet-20250514'), }, // No costUSD, so auto mode will need to calculate }; await using fixture = await createFixture({ projects: { 'test-project': { session: { 'usage.jsonl': JSON.stringify(data), }, }, }, }); // This should fetch pricing data (will call real fetch) const results = await loadDailyUsageData({ claudePath: fixture.path, mode: 'auto', }); expect(results).toHaveLength(1); expect(results[0]?.totalCost).toBeGreaterThan(0); }); it('session data should not require model pricing when mode is display', async () => { const data = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-4-sonnet-20250514'), }, costUSD: 0.05, }; await using fixture = await createFixture({ projects: { 'test-project': { session: { 'usage.jsonl': JSON.stringify(data), }, }, }, }); // In display mode, only pre-calculated costUSD should be used const results = await loadSessionData({ claudePath: fixture.path, mode: 'display', }); expect(results).toHaveLength(1); expect(results[0]?.totalCost).toBe(0.05); }); it('display mode should work without network access', async () => { const data = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: 'some-unknown-model', }, costUSD: 0.05, }; await using fixture = await createFixture({ projects: { 'test-project': { session: { 'usage.jsonl': JSON.stringify(data), }, }, }, }); // This test verifies that display mode doesn't try to fetch pricing // by using an unknown model that would cause pricing lookup to fail // if it were attempted. Since we're in display mode, it should just // use the costUSD value. const results = await loadDailyUsageData({ claudePath: fixture.path, mode: 'display', }); expect(results).toHaveLength(1); expect(results[0]?.totalCost).toBe(0.05); }); }); }); describe('calculateCostForEntry', () => { const mockUsageData: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500, cache_creation_input_tokens: 200, cache_read_input_tokens: 100, }, model: createModelName('claude-sonnet-4-20250514'), }, costUSD: 0.05, }; describe('display mode', () => { it('should return costUSD when available', async () => { using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(mockUsageData, 'display', fetcher); expect(result).toBe(0.05); }); it('should return 0 when costUSD is undefined', async () => { const dataWithoutCost = { ...mockUsageData }; dataWithoutCost.costUSD = undefined; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithoutCost, 'display', fetcher); expect(result).toBe(0); }); it('should not use model pricing in display mode', async () => { // Even with model pricing available, should use costUSD using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(mockUsageData, 'display', fetcher); expect(result).toBe(0.05); }); }); describe('calculate mode', () => { it('should calculate cost from tokens when model pricing available', async () => { // Use the exact same structure as working integration tests const testData: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500, }, model: createModelName('claude-4-sonnet-20250514'), }, }; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(testData, 'calculate', fetcher); expect(result).toBeGreaterThan(0); }); it('should ignore costUSD in calculate mode', async () => { using fetcher = new PricingFetcher(); const dataWithHighCost = { ...mockUsageData, costUSD: 99.99 }; const result = await calculateCostForEntry(dataWithHighCost, 'calculate', fetcher); expect(result).toBeGreaterThan(0); expect(result).toBeLessThan(1); // Much less than 99.99 }); it('should return 0 when model not available', async () => { const dataWithoutModel = { ...mockUsageData }; dataWithoutModel.message.model = undefined; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithoutModel, 'calculate', fetcher); expect(result).toBe(0); }); it('should return 0 when model pricing not found', async () => { const dataWithUnknownModel = { ...mockUsageData, message: { ...mockUsageData.message, model: createModelName('unknown-model') }, }; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithUnknownModel, 'calculate', fetcher); expect(result).toBe(0); }); it('should handle missing cache tokens', async () => { const dataWithoutCacheTokens: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500, }, model: createModelName('claude-4-sonnet-20250514'), }, }; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithoutCacheTokens, 'calculate', fetcher); expect(result).toBeGreaterThan(0); }); }); describe('auto mode', () => { it('should use costUSD when available', async () => { using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(mockUsageData, 'auto', fetcher); expect(result).toBe(0.05); }); it('should calculate from tokens when costUSD undefined', async () => { const dataWithoutCost: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500, }, model: createModelName('claude-4-sonnet-20250514'), }, }; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithoutCost, 'auto', fetcher); expect(result).toBeGreaterThan(0); }); it('should return 0 when no costUSD and no model', async () => { const dataWithoutCostOrModel = { ...mockUsageData }; dataWithoutCostOrModel.costUSD = undefined; dataWithoutCostOrModel.message.model = undefined; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithoutCostOrModel, 'auto', fetcher); expect(result).toBe(0); }); it('should return 0 when no costUSD and model pricing not found', async () => { const dataWithoutCost = { ...mockUsageData }; dataWithoutCost.costUSD = undefined; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithoutCost, 'auto', fetcher); expect(result).toBe(0); }); it('should prefer costUSD over calculation even when both available', async () => { // Both costUSD and model pricing available, should use costUSD using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(mockUsageData, 'auto', fetcher); expect(result).toBe(0.05); }); }); describe('edge cases', () => { it('should handle zero token counts', async () => { const dataWithZeroTokens = { ...mockUsageData, message: { ...mockUsageData.message, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, }, }; dataWithZeroTokens.costUSD = undefined; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithZeroTokens, 'calculate', fetcher); expect(result).toBe(0); }); it('should handle costUSD of 0', async () => { const dataWithZeroCost = { ...mockUsageData, costUSD: 0 }; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithZeroCost, 'display', fetcher); expect(result).toBe(0); }); it('should handle negative costUSD', async () => { const dataWithNegativeCost = { ...mockUsageData, costUSD: -0.01 }; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(dataWithNegativeCost, 'display', fetcher); expect(result).toBe(-0.01); }); }); describe('fast mode', () => { it('should apply fast multiplier in calculate mode', async () => { const standardData: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-opus-4-6'), }, }; const fastData: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500, speed: 'fast' }, model: createModelName('claude-opus-4-6'), }, }; using fetcher = new PricingFetcher(); const standardCost = await calculateCostForEntry(standardData, 'calculate', fetcher); const fastCost = await calculateCostForEntry(fastData, 'calculate', fetcher); expect(standardCost).toBeGreaterThan(0); expect(fastCost).toBeGreaterThan(standardCost); expect(fastCost).toBeCloseTo(standardCost * 6, 5); }); it('should apply fast multiplier in auto mode when costUSD is absent', async () => { const fastData: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500, speed: 'fast' }, model: createModelName('claude-opus-4-6'), }, }; using fetcher = new PricingFetcher(); const fastCost = await calculateCostForEntry(fastData, 'auto', fetcher); expect(fastCost).toBeGreaterThan(0); }); it('should not apply fast multiplier in display mode', async () => { const fastData: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500, speed: 'fast' }, model: createModelName('claude-opus-4-6'), }, costUSD: 0.05, }; using fetcher = new PricingFetcher(); const result = await calculateCostForEntry(fastData, 'display', fetcher); expect(result).toBe(0.05); }); }); describe('offline mode', () => { it('should pass offline flag through loadDailyUsageData', async () => { await using fixture = await createFixture({ projects: {} }); // This test verifies that the offline flag is properly passed through // We can't easily mock the internal behavior, but we can verify it doesn't throw const result = await loadDailyUsageData({ claudePath: fixture.path, offline: true, mode: 'calculate', }); // Should return empty array or valid data without throwing expect(Array.isArray(result)).toBe(true); }); }); }); describe('loadSessionBlockData', () => { it('returns empty array when no files found', async () => { await using fixture = await createFixture({ projects: {} }); const result = await loadSessionBlockData({ claudePath: fixture.path }); expect(result).toEqual([]); }); it('loads and identifies five-hour blocks correctly', async () => { const now = new Date('2024-01-01T10:00:00Z'); const laterTime = new Date(now.getTime() + 1 * 60 * 60 * 1000); // 1 hour later const muchLaterTime = new Date(now.getTime() + 6 * 60 * 60 * 1000); // 6 hours later await using fixture = await createFixture({ projects: { project1: { session1: { 'conversation1.jsonl': [ { timestamp: now.toISOString(), message: { id: 'msg1', usage: { input_tokens: 1000, output_tokens: 500, }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req1', costUSD: 0.01, version: createVersion('1.0.0'), }, { timestamp: laterTime.toISOString(), message: { id: 'msg2', usage: { input_tokens: 2000, output_tokens: 1000, }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req2', costUSD: 0.02, version: createVersion('1.0.0'), }, { timestamp: muchLaterTime.toISOString(), message: { id: 'msg3', usage: { input_tokens: 1500, output_tokens: 750, }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req3', costUSD: 0.015, version: createVersion('1.0.0'), }, ] .map((data) => JSON.stringify(data)) .join('\n'), }, }, }, }); const result = await loadSessionBlockData({ claudePath: fixture.path }); expect(result.length).toBeGreaterThan(0); // Should have blocks expect(result[0]?.entries).toHaveLength(1); // First block has one entry // Total entries across all blocks should be 3 const totalEntries = result.reduce((sum, block) => sum + block.entries.length, 0); expect(totalEntries).toBe(3); }); it('handles cost calculation modes correctly', async () => { const now = new Date('2024-01-01T10:00:00Z'); await using fixture = await createFixture({ projects: { project1: { session1: { 'conversation1.jsonl': JSON.stringify({ timestamp: now.toISOString(), message: { id: 'msg1', usage: { input_tokens: 1000, output_tokens: 500, }, model: createModelName('claude-sonnet-4-20250514'), }, request: { id: 'req1' }, costUSD: 0.01, version: createVersion('1.0.0'), }), }, }, }, }); // Test display mode const displayResult = await loadSessionBlockData({ claudePath: fixture.path, mode: 'display', }); expect(displayResult).toHaveLength(1); expect(displayResult[0]?.costUSD).toBe(0.01); // Test calculate mode const calculateResult = await loadSessionBlockData({ claudePath: fixture.path, mode: 'calculate', }); expect(calculateResult).toHaveLength(1); expect(calculateResult[0]?.costUSD).toBeGreaterThan(0); }); it('filters by date range correctly', async () => { const date1 = new Date('2024-01-01T10:00:00Z'); const date2 = new Date('2024-01-02T10:00:00Z'); const date3 = new Date('2024-01-03T10:00:00Z'); await using fixture = await createFixture({ projects: { project1: { session1: { 'conversation1.jsonl': [ { timestamp: date1.toISOString(), message: { id: 'msg1', usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req1', costUSD: 0.01, version: createVersion('1.0.0'), }, { timestamp: date2.toISOString(), message: { id: 'msg2', usage: { input_tokens: 2000, output_tokens: 1000 }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req2', costUSD: 0.02, version: createVersion('1.0.0'), }, { timestamp: date3.toISOString(), message: { id: 'msg3', usage: { input_tokens: 1500, output_tokens: 750 }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req3', costUSD: 0.015, version: createVersion('1.0.0'), }, ] .map((data) => JSON.stringify(data)) .join('\n'), }, }, }, }); // Test filtering with since parameter const sinceResult = await loadSessionBlockData({ claudePath: fixture.path, since: '20240102', }); expect(sinceResult.length).toBeGreaterThan(0); expect(sinceResult.every((block) => block.startTime >= date2)).toBe(true); // Test filtering with until parameter const untilResult = await loadSessionBlockData({ claudePath: fixture.path, until: '20240102', }); expect(untilResult.length).toBeGreaterThan(0); // The filter uses formatDate which converts to YYYYMMDD format for comparison expect( untilResult.every((block) => { const blockDateStr = block.startTime.toISOString().slice(0, 10).replace(/-/g, ''); return blockDateStr <= '20240102'; }), ).toBe(true); }); it('sorts blocks by order parameter', async () => { const date1 = new Date('2024-01-01T10:00:00Z'); const date2 = new Date('2024-01-02T10:00:00Z'); await using fixture = await createFixture({ projects: { project1: { session1: { 'conversation1.jsonl': [ { timestamp: date2.toISOString(), message: { id: 'msg2', usage: { input_tokens: 2000, output_tokens: 1000 }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req2', costUSD: 0.02, version: createVersion('1.0.0'), }, { timestamp: date1.toISOString(), message: { id: 'msg1', usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req1', costUSD: 0.01, version: createVersion('1.0.0'), }, ] .map((data) => JSON.stringify(data)) .join('\n'), }, }, }, }); // Test ascending order const ascResult = await loadSessionBlockData({ claudePath: fixture.path, order: 'asc', }); expect(ascResult[0]?.startTime).toEqual(date1); // Test descending order const descResult = await loadSessionBlockData({ claudePath: fixture.path, order: 'desc', }); expect(descResult[0]?.startTime).toEqual(date2); }); it('handles deduplication correctly', async () => { const now = new Date('2024-01-01T10:00:00Z'); await using fixture = await createFixture({ projects: { project1: { session1: { 'conversation1.jsonl': [ { timestamp: now.toISOString(), message: { id: 'msg1', usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req1', costUSD: 0.01, version: createVersion('1.0.0'), }, // Duplicate entry - should be filtered out { timestamp: now.toISOString(), message: { id: 'msg1', usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req1', costUSD: 0.01, version: createVersion('1.0.0'), }, ] .map((data) => JSON.stringify(data)) .join('\n'), }, }, }, }); const result = await loadSessionBlockData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]?.entries).toHaveLength(1); // Only one entry after deduplication }); it('handles invalid JSON lines gracefully', async () => { const now = new Date('2024-01-01T10:00:00Z'); await using fixture = await createFixture({ projects: { project1: { session1: { 'conversation1.jsonl': [ 'invalid json line', JSON.stringify({ timestamp: now.toISOString(), message: { id: 'msg1', usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-sonnet-4-20250514'), }, requestId: 'req1', costUSD: 0.01, version: createVersion('1.0.0'), }), 'another invalid line', ].join('\n'), }, }, }, }); const result = await loadSessionBlockData({ claudePath: fixture.path }); expect(result).toHaveLength(1); expect(result[0]?.entries).toHaveLength(1); }); describe('processJSONLFileByLine', () => { it('should process each non-empty line with correct line numbers', async () => { await using fixture = await createFixture({ 'test.jsonl': '{"line": 1}\n{"line": 2}\n{"line": 3}\n', }); const lines: Array<{ content: string; lineNumber: number }> = []; await processJSONLFileByLine(path.join(fixture.path, 'test.jsonl'), (line, lineNumber) => { lines.push({ content: line, lineNumber }); }); expect(lines).toHaveLength(3); expect(lines[0]).toEqual({ content: '{"line": 1}', lineNumber: 1 }); expect(lines[1]).toEqual({ content: '{"line": 2}', lineNumber: 2 }); expect(lines[2]).toEqual({ content: '{"line": 3}', lineNumber: 3 }); }); it('should skip empty lines', async () => { await using fixture = await createFixture({ 'test.jsonl': '{"line": 1}\n\n{"line": 2}\n \n{"line": 3}\n', }); const lines: string[] = []; await processJSONLFileByLine(path.join(fixture.path, 'test.jsonl'), (line) => { lines.push(line); }); expect(lines).toHaveLength(3); expect(lines[0]).toBe('{"line": 1}'); expect(lines[1]).toBe('{"line": 2}'); expect(lines[2]).toBe('{"line": 3}'); }); it('should handle async processLine callback', async () => { await using fixture = await createFixture({ 'test.jsonl': '{"line": 1}\n{"line": 2}\n', }); const results: string[] = []; await processJSONLFileByLine(path.join(fixture.path, 'test.jsonl'), async (line) => { // Simulate async operation await new Promise((resolve) => setTimeout(resolve, 1)); results.push(line); }); expect(results).toHaveLength(2); expect(results[0]).toBe('{"line": 1}'); expect(results[1]).toBe('{"line": 2}'); }); it('should throw error when file does not exist', async () => { await expect(processJSONLFileByLine('/nonexistent/file.jsonl', () => {})).rejects.toThrow(); }); it('should handle empty file', async () => { await using fixture = await createFixture({ 'empty.jsonl': '', }); const lines: string[] = []; await processJSONLFileByLine(path.join(fixture.path, 'empty.jsonl'), (line) => { lines.push(line); }); expect(lines).toHaveLength(0); }); it('should handle file with only empty lines', async () => { await using fixture = await createFixture({ 'only-empty.jsonl': '\n\n \n\t\n', }); const lines: string[] = []; await processJSONLFileByLine(path.join(fixture.path, 'only-empty.jsonl'), (line) => { lines.push(line); }); expect(lines).toHaveLength(0); }); it('should process large files (600MB+) without RangeError', async () => { // Create a realistic JSONL entry similar to actual Claude data (~283 bytes per line) const sampleEntry = `${JSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { id: 'msg_01234567890123456789', usage: { input_tokens: 1000, output_tokens: 500 }, model: 'claude-sonnet-4-20250514', }, requestId: 'req_01234567890123456789', costUSD: 0.01, })}\n`; // Target 600MB file (this would cause RangeError with readFile in Node.js) const targetMB = 600; const lineSize = Buffer.byteLength(sampleEntry, 'utf-8'); const lineCount = Math.ceil((targetMB * 1024 * 1024) / lineSize); // Create fixture directory first await using fixture = await createFixture({}); const filePath = path.join(fixture.path, 'large.jsonl'); // Write file using streaming to avoid Node.js string length limit (~512MB) // Creating a 600MB string directly would cause "RangeError: Invalid string length" const writeStream = createWriteStream(filePath); // Write lines and handle backpressure for (let i = 0; i < lineCount; i++) { const canContinue = writeStream.write(sampleEntry); // Respect backpressure by waiting for drain event if (!canContinue) { await new Promise((resolve) => writeStream.once('drain', () => resolve())); } } // Ensure all data is flushed await new Promise((resolve, reject) => { writeStream.end((err?: Error | null) => (err != null ? reject(err) : resolve())); }); // Test streaming processing let processedCount = 0; await processJSONLFileByLine(filePath, () => { processedCount++; }); expect(processedCount).toBe(lineCount); }); }); }); } // duplication functionality tests if (import.meta.vitest != null) { describe('deduplication functionality', () => { describe('createUniqueHash', () => { it('should create hash from message id and request id', () => { const data = { timestamp: createISOTimestamp('2025-01-10T10:00:00Z'), message: { id: createMessageId('msg_123'), usage: { input_tokens: 100, output_tokens: 50, }, }, requestId: createRequestId('req_456'), }; const hash = createUniqueHash(data); expect(hash).toBe('msg_123:req_456'); }); it('should return null when message id is missing', () => { const data = { timestamp: createISOTimestamp('2025-01-10T10:00:00Z'), message: { usage: { input_tokens: 100, output_tokens: 50, }, }, requestId: createRequestId('req_456'), }; const hash = createUniqueHash(data); expect(hash).toBeNull(); }); it('should return null when request id is missing', () => { const data = { timestamp: createISOTimestamp('2025-01-10T10:00:00Z'), message: { id: createMessageId('msg_123'), usage: { input_tokens: 100, output_tokens: 50, }, }, }; const hash = createUniqueHash(data); expect(hash).toBeNull(); }); }); describe('getEarliestTimestamp', () => { it('should extract earliest timestamp from JSONL file', async () => { const content = [ JSON.stringify({ timestamp: '2025-01-15T12:00:00Z', message: { usage: {} } }), JSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { usage: {} } }), JSON.stringify({ timestamp: '2025-01-12T11:00:00Z', message: { usage: {} } }), ].join('\n'); await using fixture = await createFixture({ 'test.jsonl': content, }); const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); expect(timestamp).toEqual(new Date('2025-01-10T10:00:00Z')); }); it('should handle files without timestamps', async () => { const content = [ JSON.stringify({ message: { usage: {} } }), JSON.stringify({ data: 'no timestamp' }), ].join('\n'); await using fixture = await createFixture({ 'test.jsonl': content, }); const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); expect(timestamp).toBeNull(); }); it('should skip invalid JSON lines', async () => { const content = [ 'invalid json', JSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { usage: {} } }), '{ broken: json', ].join('\n'); await using fixture = await createFixture({ 'test.jsonl': content, }); const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); expect(timestamp).toEqual(new Date('2025-01-10T10:00:00Z')); }); }); describe('sortFilesByTimestamp', () => { it('should sort files by earliest timestamp', async () => { await using fixture = await createFixture({ 'file1.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z' }), 'file2.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z' }), 'file3.jsonl': JSON.stringify({ timestamp: '2025-01-12T10:00:00Z' }), }); const file1 = fixture.getPath('file1.jsonl'); const file2 = fixture.getPath('file2.jsonl'); const file3 = fixture.getPath('file3.jsonl'); const sorted = await sortFilesByTimestamp([file1, file2, file3]); expect(sorted).toEqual([file2, file3, file1]); // Chronological order }); it('should place files without timestamps at the end', async () => { await using fixture = await createFixture({ 'file1.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z' }), 'file2.jsonl': JSON.stringify({ no_timestamp: true }), 'file3.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z' }), }); const file1 = fixture.getPath('file1.jsonl'); const file2 = fixture.getPath('file2.jsonl'); const file3 = fixture.getPath('file3.jsonl'); const sorted = await sortFilesByTimestamp([file1, file2, file3]); expect(sorted).toEqual([file3, file1, file2]); // file2 without timestamp goes to end }); }); describe('loadDailyUsageData with deduplication', () => { it('should deduplicate entries with same message and request IDs', async () => { await using fixture = await createFixture({ projects: { project1: { session1: { 'file1.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { id: 'msg_123', usage: { input_tokens: 100, output_tokens: 50, }, }, requestId: 'req_456', costUSD: 0.001, }), }, session2: { 'file2.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z', message: { id: 'msg_123', usage: { input_tokens: 100, output_tokens: 50, }, }, requestId: 'req_456', costUSD: 0.001, }), }, }, }, }); const data = await loadDailyUsageData({ claudePath: fixture.path, mode: 'display', }); // Should only have one entry for 2025-01-10 expect(data).toHaveLength(1); expect(data[0]?.date).toBe('2025-01-10'); expect(data[0]?.inputTokens).toBe(100); expect(data[0]?.outputTokens).toBe(50); }); it('should process files in chronological order', async () => { await using fixture = await createFixture({ projects: { 'newer.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z', message: { id: 'msg_123', usage: { input_tokens: 200, output_tokens: 100, }, }, requestId: 'req_456', costUSD: 0.002, }), 'older.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { id: 'msg_123', usage: { input_tokens: 100, output_tokens: 50, }, }, requestId: 'req_456', costUSD: 0.001, }), }, }); const data = await loadDailyUsageData({ claudePath: fixture.path, mode: 'display', }); // Should keep the older entry (100/50 tokens) not the newer one (200/100) expect(data).toHaveLength(1); expect(data[0]?.date).toBe('2025-01-10'); expect(data[0]?.inputTokens).toBe(100); expect(data[0]?.outputTokens).toBe(50); }); }); describe('loadSessionData with deduplication', () => { it('should deduplicate entries across sessions', async () => { await using fixture = await createFixture({ projects: { project1: { session1: { 'file1.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { id: 'msg_123', usage: { input_tokens: 100, output_tokens: 50, }, }, requestId: 'req_456', costUSD: 0.001, }), }, session2: { 'file2.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z', message: { id: 'msg_123', usage: { input_tokens: 100, output_tokens: 50, }, }, requestId: 'req_456', costUSD: 0.001, }), }, }, }, }); const sessions = await loadSessionData({ claudePath: fixture.path, mode: 'display', }); // Session 1 should have the entry const session1 = sessions.find((s) => s.sessionId === 'session1'); expect(session1).toBeDefined(); expect(session1?.inputTokens).toBe(100); expect(session1?.outputTokens).toBe(50); // Session 2 should either not exist or have 0 tokens (duplicate was skipped) const session2 = sessions.find((s) => s.sessionId === 'session2'); if (session2 != null) { expect(session2.inputTokens).toBe(0); expect(session2.outputTokens).toBe(0); } else { // It's also valid for session2 to not be included if it has no entries expect(sessions.length).toBe(1); } }); }); }); describe('getClaudePaths', () => { afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); it('returns paths from environment variable when set', async () => { await using fixture1 = await createFixture({ projects: {}, }); await using fixture2 = await createFixture({ projects: {}, }); vi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture1.path},${fixture2.path}`); const paths = getClaudePaths(); const normalizedFixture1 = path.resolve(fixture1.path); const normalizedFixture2 = path.resolve(fixture2.path); expect(paths).toEqual(expect.arrayContaining([normalizedFixture1, normalizedFixture2])); // Environment paths should be prioritized expect(paths[0]).toBe(normalizedFixture1); expect(paths[1]).toBe(normalizedFixture2); }); it('filters out non-existent paths from environment variable', async () => { await using fixture = await createFixture({ projects: {}, }); vi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture.path},/nonexistent/path`); const paths = getClaudePaths(); const normalizedFixture = path.resolve(fixture.path); expect(paths).toEqual(expect.arrayContaining([normalizedFixture])); expect(paths[0]).toBe(normalizedFixture); }); it('removes duplicates from combined paths', async () => { await using fixture = await createFixture({ projects: {}, }); vi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture.path},${fixture.path}`); const paths = getClaudePaths(); const normalizedFixture = path.resolve(fixture.path); // Should only contain the fixture path once (but may include defaults) const fixtureCount = paths.filter((p) => p === normalizedFixture).length; expect(fixtureCount).toBe(1); }); it('returns non-empty array with existing default paths', () => { // This test will use real filesystem checks for default paths vi.stubEnv('CLAUDE_CONFIG_DIR', ''); const paths = getClaudePaths(); expect(Array.isArray(paths)).toBe(true); // At least one path should exist in our test environment (CI creates both) expect(paths.length).toBeGreaterThanOrEqual(1); }); }); describe('multiple paths integration', () => { it('loadDailyUsageData aggregates data from multiple paths', async () => { await using fixture1 = await createFixture({ projects: { project1: { session1: { 'usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', message: { usage: { input_tokens: 100, output_tokens: 50 } }, costUSD: 0.01, }), }, }, }, }); await using fixture2 = await createFixture({ projects: { project2: { session2: { 'usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T13:00:00Z', message: { usage: { input_tokens: 200, output_tokens: 100 } }, costUSD: 0.02, }), }, }, }, }); vi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture1.path},${fixture2.path}`); const result = await loadDailyUsageData(); // Find the specific date we're testing const targetDate = result.find((day) => day.date === '2024-01-01'); expect(targetDate).toBeDefined(); expect(targetDate?.inputTokens).toBe(300); expect(targetDate?.outputTokens).toBe(150); expect(targetDate?.totalCost).toBe(0.03); }, 30000); }); describe('globUsageFiles', () => { it('should glob files from multiple paths in parallel with base directories', async () => { await using fixture = await createFixture({ 'path1/projects/project1/session1/usage.jsonl': 'data1', 'path2/projects/project2/session2/usage.jsonl': 'data2', 'path3/projects/project3/session3/usage.jsonl': 'data3', }); const paths = [fixture.getPath('path1'), fixture.getPath('path2'), fixture.getPath('path3')]; const results = await globUsageFiles(paths); expect(results).toHaveLength(3); expect(results.some((r) => r.file.includes('project1'))).toBe(true); expect(results.some((r) => r.file.includes('project2'))).toBe(true); expect(results.some((r) => r.file.includes('project3'))).toBe(true); // Check base directories are included const result1 = results.find((r) => r.file.includes('project1')); expect(result1?.baseDir).toContain(path.join('path1', 'projects')); }); it('should handle errors gracefully and return empty array for failed paths', async () => { await using fixture = await createFixture({ 'valid/projects/project1/session1/usage.jsonl': 'data1', }); const paths = [ fixture.getPath('valid'), fixture.getPath('nonexistent'), // This path doesn't exist ]; const results = await globUsageFiles(paths); expect(results).toHaveLength(1); expect(results.at(0)?.file).toContain('project1'); }); it('should return empty array when no files found', async () => { await using fixture = await createFixture({ 'empty/projects': {}, // Empty directory }); const paths = [fixture.getPath('empty')]; const results = await globUsageFiles(paths); expect(results).toEqual([]); }); it('should handle multiple files from same base directory', async () => { await using fixture = await createFixture({ 'path1/projects/project1/session1/usage.jsonl': 'data1', 'path1/projects/project1/session2/usage.jsonl': 'data2', 'path1/projects/project2/session1/usage.jsonl': 'data3', }); const paths = [fixture.getPath('path1')]; const results = await globUsageFiles(paths); expect(results).toHaveLength(3); expect(results.every((r) => r.baseDir.includes(path.join('path1', 'projects')))).toBe(true); }); }); // Test for calculateContextTokens describe('calculateContextTokens', async () => { it('returns null when transcript cannot be read', async () => { const result = await calculateContextTokens('/nonexistent/path.jsonl'); expect(result).toBeNull(); }); const { createFixture } = await import('fs-fixture'); it('parses latest assistant line and excludes output tokens', async () => { await using fixture = await createFixture({ 'transcript.jsonl': [ JSON.stringify({ type: 'user', message: {} }), JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 1000, output_tokens: 999 } }, }), JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 2000, cache_creation_input_tokens: 100, cache_read_input_tokens: 50, }, }, }), ].join('\n'), }); const res = await calculateContextTokens(fixture.getPath('transcript.jsonl')); expect(res).not.toBeNull(); // Should pick the last assistant line and exclude output tokens expect(res?.inputTokens).toBe(2000 + 100 + 50); expect(res?.percentage).toBeGreaterThan(0); }); it('handles missing cache fields gracefully', async () => { await using fixture = await createFixture({ 'transcript.jsonl': [ JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 1000 } } }), ].join('\n'), }); const res = await calculateContextTokens(fixture.getPath('transcript.jsonl')); expect(res).not.toBeNull(); expect(res?.inputTokens).toBe(1000); expect(res?.percentage).toBeGreaterThan(0); }); it('clamps percentage to 0-100 range', async () => { await using fixture = await createFixture({ 'transcript.jsonl': [ JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 300_000 } } }), ].join('\n'), }); const res = await calculateContextTokens(fixture.getPath('transcript.jsonl')); expect(res).not.toBeNull(); expect(res?.percentage).toBe(100); // Should be clamped to 100 }); }); } ================================================ FILE: apps/ccusage/src/debug.ts ================================================ /** * @fileoverview Debug utilities for cost calculation validation * * This module provides debugging tools for detecting mismatches between * pre-calculated costs and calculated costs based on token usage and model pricing. * * @module debug */ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { Result } from '@praha/byethrow'; import { createFixture } from 'fs-fixture'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; import { CLAUDE_PROJECTS_DIR_NAME, DEBUG_MATCH_THRESHOLD_PERCENT, USAGE_DATA_GLOB_PATTERN, } from './_consts.ts'; import { PricingFetcher } from './_pricing-fetcher.ts'; import { getClaudePaths, usageDataSchema } from './data-loader.ts'; import { logger } from './logger.ts'; /** * Represents a pricing discrepancy between original and calculated costs */ type Discrepancy = { file: string; timestamp: string; model: string; originalCost: number; calculatedCost: number; difference: number; percentDiff: number; usage: { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; }; }; /** * Statistics about pricing mismatches across all usage data */ type MismatchStats = { totalEntries: number; entriesWithBoth: number; matches: number; mismatches: number; discrepancies: Discrepancy[]; modelStats: Map< string, { total: number; matches: number; mismatches: number; avgPercentDiff: number; } >; versionStats: Map< string, { total: number; matches: number; mismatches: number; avgPercentDiff: number; } >; }; /** * Analyzes usage data to detect pricing mismatches between stored and calculated costs * Compares pre-calculated costUSD values with costs calculated from token usage * @param claudePath - Optional path to Claude data directory * @returns Statistics about pricing mismatches found */ export async function detectMismatches(claudePath?: string): Promise { let claudeDir: string; if (claudePath != null && claudePath !== '') { claudeDir = claudePath; } else { const paths = getClaudePaths(); if (paths.length === 0) { throw new Error('No valid Claude data directory found'); } claudeDir = path.join(paths[0]!, CLAUDE_PROJECTS_DIR_NAME); } const files = await glob([USAGE_DATA_GLOB_PATTERN], { cwd: claudeDir, absolute: true, }); // Use PricingFetcher with using statement for automatic cleanup using fetcher = new PricingFetcher(); const stats: MismatchStats = { totalEntries: 0, entriesWithBoth: 0, matches: 0, mismatches: 0, discrepancies: [], modelStats: new Map(), versionStats: new Map(), }; for (const file of files) { const content = await readFile(file, 'utf-8'); const lines = content .trim() .split('\n') .filter((line) => line.length > 0); for (const line of lines) { const parseParser = Result.try({ try: () => JSON.parse(line) as unknown, catch: () => new Error('Invalid JSON'), }); const parseResult = parseParser(); if (Result.isFailure(parseResult)) { continue; } const schemaResult = v.safeParse(usageDataSchema, parseResult.value); if (!schemaResult.success) { continue; } const data = schemaResult.output; stats.totalEntries++; // Check if we have both costUSD and model if ( data.costUSD !== undefined && data.message.model != null && data.message.model !== '' ) { stats.entriesWithBoth++; const model = data.message.model; const calculatedCost = await Result.unwrap( fetcher.calculateCostFromTokens(data.message.usage, model), ); // Only compare if we could calculate a cost const difference = Math.abs(data.costUSD - calculatedCost); const percentDiff = data.costUSD > 0 ? (difference / data.costUSD) * 100 : 0; // Update model statistics const modelStat = stats.modelStats.get(model) ?? { total: 0, matches: 0, mismatches: 0, avgPercentDiff: 0, }; modelStat.total++; // Update version statistics if version is available if (data.version != null) { const versionStat = stats.versionStats.get(data.version) ?? { total: 0, matches: 0, mismatches: 0, avgPercentDiff: 0, }; versionStat.total++; // Consider it a match if within the defined threshold (to account for floating point) if (percentDiff < DEBUG_MATCH_THRESHOLD_PERCENT) { versionStat.matches++; } else { versionStat.mismatches++; } // Update average percent difference for version versionStat.avgPercentDiff = (versionStat.avgPercentDiff * (versionStat.total - 1) + percentDiff) / versionStat.total; stats.versionStats.set(data.version, versionStat); } // Consider it a match if within 0.1% difference (to account for floating point) if (percentDiff < 0.1) { stats.matches++; modelStat.matches++; } else { stats.mismatches++; modelStat.mismatches++; stats.discrepancies.push({ file: path.basename(file), timestamp: data.timestamp, model, originalCost: data.costUSD, calculatedCost, difference, percentDiff, usage: data.message.usage, }); } // Update average percent difference modelStat.avgPercentDiff = (modelStat.avgPercentDiff * (modelStat.total - 1) + percentDiff) / modelStat.total; stats.modelStats.set(model, modelStat); } } } return stats; } /** * Prints a detailed report of pricing mismatches to the console * @param stats - Mismatch statistics to report * @param sampleCount - Number of sample discrepancies to show (default: 5) */ export function printMismatchReport(stats: MismatchStats, sampleCount = 5): void { if (stats.entriesWithBoth === 0) { logger.info('No pricing data found to analyze.'); return; } const matchRate = (stats.matches / stats.entriesWithBoth) * 100; logger.info('\n=== Pricing Mismatch Debug Report ==='); logger.info(`Total entries processed: ${stats.totalEntries.toLocaleString()}`); logger.info(`Entries with both costUSD and model: ${stats.entriesWithBoth.toLocaleString()}`); logger.info(`Matches (within 0.1%): ${stats.matches.toLocaleString()}`); logger.info(`Mismatches: ${stats.mismatches.toLocaleString()}`); logger.info(`Match rate: ${matchRate.toFixed(2)}%`); // Show model-by-model breakdown if there are mismatches if (stats.mismatches > 0 && stats.modelStats.size > 0) { logger.info('\n=== Model Statistics ==='); const sortedModels = Array.from(stats.modelStats.entries()).sort( (a, b) => b[1].mismatches - a[1].mismatches, ); for (const [model, modelStat] of sortedModels) { if (modelStat.mismatches > 0) { const modelMatchRate = (modelStat.matches / modelStat.total) * 100; logger.info(`${model}:`); logger.info(` Total entries: ${modelStat.total.toLocaleString()}`); logger.info( ` Matches: ${modelStat.matches.toLocaleString()} (${modelMatchRate.toFixed(1)}%)`, ); logger.info(` Mismatches: ${modelStat.mismatches.toLocaleString()}`); logger.info(` Avg % difference: ${modelStat.avgPercentDiff.toFixed(1)}%`); } } } // Show version statistics if there are mismatches if (stats.mismatches > 0 && stats.versionStats.size > 0) { logger.info('\n=== Version Statistics ==='); const sortedVersions = Array.from(stats.versionStats.entries()) .filter(([_, versionStat]) => versionStat.mismatches > 0) .sort((a, b) => b[1].mismatches - a[1].mismatches); for (const [version, versionStat] of sortedVersions) { const versionMatchRate = (versionStat.matches / versionStat.total) * 100; logger.info(`${version}:`); logger.info(` Total entries: ${versionStat.total.toLocaleString()}`); logger.info( ` Matches: ${versionStat.matches.toLocaleString()} (${versionMatchRate.toFixed(1)}%)`, ); logger.info(` Mismatches: ${versionStat.mismatches.toLocaleString()}`); logger.info(` Avg % difference: ${versionStat.avgPercentDiff.toFixed(1)}%`); } } // Show sample discrepancies if (stats.discrepancies.length > 0 && sampleCount > 0) { logger.info(`\n=== Sample Discrepancies (first ${sampleCount}) ===`); const samples = stats.discrepancies.slice(0, sampleCount); for (const disc of samples) { logger.info(`File: ${disc.file}`); logger.info(`Timestamp: ${disc.timestamp}`); logger.info(`Model: ${disc.model}`); logger.info(`Original cost: $${disc.originalCost.toFixed(6)}`); logger.info(`Calculated cost: $${disc.calculatedCost.toFixed(6)}`); logger.info(`Difference: $${disc.difference.toFixed(6)} (${disc.percentDiff.toFixed(2)}%)`); logger.info(`Tokens: ${JSON.stringify(disc.usage)}`); logger.info('---'); } } } if (import.meta.vitest != null) { describe('debug.ts', () => { describe('detectMismatches', () => { it('should detect no mismatches when costs match', async () => { await using fixture = await createFixture({ 'test.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.00015, // 50 * 0.000003 = 0.00015 (matches calculated) version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 0, }, }, }), }); const stats = await detectMismatches(fixture.path); expect(stats.totalEntries).toBe(1); expect(stats.entriesWithBoth).toBe(1); expect(stats.matches).toBe(1); expect(stats.mismatches).toBe(0); expect(stats.discrepancies).toHaveLength(0); }); it('should detect mismatches when costs differ significantly', async () => { await using fixture = await createFixture({ 'test.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.1, // Significantly different from calculated cost version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10, }, }, }), }); const stats = await detectMismatches(fixture.path); expect(stats.totalEntries).toBe(1); expect(stats.entriesWithBoth).toBe(1); expect(stats.matches).toBe(0); expect(stats.mismatches).toBe(1); expect(stats.discrepancies).toHaveLength(1); const discrepancy = stats.discrepancies[0]; expect(discrepancy).toBeDefined(); expect(discrepancy?.file).toBe('test.jsonl'); expect(discrepancy?.model).toBe('claude-sonnet-4-20250514'); expect(discrepancy?.originalCost).toBe(0.1); expect(discrepancy?.percentDiff).toBeGreaterThan(0.1); }); it('should handle entries without costUSD or model', async () => { await using fixture = await createFixture({ 'test.jsonl': [ JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', // No costUSD message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, message: { // No model usage: { input_tokens: 50, output_tokens: 10 }, }, }), ].join('\n'), }); const stats = await detectMismatches(fixture.path); expect(stats.totalEntries).toBe(2); expect(stats.entriesWithBoth).toBe(0); expect(stats.matches).toBe(0); expect(stats.mismatches).toBe(0); }); it('should skip synthetic models', async () => { await using fixture = await createFixture({ 'test.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, message: { model: '', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const stats = await detectMismatches(fixture.path); expect(stats.totalEntries).toBe(1); expect(stats.entriesWithBoth).toBe(0); }); it('should skip invalid JSON lines', async () => { await using fixture = await createFixture({ 'test.jsonl': [ JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), 'invalid json line', JSON.stringify({ timestamp: '2024-01-02T12:00:00Z', costUSD: 0.002, message: { model: 'claude-opus-4-20250514', usage: { input_tokens: 100, output_tokens: 20 }, }, }), ].join('\n'), }); const stats = await detectMismatches(fixture.path); expect(stats.totalEntries).toBe(2); // Only valid entries counted }); it('should detect mismatches for claude-opus-4-20250514', async () => { await using fixture = await createFixture({ 'opus-test.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.5, // Significantly different from calculated cost version: '1.0.0', message: { model: 'claude-opus-4-20250514', usage: { input_tokens: 100, output_tokens: 50, }, }, }), }); const stats = await detectMismatches(fixture.path); expect(stats.totalEntries).toBe(1); expect(stats.entriesWithBoth).toBe(1); expect(stats.mismatches).toBe(1); expect(stats.discrepancies).toHaveLength(1); const discrepancy = stats.discrepancies[0]; expect(discrepancy).toBeDefined(); expect(discrepancy?.file).toBe('opus-test.jsonl'); expect(discrepancy?.model).toBe('claude-opus-4-20250514'); expect(discrepancy?.originalCost).toBe(0.5); expect(discrepancy?.percentDiff).toBeGreaterThan(0.1); }); it('should track model statistics', async () => { await using fixture = await createFixture({ 'test.jsonl': [ JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.00015, // 50 * 0.000003 = 0.00015 (matches) message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 0 }, }, }), JSON.stringify({ timestamp: '2024-01-02T12:00:00Z', costUSD: 0.001, // Mismatch with calculated cost (0.0003) message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), ].join('\n'), }); const stats = await detectMismatches(fixture.path); expect(stats.modelStats.has('claude-sonnet-4-20250514')).toBe(true); const modelStat = stats.modelStats.get('claude-sonnet-4-20250514'); expect(modelStat).toBeDefined(); expect(modelStat?.total).toBe(2); expect(modelStat?.matches).toBe(1); expect(modelStat?.mismatches).toBe(1); }); it('should track version statistics', async () => { await using fixture = await createFixture({ 'test.jsonl': [ JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.00015, // 50 * 0.000003 = 0.00015 (matches) version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 0 }, }, }), JSON.stringify({ timestamp: '2024-01-02T12:00:00Z', costUSD: 0.001, // Mismatch with calculated cost (0.0003) version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), ].join('\n'), }); const stats = await detectMismatches(fixture.path); expect(stats.versionStats.has('1.0.0')).toBe(true); const versionStat = stats.versionStats.get('1.0.0'); expect(versionStat).toBeDefined(); expect(versionStat?.total).toBe(2); expect(versionStat?.matches).toBe(1); expect(versionStat?.mismatches).toBe(1); }); }); describe('printMismatchReport', () => { it('should work without errors for basic cases', () => { const stats = { totalEntries: 10, entriesWithBoth: 0, matches: 0, mismatches: 0, discrepancies: [], modelStats: new Map(), versionStats: new Map(), }; expect(() => printMismatchReport(stats)).not.toThrow(); }); it('should work with complex stats without errors', () => { const modelStats = new Map(); modelStats.set('claude-sonnet-4-20250514', { total: 10, matches: 8, mismatches: 2, avgPercentDiff: 5.5, }); const versionStats = new Map(); versionStats.set('1.0.0', { total: 10, matches: 8, mismatches: 2, avgPercentDiff: 3.2, }); const discrepancies = [ { file: 'test1.jsonl', timestamp: '2024-01-01T12:00:00Z', model: 'claude-sonnet-4-20250514', originalCost: 0.001, calculatedCost: 0.0015, difference: 0.0005, percentDiff: 50.0, usage: { input_tokens: 100, output_tokens: 20 }, }, ]; const stats = { totalEntries: 10, entriesWithBoth: 10, matches: 8, mismatches: 2, discrepancies, modelStats, versionStats, }; expect(() => printMismatchReport(stats)).not.toThrow(); }); it('should work with sample count limit', () => { const discrepancies = [ { file: 'test.jsonl', timestamp: '2024-01-01T12:00:00Z', model: 'claude-sonnet-4-20250514', originalCost: 0.001, calculatedCost: 0.0015, difference: 0.0005, percentDiff: 50.0, usage: { input_tokens: 100, output_tokens: 20 }, }, ]; const stats = { totalEntries: 10, entriesWithBoth: 10, matches: 9, mismatches: 1, discrepancies, modelStats: new Map(), versionStats: new Map(), }; expect(() => printMismatchReport(stats, 0)).not.toThrow(); expect(() => printMismatchReport(stats, 1)).not.toThrow(); }); }); }); } ================================================ FILE: apps/ccusage/src/index.ts ================================================ #!/usr/bin/env node /** * @fileoverview Main entry point for ccusage CLI tool * * This is the main entry point for the ccusage command-line interface tool. * It provides analysis of Claude Code usage data from local JSONL files. * * @module index */ /* eslint-disable antfu/no-top-level-await */ import { run } from './commands/index.ts'; await run(); ================================================ FILE: apps/ccusage/src/logger.ts ================================================ /** * @fileoverview Logging utilities for the ccusage application * * This module provides configured logger instances using consola for consistent * logging throughout the application with package name tagging. * * @module logger */ import { createLogger, log as internalLog } from '@ccusage/internal/logger'; import { name } from '../package.json'; /** * Application logger instance with package name tag */ export const logger = createLogger(name); /** * Direct console.log function for cases where logger formatting is not desired */ export const log = internalLog; ================================================ FILE: apps/ccusage/test/statusline-test-opus4.json ================================================ { "session_id": "test-session-opus4", "transcript_path": "test/test-transcript.jsonl", "cwd": "/Users/test/project", "model": { "id": "claude-opus-4-1-20250805", "display_name": "Opus 4.1" }, "workspace": { "current_dir": "/Users/test/project", "project_dir": "/Users/test/project" }, "version": "1.0.88", "output_style": { "name": "default" }, "cost": { "total_cost_usd": 0.0892, "total_duration_ms": 180000, "total_api_duration_ms": 12000, "total_lines_added": 0, "total_lines_removed": 0 }, "context_window": { "total_input_tokens": 85000, "total_output_tokens": 5000, "context_window_size": 200000 }, "exceeds_200k_tokens": false } ================================================ FILE: apps/ccusage/test/statusline-test-sonnet4.json ================================================ { "session_id": "test-session-sonnet4", "transcript_path": "test/test-transcript.jsonl", "cwd": "/Users/test/project", "model": { "id": "claude-sonnet-4-20250514", "display_name": "Sonnet 4" }, "workspace": { "current_dir": "/Users/test/project", "project_dir": "/Users/test/project" }, "version": "1.0.88", "output_style": { "name": "default" }, "cost": { "total_cost_usd": 0.0245, "total_duration_ms": 120000, "total_api_duration_ms": 8500, "total_lines_added": 0, "total_lines_removed": 0 }, "context_window": { "total_input_tokens": 35000, "total_output_tokens": 2500, "context_window_size": 200000 }, "exceeds_200k_tokens": false } ================================================ FILE: apps/ccusage/test/statusline-test-sonnet41.json ================================================ { "session_id": "test-session-sonnet41", "transcript_path": "test/test-transcript.jsonl", "cwd": "/Users/test/project", "model": { "id": "claude-sonnet-4-1-20250805", "display_name": "Sonnet 4.1" }, "workspace": { "current_dir": "/Users/test/project", "project_dir": "/Users/test/project" }, "version": "1.0.88", "output_style": { "name": "default" }, "cost": { "total_cost_usd": 0.0356, "total_duration_ms": 150000, "total_api_duration_ms": 9500, "total_lines_added": 0, "total_lines_removed": 0 }, "context_window": { "total_input_tokens": 52000, "total_output_tokens": 3800, "context_window_size": 200000 }, "exceeds_200k_tokens": false } ================================================ FILE: apps/ccusage/test/statusline-test.json ================================================ { "session_id": "73cc9f9a-2775-4418-beec-bc36b62a1c6f", "transcript_path": "/Users/ryoppippi/.config/claude/projects/-Users-ryoppippi-ghq-github-com-ryoppippi-ccusage/73cc9f9a-2775-4418-beec-bc36b62a1c6f.jsonl", "cwd": "/Users/ryoppippi/ghq/github.com/ryoppippi/ccusage", "model": { "id": "claude-sonnet-4-20250514", "display_name": "Sonnet 4" }, "workspace": { "current_dir": "/Users/ryoppippi/ghq/github.com/ryoppippi/ccusage", "project_dir": "/Users/ryoppippi/ghq/github.com/ryoppippi/ccusage" }, "version": "1.0.88", "output_style": { "name": "default" }, "cost": { "total_cost_usd": 0.056266149999999994, "total_duration_ms": 164055, "total_api_duration_ms": 13577, "total_lines_added": 0, "total_lines_removed": 0 }, "context_window": { "total_input_tokens": 42500, "total_output_tokens": 3200, "context_window_size": 200000 }, "exceeds_200k_tokens": false } ================================================ FILE: apps/ccusage/test/test-transcript.jsonl ================================================ {"type":"user","message":{}} {"type":"assistant","message":{"usage":{"input_tokens":1000,"output_tokens":50,"cache_creation_input_tokens":100,"cache_read_input_tokens":500}}} {"type":"user","message":{}} {"type":"assistant","message":{"usage":{"input_tokens":2000,"output_tokens":100,"cache_creation_input_tokens":200,"cache_read_input_tokens":800}}} ================================================ FILE: apps/ccusage/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "jsx": "react-jsx", // Environment setup & latest features "lib": ["ESNext"], "moduleDetection": "force", "module": "Preserve", // Bundler mode "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["@types/bun", "vitest/globals", "vitest/importMeta"], "allowImportingTsExtensions": true, "allowJs": true, // Best practices "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noEmit": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true }, "exclude": ["dist"] } ================================================ FILE: apps/ccusage/tsdown.config.ts ================================================ import { defineConfig } from 'tsdown'; import Macros from 'unplugin-macros/rolldown'; export default defineConfig({ entry: [ './src/*.ts', '!./src/**/*.test.ts', // Exclude test files '!./src/_*.ts', // Exclude internal files with underscore prefix ], outDir: 'dist', format: 'esm', clean: true, sourcemap: false, minify: 'dce-only', treeshake: true, fixedExtension: false, dts: { tsgo: false, resolve: ['type-fest', 'valibot', '@ccusage/internal', '@ccusage/terminal'], }, publint: true, unused: true, exports: { devExports: true, }, nodeProtocol: true, plugins: [ Macros({ include: ['src/index.ts', 'src/_pricing-fetcher.ts'], }), ], define: { 'import.meta.vitest': 'undefined', }, }); ================================================ FILE: apps/ccusage/vitest.config.ts ================================================ import Macros from 'unplugin-macros/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { watch: false, includeSource: ['src/**/*.{js,ts}'], globals: true, }, plugins: [ Macros({ include: ['src/index.ts', 'src/pricing-fetcher.ts'], }) as any, // vitest bundles its own vite types, so relax plugin typing here ], }); ================================================ FILE: apps/codex/CLAUDE.md ================================================ # Codex CLI Notes ## Log Sources - Codex session usage is recorded under `${CODEX_HOME:-~/.codex}/sessions/` (the CLI resolves `CODEX_HOME` and falls back to `~/.codex`). - Each JSONL line is an `event_msg` with `payload.type === "token_count"`. - `payload.info.total_token_usage` holds cumulative totals; `payload.info.last_token_usage` is the delta for the most recent turn. - When only cumulative totals are present, we subtract the previous totals to recover a per-event delta. ## Token Fields - `input_tokens`: total input tokens sent to the model. - `cached_input_tokens`: cached portion of the input (prompt-caching). - `output_tokens`: normal output tokens (includes completion text). - `reasoning_output_tokens`: structured reasoning tokens counted separately by OpenAI. - `total_tokens`: either provided directly or, for legacy entries, recomputed as `input + output` (reasoning is informational and already included in `output`). -## Cost Calculation - Pricing is pulled from LiteLLM's public JSON (`model_prices_and_context_window.json`). - The CLI trusts the model metadata emitted in each `turn_context`. Sessions missing that metadata (observed in early September 2025 builds) fall back to `gpt-5` so the tokens remain visible, but the pricing should be considered approximate. These events are tagged with `isFallbackModel === true` and surface as `isFallback` in aggregated JSON. - Per-model pricing is fetched through the shared `LiteLLMPricingFetcher` with an offline cache macro scoped to Codex-prefixed models. Aliases (e.g. `gpt-5-codex → gpt-5`) are handled in `CodexPricingSource` for pricing parity. - Cost formula per model/date: - Non-cached input: `(input_tokens - cached_input_tokens) / 1_000_000 * input_cost_per_mtoken`. - Cached input: `cached_input_tokens / 1_000_000 * cached_input_cost_per_mtoken` (falls back to input price when missing). - Output: `output_tokens / 1_000_000 * output_cost_per_mtoken`. - Cached token rate for `gpt-5` (2025-08-07 pricing): - Input: $0.00125 per 1K tokens (→ $1.25 per 1M). - Cached input: $0.000125 per 1K tokens (→ $0.125 per 1M). - Output: $0.01 per 1K tokens (→ $10 per 1M). - Command flag `--offline` forces use of the embedded pricing snapshot. ### Token mapping & reasoning notes | Field | Meaning | Billing treatment | | ----------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ | | `input_tokens` | Prompt tokens sent this turn | Priced at `input_cost_per_mtoken` minus the cached share | | `cached_input_tokens`/`cache_read_input_tokens` | Prompt tokens satisfied from cache | Priced at `cached_input_cost_per_mtoken` (falls back to input price) | | `output_tokens` | Completion tokens, including reasoning cost | Priced at `output_cost_per_mtoken` | | `reasoning_output_tokens` | Optional breakdown for reasoning | Informational only; already included in `output_tokens` | | `total_tokens` | Cumulative total emitted by Codex | Used verbatim when present; legacy entries fall back to `input + output` | Parsing normalizes every event through these rules. When we have to synthesize totals for legacy JSONL files we explicitly skip adding reasoning so the display matches what Codex billed. Events that rely on model/pricing fallbacks carry `isFallbackModel === true`, and aggregated model rows expose `isFallback` so table/JSON output highlights the assumption. ## CLI Usage - Treat Codex as a sibling to `apps/ccusage`; whenever possible reuse the same shared packages (`@ccusage/terminal`, pricing helpers, logging), command names, and flag semantics. Diverge only when Codex-specific data forces it and document the reason inline. - Codex is packaged as a bundled CLI. Keep every runtime dependency in `devDependencies` so the bundle includes the code that ships. - Entry point remains Gunshi-based; only `daily` subcommand is wired for now. - Session discovery relies solely on `CODEX_HOME`; there is no explicit `--dir` override. - `--json` toggles structured output; totals include aggregated tokens and USD cost. - Table view lists models per day with their token totals in parentheses. ## Testing Notes - Tests rely on `fs-fixture` with `using` to ensure cleanup. - Pricing tests inject stub offline loaders to avoid network access. - All vitest blocks live alongside implementation files via `if (import.meta.vitest != null)`. ================================================ FILE: apps/codex/README.md ================================================
ccusage logo

@ccusage/codex

Socket Badge npm version NPM Downloads install size DeepWiki

Codex CLI usage screenshot
> Analyze OpenAI Codex CLI usage logs with the same reporting experience as ccusage. > ⚠️ Beta: The Codex CLI support is experimental. Expect breaking changes until the upstream Codex tooling stabilizes. ## Quick Start ```bash # Recommended - always include @latest npx @ccusage/codex@latest --help bunx @ccusage/codex@latest --help # ⚠️ MUST include @latest with bunx # Alternative package runners pnpm dlx @ccusage/codex pnpx @ccusage/codex # Using deno (with security flags) deno run -E -R=$HOME/.codex/ -S=homedir -N='raw.githubusercontent.com:443' npm:@ccusage/codex@latest --help ``` > ⚠️ **Critical for bunx users**: Bun 1.2.x's bunx prioritizes binaries matching the package name suffix when given a scoped package. For `@ccusage/codex`, it looks for a `codex` binary in PATH first. If you have an existing `codex` command installed (e.g., GitHub Copilot's codex), that will be executed instead. **Always use `bunx @ccusage/codex@latest` with the version tag** to force bunx to fetch and run the correct package. ### Recommended: Shell Alias Since `npx @ccusage/codex@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias: ```bash # bash/zsh: alias ccusage-codex='bunx @ccusage/codex@latest' # fish: alias ccusage-codex 'bunx @ccusage/codex@latest' # Then simply run: ccusage-codex daily ccusage-codex monthly --json ``` > 💡 The CLI looks for Codex session JSONL files under `CODEX_HOME` (defaults to `~/.codex`). ## Common Commands ```bash # Daily usage grouped by date (default command) npx @ccusage/codex@latest daily # Date range filtering npx @ccusage/codex@latest daily --since 20250911 --until 20250917 # JSON output for scripting npx @ccusage/codex@latest daily --json # Monthly usage grouped by month npx @ccusage/codex@latest monthly # Monthly JSON report for integrations npx @ccusage/codex@latest monthly --json # Session-level detailed report npx @ccusage/codex@latest sessions ``` Useful environment variables: - `CODEX_HOME` – override the root directory that contains Codex session folders - `LOG_LEVEL` – controla consola log verbosity (0 silent … 5 trace) ℹ️ The CLI now relies on the model metadata recorded in each `turn_context`. Sessions emitted during early September 2025 that lack this metadata are skipped to avoid mispricing. Newer builds of the Codex CLI restore the model field, and aliases such as `gpt-5-codex` automatically resolve to the correct LiteLLM pricing entry. 📦 For legacy JSONL files that never emitted `turn_context` metadata, the CLI falls back to treating the tokens as `gpt-5` so that usage still appears in reports (pricing is therefore approximate for those sessions). In JSON output you will also see `"isFallback": true` on those model entries. ## Features - 📊 Responsive terminal tables shared with the `ccusage` CLI - 💵 Offline-first pricing cache with automatic LiteLLM refresh when needed - 🤖 Per-model token and cost aggregation, including cached token accounting - 📅 Daily and monthly rollups with identical CLI options - 📄 JSON output for further processing or scripting ## Documentation For detailed guides and examples, visit **[ccusage.com/guide/codex](https://ccusage.com/guide/codex/)**. ## Sponsors ### Featured Sponsor Check out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)

ccusage: The Claude Code cost scorecard that went viral

## License MIT © [@ryoppippi](https://github.com/ryoppippi) ================================================ FILE: apps/codex/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; /** @type {import('eslint').Linter.FlatConfig[]} */ const config = ryoppippi( { type: 'app', stylistic: false, }, { rules: { 'test/no-importing-vitest-globals': 'error', }, }, ); export default config; ================================================ FILE: apps/codex/package.json ================================================ { "name": "@ccusage/codex", "type": "module", "version": "18.0.10", "description": "Usage analysis tool for OpenAI Codex sessions", "author": "ryoppippi", "license": "MIT", "funding": "https://github.com/ryoppippi/ccusage?sponsor=1", "homepage": "https://github.com/ryoppippi/ccusage#readme", "repository": { "type": "git", "url": "git+https://github.com/ryoppippi/ccusage.git", "directory": "apps/codex" }, "bugs": { "url": "https://github.com/ryoppippi/ccusage/issues" }, "main": "./dist/index.js", "module": "./dist/index.js", "bin": { "ccusage-codex": "./src/index.ts" }, "files": [ "dist" ], "publishConfig": { "bin": { "ccusage-codex": "./dist/index.js" } }, "engines": { "node": ">=20.19.4" }, "scripts": { "build": "tsdown", "format": "pnpm run lint --fix", "lint": "eslint --cache .", "prepack": "pnpm run build && clean-pkg-json", "prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build", "start": "bun ./src/index.ts", "test": "TZ=UTC vitest", "typecheck": "tsgo --noEmit" }, "devDependencies": { "@ccusage/internal": "workspace:*", "@ccusage/terminal": "workspace:*", "@praha/byethrow": "catalog:runtime", "@ryoppippi/eslint-config": "catalog:lint", "@typescript/native-preview": "catalog:types", "clean-pkg-json": "catalog:release", "eslint": "catalog:lint", "fast-sort": "catalog:runtime", "fs-fixture": "catalog:testing", "gunshi": "catalog:runtime", "picocolors": "catalog:runtime", "tinyglobby": "catalog:runtime", "tsdown": "catalog:build", "unplugin-macros": "catalog:build", "unplugin-unused": "catalog:build", "valibot": "catalog:runtime", "vitest": "catalog:testing" } } ================================================ FILE: apps/codex/src/_consts.ts ================================================ import os from 'node:os'; import path from 'node:path'; export const CODEX_HOME_ENV = 'CODEX_HOME'; export const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex'); export const DEFAULT_SESSION_SUBDIR = 'sessions'; export const SESSION_GLOB = '**/*.jsonl'; export const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; export const DEFAULT_LOCALE = 'en-CA'; export const DEFAULT_PRECISION = 2; export const MILLION = 1_000_000; export const PRICING_CACHE_TTL_MS = 1000 * 60 * 5; // 5 minutes ================================================ FILE: apps/codex/src/_macro.ts ================================================ import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; import { createPricingDataset, fetchLiteLLMPricingDataset, filterPricingDataset, } from '@ccusage/internal/pricing-fetch-utils'; const CODEX_MODEL_PREFIXES = [ 'gpt-5', 'gpt-5-', 'openai/gpt-5', 'azure/gpt-5', 'openrouter/openai/gpt-5', ]; function isCodexModel(modelName: string, _pricing: LiteLLMModelPricing): boolean { return CODEX_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix)); } export async function prefetchCodexPricing(): Promise> { try { const dataset = await fetchLiteLLMPricingDataset(); return filterPricingDataset(dataset, isCodexModel); } catch (error) { console.warn('Failed to prefetch Codex pricing data, proceeding with empty cache.', error); return createPricingDataset(); } } ================================================ FILE: apps/codex/src/_shared-args.ts ================================================ import type { Args } from 'gunshi'; import { DEFAULT_LOCALE, DEFAULT_TIMEZONE } from './_consts.ts'; export const sharedArgs = { json: { type: 'boolean', short: 'j', description: 'Output report as JSON', default: false, }, since: { type: 'string', short: 's', description: 'Filter from date (YYYY-MM-DD or YYYYMMDD)', }, until: { type: 'string', short: 'u', description: 'Filter until date (inclusive)', }, timezone: { type: 'string', short: 'z', description: 'Timezone for date grouping (IANA)', default: DEFAULT_TIMEZONE, }, locale: { type: 'string', short: 'l', description: 'Locale for formatting', default: DEFAULT_LOCALE, }, offline: { type: 'boolean', short: 'O', description: 'Use cached pricing data instead of fetching from LiteLLM', default: false, negatable: true, }, compact: { type: 'boolean', description: 'Force compact table layout for narrow terminals', default: false, }, color: { // --color and FORCE_COLOR=1 is handled by picocolors type: 'boolean', description: 'Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.', }, noColor: { // --no-color and NO_COLOR=1 is handled by picocolors type: 'boolean', description: 'Disable colored output (default: auto). NO_COLOR=1 has the same effect.', }, } as const satisfies Args; ================================================ FILE: apps/codex/src/_types.ts ================================================ export type TokenUsageDelta = { inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; }; export type TokenUsageEvent = TokenUsageDelta & { timestamp: string; sessionId: string; model?: string; isFallbackModel?: boolean; }; export type ModelUsage = TokenUsageDelta & { isFallback?: boolean; }; export type DailyUsageSummary = { date: string; firstTimestamp: string; costUSD: number; models: Map; } & TokenUsageDelta; export type MonthlyUsageSummary = { month: string; firstTimestamp: string; costUSD: number; models: Map; } & TokenUsageDelta; export type SessionUsageSummary = { sessionId: string; firstTimestamp: string; lastTimestamp: string; costUSD: number; models: Map; } & TokenUsageDelta; export type ModelPricing = { inputCostPerMToken: number; cachedInputCostPerMToken: number; outputCostPerMToken: number; }; export type PricingLookupResult = { model: string; pricing: ModelPricing; }; export type PricingSource = { getPricing: (model: string) => Promise; }; export type DailyReportRow = { date: string; inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; costUSD: number; models: Record; }; export type MonthlyReportRow = { month: string; inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; costUSD: number; models: Record; }; export type SessionReportRow = { sessionId: string; lastActivity: string; sessionFile: string; directory: string; inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; costUSD: number; models: Record; }; ================================================ FILE: apps/codex/src/command-utils.ts ================================================ import { sort } from 'fast-sort'; export type UsageGroup = { inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningOutputTokens: number; }; export function splitUsageTokens(usage: UsageGroup): { inputTokens: number; reasoningTokens: number; cacheReadTokens: number; outputTokens: number; } { const cacheReadTokens = Math.min(usage.cachedInputTokens, usage.inputTokens); const inputTokens = Math.max(usage.inputTokens - cacheReadTokens, 0); const outputTokens = Math.max(usage.outputTokens, 0); const rawReasoning = usage.reasoningOutputTokens ?? 0; const reasoningTokens = Math.max(0, Math.min(rawReasoning, outputTokens)); return { inputTokens, reasoningTokens, cacheReadTokens, outputTokens, }; } export function formatModelsList( models: Record, ): string[] { return sort(Object.entries(models)) .asc(([model]) => model) .map(([model, data]) => (data.isFallback === true ? `${model} (fallback)` : model)); } ================================================ FILE: apps/codex/src/commands/daily.ts ================================================ import process from 'node:process'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; import { DEFAULT_TIMEZONE } from '../_consts.ts'; import { sharedArgs } from '../_shared-args.ts'; import { formatModelsList, splitUsageTokens } from '../command-utils.ts'; import { buildDailyReport } from '../daily-report.ts'; import { loadTokenUsageEvents } from '../data-loader.ts'; import { normalizeFilterDate } from '../date-utils.ts'; import { log, logger } from '../logger.ts'; import { CodexPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 8; export const dailyCommand = define({ name: 'daily', description: 'Show Codex token usage grouped by day', args: sharedArgs, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); if (jsonOutput) { logger.level = 0; } let since: string | undefined; let until: string | undefined; try { since = normalizeFilterDate(ctx.values.since); until = normalizeFilterDate(ctx.values.until); } catch (error) { logger.error(String(error)); process.exit(1); } const { events, missingDirectories } = await loadTokenUsageEvents(); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); } if (events.length === 0) { log(jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No Codex usage data found.'); return; } const pricingSource = new CodexPricingSource({ offline: ctx.values.offline, }); try { const rows = await buildDailyReport(events, { pricingSource, timezone: ctx.values.timezone, locale: ctx.values.locale, since, until, }); if (rows.length === 0) { log( jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No Codex usage data found for provided filters.', ); return; } const totals = rows.reduce( (acc, row) => { acc.inputTokens += row.inputTokens; acc.cachedInputTokens += row.cachedInputTokens; acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; acc.costUSD += row.costUSD; return acc; }, { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, costUSD: 0, }, ); if (jsonOutput) { log( JSON.stringify( { daily: rows, totals, }, null, 2, ), ); return; } logger.box( `Codex Token Usage Report - Daily (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, ); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Date', 'Models', 'Input', 'Output', 'Reasoning', 'Cache Read', 'Total Tokens', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Date', 'Models', 'Input', 'Output', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: ctx.values.compact, style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); const totalsForDisplay = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, totalTokens: 0, costUSD: 0, }; for (const row of rows) { const split = splitUsageTokens(row); totalsForDisplay.inputTokens += split.inputTokens; totalsForDisplay.outputTokens += split.outputTokens; totalsForDisplay.reasoningTokens += split.reasoningTokens; totalsForDisplay.cacheReadTokens += split.cacheReadTokens; totalsForDisplay.totalTokens += row.totalTokens; totalsForDisplay.costUSD += row.costUSD; table.push([ row.date, formatModelsDisplayMultiline(formatModelsList(row.models)), formatNumber(split.inputTokens), formatNumber(split.outputTokens), formatNumber(split.reasoningTokens), formatNumber(split.cacheReadTokens), formatNumber(row.totalTokens), formatCurrency(row.costUSD), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totalsForDisplay.inputTokens)), pc.yellow(formatNumber(totalsForDisplay.outputTokens)), pc.yellow(formatNumber(totalsForDisplay.reasoningTokens)), pc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)), pc.yellow(formatNumber(totalsForDisplay.totalTokens)), pc.yellow(formatCurrency(totalsForDisplay.costUSD)), ]); log(table.toString()); if (table.isCompactMode()) { logger.info('\nRunning in Compact Mode'); logger.info('Expand terminal width to see cache metrics and total tokens'); } } finally { pricingSource[Symbol.dispose](); } }, }); ================================================ FILE: apps/codex/src/commands/monthly.ts ================================================ import process from 'node:process'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; import { DEFAULT_TIMEZONE } from '../_consts.ts'; import { sharedArgs } from '../_shared-args.ts'; import { formatModelsList, splitUsageTokens } from '../command-utils.ts'; import { loadTokenUsageEvents } from '../data-loader.ts'; import { normalizeFilterDate } from '../date-utils.ts'; import { log, logger } from '../logger.ts'; import { buildMonthlyReport } from '../monthly-report.ts'; import { CodexPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 8; export const monthlyCommand = define({ name: 'monthly', description: 'Show Codex token usage grouped by month', args: sharedArgs, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); if (jsonOutput) { logger.level = 0; } let since: string | undefined; let until: string | undefined; try { since = normalizeFilterDate(ctx.values.since); until = normalizeFilterDate(ctx.values.until); } catch (error) { logger.error(String(error)); process.exit(1); } const { events, missingDirectories } = await loadTokenUsageEvents(); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); } if (events.length === 0) { log( jsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No Codex usage data found.', ); return; } const pricingSource = new CodexPricingSource({ offline: ctx.values.offline, }); try { const rows = await buildMonthlyReport(events, { pricingSource, timezone: ctx.values.timezone, locale: ctx.values.locale, since, until, }); if (rows.length === 0) { log( jsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No Codex usage data found for provided filters.', ); return; } const totals = rows.reduce( (acc, row) => { acc.inputTokens += row.inputTokens; acc.cachedInputTokens += row.cachedInputTokens; acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; acc.costUSD += row.costUSD; return acc; }, { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, costUSD: 0, }, ); if (jsonOutput) { log( JSON.stringify( { monthly: rows, totals, }, null, 2, ), ); return; } logger.box( `Codex Token Usage Report - Monthly (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, ); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Month', 'Models', 'Input', 'Output', 'Reasoning', 'Cache Read', 'Total Tokens', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Month', 'Models', 'Input', 'Output', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: ctx.values.compact, style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); const totalsForDisplay = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, totalTokens: 0, costUSD: 0, }; for (const row of rows) { const split = splitUsageTokens(row); totalsForDisplay.inputTokens += split.inputTokens; totalsForDisplay.outputTokens += split.outputTokens; totalsForDisplay.reasoningTokens += split.reasoningTokens; totalsForDisplay.cacheReadTokens += split.cacheReadTokens; totalsForDisplay.totalTokens += row.totalTokens; totalsForDisplay.costUSD += row.costUSD; table.push([ row.month, formatModelsDisplayMultiline(formatModelsList(row.models)), formatNumber(split.inputTokens), formatNumber(split.outputTokens), formatNumber(split.reasoningTokens), formatNumber(split.cacheReadTokens), formatNumber(row.totalTokens), formatCurrency(row.costUSD), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totalsForDisplay.inputTokens)), pc.yellow(formatNumber(totalsForDisplay.outputTokens)), pc.yellow(formatNumber(totalsForDisplay.reasoningTokens)), pc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)), pc.yellow(formatNumber(totalsForDisplay.totalTokens)), pc.yellow(formatCurrency(totalsForDisplay.costUSD)), ]); log(table.toString()); if (table.isCompactMode()) { logger.info('\nRunning in Compact Mode'); logger.info('Expand terminal width to see cache metrics and total tokens'); } } finally { pricingSource[Symbol.dispose](); } }, }); ================================================ FILE: apps/codex/src/commands/session.ts ================================================ import process from 'node:process'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; import { DEFAULT_TIMEZONE } from '../_consts.ts'; import { sharedArgs } from '../_shared-args.ts'; import { formatModelsList, splitUsageTokens } from '../command-utils.ts'; import { loadTokenUsageEvents } from '../data-loader.ts'; import { formatDisplayDate, formatDisplayDateTime, normalizeFilterDate, toDateKey, } from '../date-utils.ts'; import { log, logger } from '../logger.ts'; import { CodexPricingSource } from '../pricing.ts'; import { buildSessionReport } from '../session-report.ts'; const TABLE_COLUMN_COUNT = 11; export const sessionCommand = define({ name: 'session', description: 'Show Codex token usage grouped by session', args: sharedArgs, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); if (jsonOutput) { logger.level = 0; } let since: string | undefined; let until: string | undefined; try { since = normalizeFilterDate(ctx.values.since); until = normalizeFilterDate(ctx.values.until); } catch (error) { logger.error(String(error)); process.exit(1); } const { events, missingDirectories } = await loadTokenUsageEvents(); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); } if (events.length === 0) { log( jsonOutput ? JSON.stringify({ sessions: [], totals: null }) : 'No Codex usage data found.', ); return; } const pricingSource = new CodexPricingSource({ offline: ctx.values.offline, }); try { const rows = await buildSessionReport(events, { pricingSource, timezone: ctx.values.timezone, locale: ctx.values.locale, since, until, }); if (rows.length === 0) { log( jsonOutput ? JSON.stringify({ sessions: [], totals: null }) : 'No Codex usage data found for provided filters.', ); return; } const totals = rows.reduce( (acc, row) => { acc.inputTokens += row.inputTokens; acc.cachedInputTokens += row.cachedInputTokens; acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; acc.costUSD += row.costUSD; return acc; }, { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, costUSD: 0, }, ); if (jsonOutput) { log( JSON.stringify( { sessions: rows, totals, }, null, 2, ), ); return; } logger.box( `Codex Token Usage Report - Sessions (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, ); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Date', 'Directory', 'Session', 'Models', 'Input', 'Output', 'Reasoning', 'Cache Read', 'Total Tokens', 'Cost (USD)', 'Last Activity', ], colAligns: [ 'left', 'left', 'left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'left', ], compactHead: ['Date', 'Directory', 'Session', 'Input', 'Output', 'Cost (USD)'], compactColAligns: ['left', 'left', 'left', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: ctx.values.compact, style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); const totalsForDisplay = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, totalTokens: 0, costUSD: 0, }; for (const row of rows) { const split = splitUsageTokens(row); totalsForDisplay.inputTokens += split.inputTokens; totalsForDisplay.outputTokens += split.outputTokens; totalsForDisplay.reasoningTokens += split.reasoningTokens; totalsForDisplay.cacheReadTokens += split.cacheReadTokens; totalsForDisplay.totalTokens += row.totalTokens; totalsForDisplay.costUSD += row.costUSD; const dateKey = toDateKey(row.lastActivity, ctx.values.timezone); const displayDate = formatDisplayDate(dateKey, ctx.values.locale, ctx.values.timezone); const directoryDisplay = row.directory === '' ? '-' : row.directory; const sessionFile = row.sessionFile; const shortSession = sessionFile.length > 8 ? `…${sessionFile.slice(-8)}` : sessionFile; table.push([ displayDate, directoryDisplay, shortSession, formatModelsDisplayMultiline(formatModelsList(row.models)), formatNumber(split.inputTokens), formatNumber(split.outputTokens), formatNumber(split.reasoningTokens), formatNumber(split.cacheReadTokens), formatNumber(row.totalTokens), formatCurrency(row.costUSD), formatDisplayDateTime(row.lastActivity, ctx.values.locale, ctx.values.timezone), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ '', '', pc.yellow('Total'), '', pc.yellow(formatNumber(totalsForDisplay.inputTokens)), pc.yellow(formatNumber(totalsForDisplay.outputTokens)), pc.yellow(formatNumber(totalsForDisplay.reasoningTokens)), pc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)), pc.yellow(formatNumber(totalsForDisplay.totalTokens)), pc.yellow(formatCurrency(totalsForDisplay.costUSD)), '', ]); log(table.toString()); if (table.isCompactMode()) { logger.info('\nRunning in Compact Mode'); logger.info( 'Expand terminal width to see directories, cache metrics, total tokens, and last activity', ); } } finally { pricingSource[Symbol.dispose](); } }, }); ================================================ FILE: apps/codex/src/daily-report.ts ================================================ import type { DailyReportRow, DailyUsageSummary, ModelPricing, ModelUsage, PricingSource, TokenUsageEvent, } from './_types.ts'; import { formatDisplayDate, isWithinRange, toDateKey } from './date-utils.ts'; import { addUsage, calculateCostUSD, createEmptyUsage } from './token-utils.ts'; export type DailyReportOptions = { timezone?: string; locale?: string; since?: string; until?: string; pricingSource: PricingSource; }; function createSummary(date: string, initialTimestamp: string): DailyUsageSummary { return { date, firstTimestamp: initialTimestamp, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, costUSD: 0, models: new Map(), }; } export async function buildDailyReport( events: TokenUsageEvent[], options: DailyReportOptions, ): Promise { const timezone = options.timezone; const locale = options.locale; const since = options.since; const until = options.until; const pricingSource = options.pricingSource; const summaries = new Map(); for (const event of events) { const modelName = event.model?.trim(); if (modelName == null || modelName === '') { continue; } const dateKey = toDateKey(event.timestamp, timezone); if (!isWithinRange(dateKey, since, until)) { continue; } const summary = summaries.get(dateKey) ?? createSummary(dateKey, event.timestamp); if (!summaries.has(dateKey)) { summaries.set(dateKey, summary); } addUsage(summary, event); const modelUsage: ModelUsage = summary.models.get(modelName) ?? { ...createEmptyUsage(), isFallback: false, }; if (!summary.models.has(modelName)) { summary.models.set(modelName, modelUsage); } addUsage(modelUsage, event); if (event.isFallbackModel === true) { modelUsage.isFallback = true; } } const uniqueModels = new Set(); for (const summary of summaries.values()) { for (const modelName of summary.models.keys()) { uniqueModels.add(modelName); } } const modelPricing = new Map>>(); for (const modelName of uniqueModels) { modelPricing.set(modelName, await pricingSource.getPricing(modelName)); } const rows: DailyReportRow[] = []; const sortedSummaries = Array.from(summaries.values()).sort((a, b) => a.date.localeCompare(b.date), ); for (const summary of sortedSummaries) { let cost = 0; for (const [modelName, usage] of summary.models) { const pricing = modelPricing.get(modelName); if (pricing == null) { continue; } cost += calculateCostUSD(usage, pricing); } summary.costUSD = cost; const rowModels: Record = {}; for (const [modelName, usage] of summary.models) { rowModels[modelName] = { ...usage }; } rows.push({ date: formatDisplayDate(summary.date, locale, timezone), inputTokens: summary.inputTokens, cachedInputTokens: summary.cachedInputTokens, outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, costUSD: cost, models: rowModels, }); } return rows; } if (import.meta.vitest != null) { describe('buildDailyReport', () => { it('aggregates events by day and calculates costs', async () => { const pricing = new Map([ [ 'gpt-5', { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 }, ], [ 'gpt-5-mini', { inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 }, ], ]); const stubPricingSource: PricingSource = { async getPricing(model: string): Promise { const value = pricing.get(model); if (value == null) { throw new Error(`Missing pricing for ${model}`); } return value; }, }; const report = await buildDailyReport( [ { sessionId: 'session-1', timestamp: '2025-09-11T03:00:00.000Z', model: 'gpt-5', inputTokens: 1_000, cachedInputTokens: 200, outputTokens: 500, reasoningOutputTokens: 0, totalTokens: 1_500, }, { sessionId: 'session-1', timestamp: '2025-09-11T05:00:00.000Z', model: 'gpt-5-mini', inputTokens: 400, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 50, totalTokens: 750, }, { sessionId: 'session-2', timestamp: '2025-09-12T01:00:00.000Z', model: 'gpt-5', inputTokens: 2_000, cachedInputTokens: 0, outputTokens: 800, reasoningOutputTokens: 0, totalTokens: 2_800, }, ], { pricingSource: stubPricingSource, since: '2025-09-11', until: '2025-09-12', }, ); expect(report).toHaveLength(2); const first = report[0]!; expect(first.date).toContain('2025'); expect(first.inputTokens).toBe(1_400); expect(first.cachedInputTokens).toBe(300); expect(first.outputTokens).toBe(700); expect(first.reasoningOutputTokens).toBe(50); // gpt-5: 800 non-cached input @ 1.25, 200 cached @ 0.125, 500 output @ 10 // gpt-5-mini: 300 non-cached input @ 0.6, 100 cached @ 0.06, 200 output @ 2 (reasoning already included) const expectedCost = (800 / 1_000_000) * 1.25 + (200 / 1_000_000) * 0.125 + (500 / 1_000_000) * 10 + (300 / 1_000_000) * 0.6 + (100 / 1_000_000) * 0.06 + (200 / 1_000_000) * 2; expect(first.costUSD).toBeCloseTo(expectedCost, 10); }); }); } ================================================ FILE: apps/codex/src/data-loader.ts ================================================ import type { TokenUsageDelta, TokenUsageEvent } from './_types.ts'; import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { Result } from '@praha/byethrow'; import { createFixture } from 'fs-fixture'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; import { CODEX_HOME_ENV, DEFAULT_CODEX_DIR, DEFAULT_SESSION_SUBDIR, SESSION_GLOB, } from './_consts.ts'; import { logger } from './logger.ts'; type RawUsage = { input_tokens: number; cached_input_tokens: number; output_tokens: number; reasoning_output_tokens: number; total_tokens: number; }; function ensureNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0; } /** * Normalize Codex `token_count` payloads into a predictable shape. * * Codex reports four counters: * - input_tokens * - cached_input_tokens (a.k.a cache_read_input_tokens) * - output_tokens (this already includes any reasoning charge) * - reasoning_output_tokens (informational only) * * Modern JSONL entries also provide `total_tokens`, but legacy ones may omit it. * When that happens we mirror Codex' billing behavior and synthesize * `input + output` (reasoning is treated as part of output, not an extra charge). */ function normalizeRawUsage(value: unknown): RawUsage | null { if (value == null || typeof value !== 'object') { return null; } const record = value as Record; const input = ensureNumber(record.input_tokens); const cached = ensureNumber(record.cached_input_tokens ?? record.cache_read_input_tokens); const output = ensureNumber(record.output_tokens); const reasoning = ensureNumber(record.reasoning_output_tokens); const total = ensureNumber(record.total_tokens); return { input_tokens: input, cached_input_tokens: cached, output_tokens: output, reasoning_output_tokens: reasoning, // LiteLLM pricing treats reasoning tokens as part of the normal output price. Codex // includes them as a separate field but does not add them to total_tokens, so when we // have to synthesize a total (legacy logs), we mirror that behavior with input+output. total_tokens: total > 0 ? total : input + output, }; } function subtractRawUsage(current: RawUsage, previous: RawUsage | null): RawUsage { return { input_tokens: Math.max(current.input_tokens - (previous?.input_tokens ?? 0), 0), cached_input_tokens: Math.max( current.cached_input_tokens - (previous?.cached_input_tokens ?? 0), 0, ), output_tokens: Math.max(current.output_tokens - (previous?.output_tokens ?? 0), 0), reasoning_output_tokens: Math.max( current.reasoning_output_tokens - (previous?.reasoning_output_tokens ?? 0), 0, ), total_tokens: Math.max(current.total_tokens - (previous?.total_tokens ?? 0), 0), }; } /** * Convert cumulative usage into a per-event delta. * * Codex includes the cost of reasoning inside `output_tokens`. The * `reasoning_output_tokens` field is useful for display/debug purposes, but we * must not add it to the billable output again. For legacy totals we therefore * fallback to `input + output`. */ function convertToDelta(raw: RawUsage): TokenUsageDelta { const total = raw.total_tokens > 0 ? raw.total_tokens : raw.input_tokens + raw.output_tokens; const cached = Math.min(raw.cached_input_tokens, raw.input_tokens); return { inputTokens: raw.input_tokens, cachedInputTokens: cached, outputTokens: raw.output_tokens, reasoningOutputTokens: raw.reasoning_output_tokens, totalTokens: total, }; } const recordSchema = v.record(v.string(), v.unknown()); const LEGACY_FALLBACK_MODEL = 'gpt-5'; const entrySchema = v.object({ type: v.string(), payload: v.optional(v.unknown()), timestamp: v.optional(v.string()), }); const tokenCountPayloadSchema = v.object({ type: v.literal('token_count'), info: v.optional(recordSchema), }); function extractModel(value: unknown): string | undefined { const parsed = v.safeParse(recordSchema, value); if (!parsed.success) { return undefined; } const payload = parsed.output; const infoCandidate = payload.info; if (infoCandidate != null) { const infoParsed = v.safeParse(recordSchema, infoCandidate); if (infoParsed.success) { const info = infoParsed.output; const directCandidates = [info.model, info.model_name]; for (const candidate of directCandidates) { const model = asNonEmptyString(candidate); if (model != null) { return model; } } if (info.metadata != null) { const metadataParsed = v.safeParse(recordSchema, info.metadata); if (metadataParsed.success) { const model = asNonEmptyString(metadataParsed.output.model); if (model != null) { return model; } } } } } const fallbackModel = asNonEmptyString(payload.model); if (fallbackModel != null) { return fallbackModel; } if (payload.metadata != null) { const metadataParsed = v.safeParse(recordSchema, payload.metadata); if (metadataParsed.success) { const model = asNonEmptyString(metadataParsed.output.model); if (model != null) { return model; } } } return undefined; } function asNonEmptyString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed === '' ? undefined : trimmed; } export type LoadOptions = { sessionDirs?: string[]; }; export type LoadResult = { events: TokenUsageEvent[]; missingDirectories: string[]; }; export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise { const providedDirs = options.sessionDirs != null && options.sessionDirs.length > 0 ? options.sessionDirs.map((dir) => path.resolve(dir)) : undefined; const codexHomeEnv = process.env[CODEX_HOME_ENV]?.trim(); const codexHome = codexHomeEnv != null && codexHomeEnv !== '' ? path.resolve(codexHomeEnv) : DEFAULT_CODEX_DIR; const defaultSessionsDir = path.join(codexHome, DEFAULT_SESSION_SUBDIR); const sessionDirs = providedDirs ?? [defaultSessionsDir]; const events: TokenUsageEvent[] = []; const missingDirectories: string[] = []; for (const dir of sessionDirs) { const directoryPath = path.resolve(dir); const statResult = await Result.try({ try: stat(directoryPath), catch: (error) => error, }); if (Result.isFailure(statResult)) { missingDirectories.push(directoryPath); continue; } if (!statResult.value.isDirectory()) { missingDirectories.push(directoryPath); continue; } const files = await glob(SESSION_GLOB, { cwd: directoryPath, absolute: true, }); for (const file of files) { const relativeSessionPath = path.relative(directoryPath, file); const normalizedSessionPath = relativeSessionPath.split(path.sep).join('/'); const sessionId = normalizedSessionPath.replace(/\.jsonl$/i, ''); const fileContentResult = await Result.try({ try: readFile(file, 'utf8'), catch: (error) => error, }); if (Result.isFailure(fileContentResult)) { logger.debug('Failed to read Codex session file', fileContentResult.error); continue; } let previousTotals: RawUsage | null = null; let currentModel: string | undefined; let currentModelIsFallback = false; let legacyFallbackUsed = false; const lines = fileContentResult.value.split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); if (trimmed === '') { continue; } const parseLine = Result.try({ try: () => JSON.parse(trimmed) as unknown, catch: (error) => error, }); const parsedResult = parseLine(); if (Result.isFailure(parsedResult)) { continue; } const entryParse = v.safeParse(entrySchema, parsedResult.value); if (!entryParse.success) { continue; } const { type: entryType, payload, timestamp } = entryParse.output; if (entryType === 'turn_context') { const contextPayload = v.safeParse(recordSchema, payload ?? null); if (contextPayload.success) { const contextModel = extractModel(contextPayload.output); if (contextModel != null) { currentModel = contextModel; currentModelIsFallback = false; } } continue; } if (entryType !== 'event_msg') { continue; } const tokenPayloadResult = v.safeParse(tokenCountPayloadSchema, payload ?? undefined); if (!tokenPayloadResult.success) { continue; } if (timestamp == null) { continue; } const info = tokenPayloadResult.output.info; const lastUsage = normalizeRawUsage(info?.last_token_usage); const totalUsage = normalizeRawUsage(info?.total_token_usage); let raw = lastUsage; if (raw == null && totalUsage != null) { raw = subtractRawUsage(totalUsage, previousTotals); } if (totalUsage != null) { previousTotals = totalUsage; } if (raw == null) { continue; } const delta = convertToDelta(raw); if ( delta.inputTokens === 0 && delta.cachedInputTokens === 0 && delta.outputTokens === 0 && delta.reasoningOutputTokens === 0 ) { continue; } const payloadRecordResult = v.safeParse(recordSchema, payload ?? undefined); const extractionSource = payloadRecordResult.success ? Object.assign({}, payloadRecordResult.output, { info }) : { info }; const extractedModel = extractModel(extractionSource); let isFallbackModel = false; if (extractedModel != null) { currentModel = extractedModel; currentModelIsFallback = false; } let model = extractedModel ?? currentModel; if (model == null) { model = LEGACY_FALLBACK_MODEL; isFallbackModel = true; legacyFallbackUsed = true; currentModel = model; currentModelIsFallback = true; } else if (extractedModel == null && currentModelIsFallback) { isFallbackModel = true; } const event: TokenUsageEvent = { sessionId, timestamp, model, inputTokens: delta.inputTokens, cachedInputTokens: delta.cachedInputTokens, outputTokens: delta.outputTokens, reasoningOutputTokens: delta.reasoningOutputTokens, totalTokens: delta.totalTokens, }; if (isFallbackModel) { // Surface the fallback so both table + JSON outputs can annotate pricing that was // inferred rather than sourced from the log metadata. event.isFallbackModel = true; } events.push(event); } if (legacyFallbackUsed) { logger.debug('Legacy Codex session lacked model metadata; applied fallback', { file, model: LEGACY_FALLBACK_MODEL, }); } } } events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); return { events, missingDirectories }; } if (import.meta.vitest != null) { describe('loadTokenUsageEvents', () => { it('parses token_count events and skips entries without model metadata', async () => { await using fixture = await createFixture({ sessions: { 'project-1.jsonl': [ JSON.stringify({ timestamp: '2025-09-11T18:25:30.000Z', type: 'turn_context', payload: { model: 'gpt-5', }, }), JSON.stringify({ timestamp: '2025-09-11T18:25:40.670Z', type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 1_200, cached_input_tokens: 200, output_tokens: 500, reasoning_output_tokens: 0, total_tokens: 1_700, }, last_token_usage: { input_tokens: 1_200, cached_input_tokens: 200, output_tokens: 500, reasoning_output_tokens: 0, total_tokens: 1_700, }, model: 'gpt-5', }, }, }), JSON.stringify({ timestamp: '2025-09-11T18:40:00.000Z', type: 'turn_context', payload: { model: 'gpt-5', }, }), JSON.stringify({ timestamp: '2025-09-12T00:00:00.000Z', type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 2_000, cached_input_tokens: 300, output_tokens: 800, reasoning_output_tokens: 0, total_tokens: 2_800, }, }, }, }), ].join('\n'), }, }); expect(await fixture.exists('sessions/project-1.jsonl')).toBe(true); const { events, missingDirectories } = await loadTokenUsageEvents({ sessionDirs: [fixture.getPath('sessions')], }); expect(missingDirectories).toEqual([]); expect(events).toHaveLength(2); const first = events[0]!; expect(first.model).toBe('gpt-5'); expect(first.inputTokens).toBe(1_200); expect(first.cachedInputTokens).toBe(200); const second = events[1]!; expect(second.model).toBe('gpt-5'); expect(second.inputTokens).toBe(800); expect(second.cachedInputTokens).toBe(100); }); it('falls back to legacy model when metadata is missing entirely', async () => { await using fixture = await createFixture({ sessions: { 'legacy.jsonl': [ JSON.stringify({ timestamp: '2025-09-15T13:00:00.000Z', type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 5_000, cached_input_tokens: 0, output_tokens: 1_000, reasoning_output_tokens: 0, total_tokens: 6_000, }, }, }, }), ].join('\n'), }, }); const { events } = await loadTokenUsageEvents({ sessionDirs: [fixture.getPath('sessions')], }); expect(events).toHaveLength(1); expect(events[0]!.model).toBe('gpt-5'); expect(events[0]!.isFallbackModel).toBe(true); }); }); } ================================================ FILE: apps/codex/src/date-utils.ts ================================================ function safeTimeZone(timezone?: string): string { if (timezone == null || timezone.trim() === '') { return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; } try { // Validate timezone by creating a formatter Intl.DateTimeFormat('en-US', { timeZone: timezone }); return timezone; } catch { return 'UTC'; } } export function toDateKey(timestamp: string, timezone?: string): string { const tz = safeTimeZone(timezone); const date = new Date(timestamp); const formatter = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: tz, }); return formatter.format(date); } export function normalizeFilterDate(value?: string): string | undefined { if (value == null) { return undefined; } const compact = value.replaceAll('-', '').trim(); if (!/^\d{8}$/.test(compact)) { throw new Error(`Invalid date format: ${value}. Expected YYYYMMDD or YYYY-MM-DD.`); } return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`; } export function isWithinRange(dateKey: string, since?: string, until?: string): boolean { const value = dateKey.replaceAll('-', ''); const sinceValue = since?.replaceAll('-', ''); const untilValue = until?.replaceAll('-', ''); if (sinceValue != null && value < sinceValue) { return false; } if (untilValue != null && value > untilValue) { return false; } return true; } export function formatDisplayDate(dateKey: string, locale?: string, _timezone?: string): string { // dateKey is already computed for the target timezone via toDateKey(). // Treat it as a plain calendar date and avoid shifting it by applying a timezone. const [yearStr = '0', monthStr = '1', dayStr = '1'] = dateKey.split('-'); const year = Number.parseInt(yearStr, 10); const month = Number.parseInt(monthStr, 10); const day = Number.parseInt(dayStr, 10); const date = new Date(Date.UTC(year, month - 1, day)); const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC', }); return formatter.format(date); } export function toMonthKey(timestamp: string, timezone?: string): string { const tz = safeTimeZone(timezone); const date = new Date(timestamp); const formatter = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', timeZone: tz, }); const [year, month] = formatter.format(date).split('-'); return `${year}-${month}`; } export function formatDisplayMonth(monthKey: string, locale?: string, _timezone?: string): string { // monthKey is already derived in the target timezone via toMonthKey(). // Render it as a calendar month without shifting by timezone. const [yearStr = '0', monthStr = '1'] = monthKey.split('-'); const year = Number.parseInt(yearStr, 10); const month = Number.parseInt(monthStr, 10); const date = new Date(Date.UTC(year, month - 1, 1)); const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { year: 'numeric', month: 'short', timeZone: 'UTC', }); return formatter.format(date); } export function formatDisplayDateTime( timestamp: string, locale?: string, timezone?: string, ): string { const tz = safeTimeZone(timezone); const date = new Date(timestamp); const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { dateStyle: 'short', timeStyle: 'short', timeZone: tz, }); return formatter.format(date); } ================================================ FILE: apps/codex/src/index.ts ================================================ #!/usr/bin/env node import { run } from './run.ts'; // eslint-disable-next-line antfu/no-top-level-await await run(); ================================================ FILE: apps/codex/src/logger.ts ================================================ import { createLogger, log as internalLog } from '@ccusage/internal/logger'; import { name } from '../package.json'; export const logger = createLogger(name); export const log = internalLog; ================================================ FILE: apps/codex/src/monthly-report.ts ================================================ import type { ModelPricing, ModelUsage, MonthlyReportRow, MonthlyUsageSummary, PricingSource, TokenUsageEvent, } from './_types.ts'; import { formatDisplayMonth, isWithinRange, toDateKey, toMonthKey } from './date-utils.ts'; import { addUsage, calculateCostUSD, createEmptyUsage } from './token-utils.ts'; export type MonthlyReportOptions = { timezone?: string; locale?: string; since?: string; until?: string; pricingSource: PricingSource; }; function createSummary(month: string, initialTimestamp: string): MonthlyUsageSummary { return { month, firstTimestamp: initialTimestamp, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, costUSD: 0, models: new Map(), }; } export async function buildMonthlyReport( events: TokenUsageEvent[], options: MonthlyReportOptions, ): Promise { const timezone = options.timezone; const locale = options.locale; const since = options.since; const until = options.until; const pricingSource = options.pricingSource; const summaries = new Map(); for (const event of events) { const modelName = event.model?.trim(); if (modelName == null || modelName === '') { continue; } const dateKey = toDateKey(event.timestamp, timezone); if (!isWithinRange(dateKey, since, until)) { continue; } const monthKey = toMonthKey(event.timestamp, timezone); const summary = summaries.get(monthKey) ?? createSummary(monthKey, event.timestamp); if (!summaries.has(monthKey)) { summaries.set(monthKey, summary); } addUsage(summary, event); const modelUsage: ModelUsage = summary.models.get(modelName) ?? { ...createEmptyUsage(), isFallback: false, }; if (!summary.models.has(modelName)) { summary.models.set(modelName, modelUsage); } addUsage(modelUsage, event); if (event.isFallbackModel === true) { modelUsage.isFallback = true; } } const uniqueModels = new Set(); for (const summary of summaries.values()) { for (const modelName of summary.models.keys()) { uniqueModels.add(modelName); } } const modelPricing = new Map>>(); for (const modelName of uniqueModels) { modelPricing.set(modelName, await pricingSource.getPricing(modelName)); } const rows: MonthlyReportRow[] = []; const sortedSummaries = Array.from(summaries.values()).sort((a, b) => a.month.localeCompare(b.month), ); for (const summary of sortedSummaries) { let cost = 0; for (const [modelName, usage] of summary.models) { const pricing = modelPricing.get(modelName); if (pricing == null) { continue; } cost += calculateCostUSD(usage, pricing); } summary.costUSD = cost; const rowModels: Record = {}; for (const [modelName, usage] of summary.models) { rowModels[modelName] = { ...usage }; } rows.push({ month: formatDisplayMonth(summary.month, locale, timezone), inputTokens: summary.inputTokens, cachedInputTokens: summary.cachedInputTokens, outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, costUSD: cost, models: rowModels, }); } return rows; } if (import.meta.vitest != null) { describe('buildMonthlyReport', () => { it('aggregates events by month and calculates costs', async () => { const pricing = new Map([ [ 'gpt-5', { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 }, ], [ 'gpt-5-mini', { inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 }, ], ]); const stubPricingSource: PricingSource = { async getPricing(model: string): Promise { const value = pricing.get(model); if (value == null) { throw new Error(`Missing pricing for ${model}`); } return value; }, }; const report = await buildMonthlyReport( [ { sessionId: 'session-1', timestamp: '2025-08-11T03:00:00.000Z', model: 'gpt-5', inputTokens: 1_000, cachedInputTokens: 200, outputTokens: 500, reasoningOutputTokens: 0, totalTokens: 1_500, }, { sessionId: 'session-1', timestamp: '2025-08-20T05:00:00.000Z', model: 'gpt-5-mini', inputTokens: 400, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 50, totalTokens: 750, }, { sessionId: 'session-2', timestamp: '2025-09-12T01:00:00.000Z', model: 'gpt-5', inputTokens: 2_000, cachedInputTokens: 0, outputTokens: 800, reasoningOutputTokens: 0, totalTokens: 2_800, }, ], { pricingSource: stubPricingSource, since: '2025-08-01', until: '2025-09-30', }, ); expect(report).toHaveLength(2); const first = report[0]!; expect(first.inputTokens).toBe(1_400); expect(first.cachedInputTokens).toBe(300); expect(first.outputTokens).toBe(700); expect(first.reasoningOutputTokens).toBe(50); // gpt-5: 800 non-cached input @ 1.25, 200 cached @ 0.125, 500 output @ 10 // gpt-5-mini: 300 non-cached input @ 0.6, 100 cached @ 0.06, 200 output @ 2 (reasoning already included) const expectedCost = (800 / 1_000_000) * 1.25 + (200 / 1_000_000) * 0.125 + (500 / 1_000_000) * 10 + (300 / 1_000_000) * 0.6 + (100 / 1_000_000) * 0.06 + (200 / 1_000_000) * 2; expect(first.costUSD).toBeCloseTo(expectedCost, 10); }); }); } ================================================ FILE: apps/codex/src/pricing.ts ================================================ import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; import type { ModelPricing, PricingSource } from './_types.ts'; import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import { Result } from '@praha/byethrow'; import { MILLION } from './_consts.ts'; import { prefetchCodexPricing } from './_macro.ts' with { type: 'macro' }; import { logger } from './logger.ts'; const CODEX_PROVIDER_PREFIXES = ['openai/', 'azure/', 'openrouter/openai/']; const CODEX_MODEL_ALIASES_MAP = new Map([ ['gpt-5-codex', 'gpt-5'], ['gpt-5.3-codex', 'gpt-5.2-codex'], ]); const FREE_MODEL_PRICING = { inputCostPerMToken: 0, cachedInputCostPerMToken: 0, outputCostPerMToken: 0, } as const satisfies ModelPricing; function isOpenRouterFreeModel(model: string): boolean { const normalized = model.trim().toLowerCase(); if (normalized === 'openrouter/free') { return true; } return normalized.startsWith('openrouter/') && normalized.endsWith(':free'); } function hasNonZeroTokenPricing(pricing: LiteLLMModelPricing): boolean { return ( (pricing.input_cost_per_token ?? 0) > 0 || (pricing.output_cost_per_token ?? 0) > 0 || (pricing.cache_read_input_token_cost ?? 0) > 0 ); } function toPerMillion(value: number | undefined, fallback?: number): number { const perToken = value ?? fallback ?? 0; return perToken * MILLION; } export type CodexPricingSourceOptions = { offline?: boolean; offlineLoader?: () => Promise>; }; const PREFETCHED_CODEX_PRICING = prefetchCodexPricing(); export class CodexPricingSource implements PricingSource, Disposable { private readonly fetcher: LiteLLMPricingFetcher; constructor(options: CodexPricingSourceOptions = {}) { this.fetcher = new LiteLLMPricingFetcher({ offline: options.offline ?? false, offlineLoader: options.offlineLoader ?? (async () => PREFETCHED_CODEX_PRICING), logger, providerPrefixes: CODEX_PROVIDER_PREFIXES, }); } [Symbol.dispose](): void { this.fetcher[Symbol.dispose](); } async getPricing(model: string): Promise { if (isOpenRouterFreeModel(model)) { return FREE_MODEL_PRICING; } const directLookup = await this.fetcher.getModelPricing(model); if (Result.isFailure(directLookup)) { throw directLookup.error; } let pricing = directLookup.value; const alias = CODEX_MODEL_ALIASES_MAP.get(model); if (alias != null && (pricing == null || !hasNonZeroTokenPricing(pricing))) { const aliasLookup = await this.fetcher.getModelPricing(alias); if (Result.isFailure(aliasLookup)) { throw aliasLookup.error; } if (aliasLookup.value != null && hasNonZeroTokenPricing(aliasLookup.value)) { pricing = aliasLookup.value; } } if (pricing == null) { logger.warn(`Pricing not found for model ${model}; defaulting to zero-cost pricing.`); return FREE_MODEL_PRICING; } return { inputCostPerMToken: toPerMillion(pricing.input_cost_per_token), cachedInputCostPerMToken: toPerMillion( pricing.cache_read_input_token_cost, pricing.input_cost_per_token, ), outputCostPerMToken: toPerMillion(pricing.output_cost_per_token), }; } } if (import.meta.vitest != null) { describe('CodexPricingSource', () => { it('converts LiteLLM pricing to per-million costs', async () => { using source = new CodexPricingSource({ offline: true, offlineLoader: async () => ({ 'gpt-5': { input_cost_per_token: 1.25e-6, output_cost_per_token: 1e-5, cache_read_input_token_cost: 1.25e-7, }, }), }); const pricing = await source.getPricing('gpt-5-codex'); expect(pricing.inputCostPerMToken).toBeCloseTo(1.25); expect(pricing.outputCostPerMToken).toBeCloseTo(10); expect(pricing.cachedInputCostPerMToken).toBeCloseTo(0.125); }); it('returns zero pricing for OpenRouter free routes', async () => { using source = new CodexPricingSource({ offline: true, offlineLoader: async () => ({}), }); const directFree = await source.getPricing('openrouter/free'); expect(directFree).toEqual(FREE_MODEL_PRICING); const modelFree = await source.getPricing('openrouter/openai/gpt-5:free'); expect(modelFree).toEqual(FREE_MODEL_PRICING); }); it('falls back to zero pricing for unknown non-free models', async () => { using source = new CodexPricingSource({ offline: true, offlineLoader: async () => ({}), }); const pricing = await source.getPricing('openrouter/unknown'); expect(pricing).toEqual(FREE_MODEL_PRICING); }); it('falls back to alias pricing when direct model pricing is all zeros', async () => { using source = new CodexPricingSource({ offline: true, offlineLoader: async () => ({ 'gpt-5.3-codex': { input_cost_per_token: 0, output_cost_per_token: 0, cache_read_input_token_cost: 0, }, 'gpt-5.2-codex': { input_cost_per_token: 1.75e-6, output_cost_per_token: 1.4e-5, cache_read_input_token_cost: 1.75e-7, }, }), }); const pricing = await source.getPricing('gpt-5.3-codex'); expect(pricing.inputCostPerMToken).toBeCloseTo(1.75); expect(pricing.outputCostPerMToken).toBeCloseTo(14); expect(pricing.cachedInputCostPerMToken).toBeCloseTo(0.175); }); it('prefers direct pricing when non-zero pricing is available', async () => { using source = new CodexPricingSource({ offline: true, offlineLoader: async () => ({ 'gpt-5.3-codex': { input_cost_per_token: 1.9e-6, output_cost_per_token: 1.5e-5, cache_read_input_token_cost: 1.9e-7, }, 'gpt-5.2-codex': { input_cost_per_token: 1.75e-6, output_cost_per_token: 1.4e-5, cache_read_input_token_cost: 1.75e-7, }, }), }); const pricing = await source.getPricing('gpt-5.3-codex'); expect(pricing.inputCostPerMToken).toBeCloseTo(1.9); expect(pricing.outputCostPerMToken).toBeCloseTo(15); expect(pricing.cachedInputCostPerMToken).toBeCloseTo(0.19); }); }); } ================================================ FILE: apps/codex/src/run.ts ================================================ import process from 'node:process'; import { cli } from 'gunshi'; import { description, name, version } from '../package.json'; import { dailyCommand } from './commands/daily.ts'; import { monthlyCommand } from './commands/monthly.ts'; import { sessionCommand } from './commands/session.ts'; const subCommands = new Map([ ['daily', dailyCommand], ['monthly', monthlyCommand], ['session', sessionCommand], ]); const mainCommand = dailyCommand; export async function run(): Promise { // When invoked through npx, the binary name might be passed as the first argument // Filter it out if it matches the expected binary name let args = process.argv.slice(2); if (args[0] === 'ccusage-codex') { args = args.slice(1); } await cli(args, mainCommand, { name, version, description, subCommands, renderHeader: null, }); } ================================================ FILE: apps/codex/src/session-report.ts ================================================ import type { ModelPricing, ModelUsage, PricingSource, SessionReportRow, SessionUsageSummary, TokenUsageEvent, } from './_types.ts'; import { isWithinRange, toDateKey } from './date-utils.ts'; import { addUsage, calculateCostUSD, createEmptyUsage } from './token-utils.ts'; export type SessionReportOptions = { timezone?: string; locale?: string; since?: string; until?: string; pricingSource: PricingSource; }; function createSummary(sessionId: string, initialTimestamp: string): SessionUsageSummary { return { sessionId, firstTimestamp: initialTimestamp, lastTimestamp: initialTimestamp, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, costUSD: 0, models: new Map(), }; } export async function buildSessionReport( events: TokenUsageEvent[], options: SessionReportOptions, ): Promise { const timezone = options.timezone; const since = options.since; const until = options.until; const pricingSource = options.pricingSource; const summaries = new Map(); for (const event of events) { const rawSessionId = event.sessionId; if (rawSessionId == null) { continue; } const sessionId = rawSessionId.trim(); if (sessionId === '') { continue; } const rawModelName = event.model; if (rawModelName == null) { continue; } const modelName = rawModelName.trim(); if (modelName === '') { continue; } const dateKey = toDateKey(event.timestamp, timezone); if (!isWithinRange(dateKey, since, until)) { continue; } const summary = summaries.get(sessionId) ?? createSummary(sessionId, event.timestamp); if (!summaries.has(sessionId)) { summaries.set(sessionId, summary); } addUsage(summary, event); if (event.timestamp > summary.lastTimestamp) { summary.lastTimestamp = event.timestamp; } const modelUsage: ModelUsage = summary.models.get(modelName) ?? { ...createEmptyUsage(), isFallback: false, }; if (!summary.models.has(modelName)) { summary.models.set(modelName, modelUsage); } addUsage(modelUsage, event); if (event.isFallbackModel === true) { modelUsage.isFallback = true; } } if (summaries.size === 0) { return []; } const uniqueModels = new Set(); for (const summary of summaries.values()) { for (const modelName of summary.models.keys()) { uniqueModels.add(modelName); } } const modelPricing = new Map>>(); for (const modelName of uniqueModels) { modelPricing.set(modelName, await pricingSource.getPricing(modelName)); } const sortedSummaries = Array.from(summaries.values()).sort((a, b) => a.lastTimestamp.localeCompare(b.lastTimestamp), ); const rows: SessionReportRow[] = []; for (const summary of sortedSummaries) { let cost = 0; for (const [modelName, usage] of summary.models) { const pricing = modelPricing.get(modelName); if (pricing == null) { continue; } cost += calculateCostUSD(usage, pricing); } summary.costUSD = cost; const rowModels: Record = {}; for (const [modelName, usage] of summary.models) { rowModels[modelName] = { ...usage }; } const separatorIndex = summary.sessionId.lastIndexOf('/'); const directory = separatorIndex >= 0 ? summary.sessionId.slice(0, separatorIndex) : ''; const sessionFile = separatorIndex >= 0 ? summary.sessionId.slice(separatorIndex + 1) : summary.sessionId; rows.push({ sessionId: summary.sessionId, lastActivity: summary.lastTimestamp, sessionFile, directory, inputTokens: summary.inputTokens, cachedInputTokens: summary.cachedInputTokens, outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, costUSD: cost, models: rowModels, }); } return rows; } if (import.meta.vitest != null) { describe('buildSessionReport', () => { it('groups events by session and calculates costs', async () => { const pricing = new Map([ [ 'gpt-5', { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 }, ], [ 'gpt-5-mini', { inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 }, ], ]); const stubPricingSource: PricingSource = { async getPricing(model: string): Promise { const value = pricing.get(model); if (value == null) { throw new Error(`Missing pricing for ${model}`); } return value; }, }; const report = await buildSessionReport( [ { sessionId: 'session-a', timestamp: '2025-09-12T01:00:00.000Z', model: 'gpt-5', inputTokens: 1_000, cachedInputTokens: 100, outputTokens: 500, reasoningOutputTokens: 0, totalTokens: 1_500, }, { sessionId: 'session-a', timestamp: '2025-09-12T02:00:00.000Z', model: 'gpt-5-mini', inputTokens: 400, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 30, totalTokens: 630, }, { sessionId: 'session-b', timestamp: '2025-09-11T23:30:00.000Z', model: 'gpt-5', inputTokens: 800, cachedInputTokens: 0, outputTokens: 300, reasoningOutputTokens: 0, totalTokens: 1_100, }, ], { pricingSource: stubPricingSource, }, ); expect(report).toHaveLength(2); const first = report[0]!; expect(first.sessionId).toBe('session-b'); expect(first.sessionFile).toBe('session-b'); expect(first.directory).toBe(''); expect(first.totalTokens).toBe(1_100); const second = report[1]!; expect(second.sessionId).toBe('session-a'); expect(second.sessionFile).toBe('session-a'); expect(second.directory).toBe(''); expect(second.totalTokens).toBe(2_130); expect(second.models['gpt-5']?.totalTokens).toBe(1_500); const expectedCost = (900 / 1_000_000) * 1.25 + (100 / 1_000_000) * 0.125 + (500 / 1_000_000) * 10 + (300 / 1_000_000) * 0.6 + (100 / 1_000_000) * 0.06 + (200 / 1_000_000) * 2; expect(second.costUSD).toBeCloseTo(expectedCost, 10); }); }); } ================================================ FILE: apps/codex/src/token-utils.ts ================================================ import type { ModelPricing, TokenUsageDelta } from './_types.ts'; import { formatCurrency, formatTokens } from '@ccusage/internal/format'; import { MILLION } from './_consts.ts'; export function createEmptyUsage(): TokenUsageDelta { return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, }; } export function addUsage(target: TokenUsageDelta, delta: TokenUsageDelta): void { target.inputTokens += delta.inputTokens; target.cachedInputTokens += delta.cachedInputTokens; target.outputTokens += delta.outputTokens; target.reasoningOutputTokens += delta.reasoningOutputTokens; target.totalTokens += delta.totalTokens; } function nonCachedInputTokens(usage: TokenUsageDelta): number { const nonCached = usage.inputTokens - usage.cachedInputTokens; return nonCached > 0 ? nonCached : 0; } /** * Calculate the cost in USD for token usage based on model pricing * * @param usage - Token usage data including input, output, cached, and reasoning tokens * @param pricing - Model-specific pricing rates per million tokens * @returns Cost in USD * * @remarks * - Cached input tokens receive a 50% discount from OpenAI * @see {@link https://platform.openai.com/docs/guides/prompt-caching} * * - Reasoning tokens are already included in output_tokens, so they are not added separately * to avoid double-counting */ export function calculateCostUSD(usage: TokenUsageDelta, pricing: ModelPricing): number { const nonCachedInput = nonCachedInputTokens(usage); const cachedInput = usage.cachedInputTokens > usage.inputTokens ? usage.inputTokens : usage.cachedInputTokens; const outputTokens = usage.outputTokens; const inputCost = (nonCachedInput / MILLION) * pricing.inputCostPerMToken; const cachedCost = (cachedInput / MILLION) * pricing.cachedInputCostPerMToken; const outputCost = (outputTokens / MILLION) * pricing.outputCostPerMToken; return inputCost + cachedCost + outputCost; } export { formatCurrency, formatTokens }; ================================================ FILE: apps/codex/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "lib": ["ESNext"], "moduleDetection": "force", "module": "Preserve", "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["vitest/globals", "vitest/importMeta"], "allowImportingTsExtensions": true, "allowJs": false, "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, "noUnusedLocals": false, "noUnusedParameters": false, "noEmit": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true }, "exclude": ["dist"] } ================================================ FILE: apps/codex/tsdown.config.ts ================================================ import { defineConfig } from 'tsdown'; import Macros from 'unplugin-macros/rolldown'; export default defineConfig({ entry: ['src/index.ts'], outDir: 'dist', format: 'esm', clean: true, sourcemap: false, minify: 'dce-only', treeshake: true, dts: false, publint: true, unused: true, fixedExtension: false, nodeProtocol: true, plugins: [ Macros({ include: ['src/index.ts', 'src/pricing.ts'], }), ], define: { 'import.meta.vitest': 'undefined', }, }); ================================================ FILE: apps/codex/vitest.config.ts ================================================ import Macros from 'unplugin-macros/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { watch: false, includeSource: ['src/**/*.{js,ts}'], globals: true, }, plugins: [ Macros({ include: ['src/index.ts', 'src/pricing.ts'], }) as any, ], }); ================================================ FILE: apps/mcp/CLAUDE.md ================================================ # CLAUDE.md - MCP Package This package provides the MCP (Model Context Protocol) server implementation for ccusage data. ## Package Overview **Name**: `@ccusage/mcp` **Description**: MCP server implementation for ccusage data **Type**: MCP server with CLI and library exports ## Development Commands **Testing and Quality:** - `pnpm run test` - Run all tests using vitest - `pnpm run lint` - Lint code using ESLint - `pnpm run format` - Format and auto-fix code with ESLint - `pnpm typecheck` - Type check with TypeScript **Build and Release:** - `pnpm run build` - Build distribution files with tsdown - `pnpm run prerelease` - Full release workflow (lint + typecheck + build) ## Usage **As MCP Server:** ```bash # Install and run as MCP server pnpm dlx @ccusage/mcp@latest -- --help pnpm dlx @ccusage/mcp@latest -- --type http --port 8080 ``` **Direct Usage:** ```bash # Run the CLI directly ccusage-mcp --help ``` ## Architecture This package implements an MCP server that exposes ccusage functionality through the Model Context Protocol: **Key Modules:** - `src/index.ts` - Main MCP server implementation - `src/cli.ts` - CLI entry point for the MCP server - `src/command.ts` - Command handling and routing **MCP Tools Provided:** - `daily` - Daily usage reports - `session` - Session-based usage reports - `monthly` - Monthly usage reports - `blocks` - 5-hour billing blocks usage reports **Transport Support:** - HTTP transport for web-based integration - Configurable port and host settings ## Dependencies **Key Runtime Dependencies:** - `@hono/mcp` - MCP implementation for Hono - `@hono/node-server` - Node.js server adapter for Hono - `@modelcontextprotocol/sdk` - Official MCP SDK - `ccusage` - Main ccusage package (workspace dependency) - `gunshi` - CLI framework - `hono` - Web framework - `zod` - Schema validation **Key Dev Dependencies:** - `vitest` - Testing framework - `tsdown` - TypeScript build tool - `eslint` - Linting and formatting - `fs-fixture` - Test fixture creation ## Integration with Claude Desktop This MCP server can be integrated with Claude Desktop to provide usage analysis directly within Claude conversations. Configure it in your Claude Desktop MCP settings to access ccusage data through the MCP protocol. ## Testing - **In-Source Testing**: Uses the same testing pattern as the main package - **Vitest Globals Enabled**: Use `describe`, `it`, `expect` directly without imports - **Mock Data**: Uses `fs-fixture` for testing MCP server functionality - **CRITICAL**: NEVER use `await import()` dynamic imports anywhere ## Code Style Follow the same code style guidelines as the main ccusage package: - **Error Handling**: Prefer `@praha/byethrow Result` type over try-catch - **Imports**: Use `.ts` extensions for local imports - **Exports**: Only export what's actually used - **Dependencies**: Add as `devDependencies` unless explicitly requested **Post-Change Workflow:** Always run these commands in parallel after code changes: - `pnpm run format` - Auto-fix and format - `pnpm typecheck` - Type checking - `pnpm run test` - Run tests ## Package Exports The package provides multiple exports: - `.` - Main MCP server - `./cli` - CLI entry point - `./command` - Command handling utilities ## Binary The package includes a binary `ccusage-mcp` that can be used to start the MCP server from the command line. ================================================ FILE: apps/mcp/README.md ================================================
ccusage logo

@ccusage/mcp

Socket Badge npm version NPM Downloads install size DeepWiki

Claude Desktop MCP integration screenshot
> MCP (Model Context Protocol) server implementation for ccusage - provides Claude Code usage data through the MCP protocol. ## Quick Start ```bash # Using bunx (recommended for speed) bunx @ccusage/mcp@latest # Using npx npx @ccusage/mcp@latest # Start with HTTP transport bunx @ccusage/mcp@latest -- --type http --port 8080 ``` ## Integrations ### Claude Desktop Integration Add to your Claude Desktop MCP configuration: ```json { "mcpServers": { "ccusage": { "command": "npx", "args": ["@ccusage/mcp@latest"], "type": "stdio" } } } ``` ### Claude Code ```sh claude mcp add ccusage npx -- @ccusage/mcp@latest ``` ## Documentation For full documentation, visit **[ccusage.com/guide/mcp-server](https://ccusage.com/guide/mcp-server)** ## Sponsors ### Featured Sponsor Check out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)

ccusage: The Claude Code cost scorecard that went viral

## License MIT © [@ryoppippi](https://github.com/ryoppippi) ================================================ FILE: apps/mcp/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; /** @type {import('eslint').Linter.FlatConfig[]} */ const config = ryoppippi( { type: 'app', stylistic: false, }, { rules: { 'test/no-importing-vitest-globals': 'error', }, }, ); export default config; ================================================ FILE: apps/mcp/package.json ================================================ { "name": "@ccusage/mcp", "type": "module", "version": "18.0.10", "description": "MCP server implementation for ccusage data", "author": "ryoppippi", "license": "MIT", "funding": "https://github.com/ryoppippi/ccusage?sponsor=1", "homepage": "https://github.com/ryoppippi/ccusage#readme", "repository": { "type": "git", "url": "git+https://github.com/ryoppippi/ccusage.git", "directory": "apps/mcp" }, "bugs": { "url": "https://github.com/ryoppippi/ccusage/issues" }, "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { "ccusage-mcp": "./src/index.ts" }, "files": [ "README.md", "dist" ], "publishConfig": { "bin": { "ccusage-mcp": "./dist/index.js" }, "exports": { ".": "./dist/index.js", "./package.json": "./package.json" } }, "engines": { "node": ">=20.19.4" }, "scripts": { "build": "tsdown", "dev": "bun -b --watch ./src/index.ts", "format": "pnpm run lint --fix", "lint": "eslint --cache .", "prepack": "pnpm run build && clean-pkg-json", "prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build", "start": "bun ./src/index.ts", "test": "TZ=UTC vitest", "typecheck": "tsgo --noEmit" }, "dependencies": { "@ccusage/codex": "workspace:*", "@hono/mcp": "catalog:runtime", "@hono/node-server": "catalog:runtime", "@modelcontextprotocol/sdk": "catalog:runtime", "ccusage": "workspace:*", "gunshi": "catalog:runtime", "hono": "catalog:runtime", "nano-spawn": "catalog:runtime", "zod": "catalog:runtime" }, "devDependencies": { "@ccusage/internal": "workspace:*", "@ryoppippi/eslint-config": "catalog:lint", "@typescript/native-preview": "catalog:types", "clean-pkg-json": "catalog:release", "eslint": "catalog:lint", "fs-fixture": "catalog:testing", "publint": "catalog:lint", "tsdown": "catalog:build", "vitest": "catalog:testing" } } ================================================ FILE: apps/mcp/src/ccusage.ts ================================================ import type { CliInvocation } from './cli-utils.ts'; import { z } from 'zod'; import { createCliInvocation, executeCliCommand, resolveBinaryPath } from './cli-utils.ts'; import { DATE_FILTER_REGEX } from './consts.ts'; export const filterDateSchema = z .string() .regex(DATE_FILTER_REGEX, 'Date must be in YYYYMMDD format'); export const ccusageParametersShape = { since: filterDateSchema.optional(), until: filterDateSchema.optional(), mode: z.enum(['auto', 'calculate', 'display']).default('auto').optional(), timezone: z.string().optional(), locale: z.string().optional(), } as const satisfies Record; export const ccusageParametersSchema = z.object(ccusageParametersShape); let cachedCcusageInvocation: CliInvocation | null = null; function getCcusageInvocation(): CliInvocation { if (cachedCcusageInvocation != null) { return cachedCcusageInvocation; } const entryPath = resolveBinaryPath('ccusage', 'ccusage'); cachedCcusageInvocation = createCliInvocation(entryPath); return cachedCcusageInvocation; } async function runCcusageCliJson( command: 'daily' | 'monthly' | 'session' | 'blocks', parameters: z.infer, claudePath: string, ): Promise { const { executable, prefixArgs } = getCcusageInvocation(); const cliArgs: string[] = [...prefixArgs, command, '--json']; const since = parameters.since; if (since != null && since !== '') { cliArgs.push('--since', since); } const until = parameters.until; if (until != null && until !== '') { cliArgs.push('--until', until); } const mode = parameters.mode; if (mode != null && mode !== 'auto') { cliArgs.push('--mode', mode); } const timezone = parameters.timezone; if (timezone != null && timezone !== '') { cliArgs.push('--timezone', timezone); } const locale = parameters.locale; if (locale != null && locale !== '') { cliArgs.push('--locale', locale); } return executeCliCommand(executable, cliArgs, { // Set Claude path for ccusage CLAUDE_CONFIG_DIR: claudePath, }); } export async function getCcusageDaily( parameters: z.infer, claudePath: string, ): Promise { try { const raw = await runCcusageCliJson('daily', parameters, claudePath); const parsed = JSON.parse(raw) as unknown; // If the parsed result is an empty array, convert to expected structure if (Array.isArray(parsed) && parsed.length === 0) { return { daily: [], totals: {} }; } return parsed; } catch { // Return empty result on error return { daily: [], totals: {} }; } } export async function getCcusageMonthly( parameters: z.infer, claudePath: string, ): Promise { try { const raw = await runCcusageCliJson('monthly', parameters, claudePath); const parsed = JSON.parse(raw) as unknown; // If the parsed result is an empty array, convert to expected structure if (Array.isArray(parsed) && parsed.length === 0) { return { monthly: [], totals: {} }; } return parsed; } catch { // Return empty result on error return { monthly: [], totals: {} }; } } export async function getCcusageSession( parameters: z.infer, claudePath: string, ): Promise { try { const raw = await runCcusageCliJson('session', parameters, claudePath); const parsed = JSON.parse(raw) as unknown; // If the parsed result is an empty array, convert to expected structure if (Array.isArray(parsed) && parsed.length === 0) { return { sessions: [], totals: {} }; } return parsed; } catch { // Return empty result on error return { sessions: [], totals: {} }; } } export async function getCcusageBlocks( parameters: z.infer, claudePath: string, ): Promise { try { const raw = await runCcusageCliJson('blocks', parameters, claudePath); const parsed = JSON.parse(raw) as unknown; // If the parsed result is an empty array, convert to expected structure if (Array.isArray(parsed) && parsed.length === 0) { return { blocks: [] }; } return parsed; } catch { // Return empty result on error return { blocks: [] }; } } ================================================ FILE: apps/mcp/src/cli-utils.ts ================================================ import { createRequire } from 'node:module'; import path from 'node:path'; import process from 'node:process'; import spawn, { SubprocessError } from 'nano-spawn'; const nodeRequire = createRequire(import.meta.url); export type BinField = string | Record | undefined; export type CliInvocation = { executable: string; prefixArgs: string[]; }; /** * Resolves the binary path for a package */ export function resolveBinaryPath(packageName: string, binName?: string): string { let packageJsonPath: string; try { packageJsonPath = nodeRequire.resolve(`${packageName}/package.json`); } catch (error) { throw new Error( `Unable to resolve ${packageName}. Install the package alongside @ccusage/mcp to enable ${packageName} tools.`, { cause: error }, ); } const packageJson = nodeRequire(packageJsonPath) as { bin?: BinField; publishConfig?: { bin?: BinField }; }; const binField: BinField = packageJson.bin ?? packageJson.publishConfig?.bin; let binRelative: string | undefined; if (typeof binField === 'string') { binRelative = binField; } else if (binField != null && typeof binField === 'object') { binRelative = binName != null && binName !== '' ? binField[binName] : Object.values(binField)[0]; } if (binRelative == null) { throw new Error( `Unable to locate ${binName ?? packageName} binary entry in ${packageName}/package.json`, ); } const packageDir = path.dirname(packageJsonPath); return path.resolve(packageDir, binRelative); } /** * Creates invocation config for CLI execution */ export function createCliInvocation(entryPath: string): CliInvocation { // Use bun for TypeScript files in development if (entryPath.endsWith('.ts')) { return { executable: 'bun', prefixArgs: [entryPath], }; } // Use node for built JavaScript files in production return { executable: process.execPath, prefixArgs: [entryPath], }; } /** * Executes a CLI command and returns the output */ export async function executeCliCommand( executable: string, args: string[], env?: Record, ): Promise { try { const result = await spawn(executable, args, { env: { ...process.env, // Suppress color output FORCE_COLOR: '0', // nano-spawn captures stdout, so it won't leak to terminal ...env, }, }); const output = (result.stdout ?? result.output ?? '').trim(); if (output === '') { throw new Error('CLI command returned empty output'); } return output; } catch (error: unknown) { if (error instanceof SubprocessError) { const message = (error.stderr ?? error.stdout ?? error.output ?? error.message).trim(); throw new Error(message); } throw error; } } ================================================ FILE: apps/mcp/src/codex.ts ================================================ import type { CliInvocation } from './cli-utils.ts'; import { z } from 'zod'; import { createCliInvocation, executeCliCommand, resolveBinaryPath } from './cli-utils.ts'; const codexModelUsageSchema = z.object({ inputTokens: z.number(), cachedInputTokens: z.number(), outputTokens: z.number(), reasoningOutputTokens: z.number(), totalTokens: z.number(), isFallback: z.boolean().optional(), }); const codexTotalsSchema = z.object({ inputTokens: z.number(), cachedInputTokens: z.number(), outputTokens: z.number(), reasoningOutputTokens: z.number(), totalTokens: z.number(), costUSD: z.number(), }); const codexDailyRowSchema = z.object({ date: z.string(), inputTokens: z.number(), cachedInputTokens: z.number(), outputTokens: z.number(), reasoningOutputTokens: z.number(), totalTokens: z.number(), costUSD: z.number(), models: z.record(z.string(), codexModelUsageSchema), }); const codexMonthlyRowSchema = z.object({ month: z.string(), inputTokens: z.number(), cachedInputTokens: z.number(), outputTokens: z.number(), reasoningOutputTokens: z.number(), totalTokens: z.number(), costUSD: z.number(), models: z.record(z.string(), codexModelUsageSchema), }); // Response schemas for internal parsing only - not exported const codexDailyResponseSchema = z.object({ daily: z.array(codexDailyRowSchema), totals: codexTotalsSchema.nullable(), }); const codexMonthlyResponseSchema = z.object({ monthly: z.array(codexMonthlyRowSchema), totals: codexTotalsSchema.nullable(), }); export const codexParametersShape = { since: z.string().optional(), until: z.string().optional(), timezone: z.string().optional(), locale: z.string().optional(), offline: z.boolean().optional(), } as const satisfies Record; export const codexParametersSchema = z.object(codexParametersShape); let cachedCodexInvocation: CliInvocation | null = null; function getCodexInvocation(): CliInvocation { if (cachedCodexInvocation != null) { return cachedCodexInvocation; } const entryPath = resolveBinaryPath('@ccusage/codex', 'ccusage-codex'); cachedCodexInvocation = createCliInvocation(entryPath); return cachedCodexInvocation; } async function runCodexCliJson( command: 'daily' | 'monthly', parameters: z.infer, ): Promise { const { executable, prefixArgs } = getCodexInvocation(); const cliArgs: string[] = [...prefixArgs, command, '--json']; const since = parameters.since; if (since != null && since !== '') { cliArgs.push('--since', since); } const until = parameters.until; if (until != null && until !== '') { cliArgs.push('--until', until); } const timezone = parameters.timezone; if (timezone != null && timezone !== '') { cliArgs.push('--timezone', timezone); } const locale = parameters.locale; if (locale != null && locale !== '') { cliArgs.push('--locale', locale); } if (parameters.offline === true) { cliArgs.push('--offline'); } else if (parameters.offline === false) { cliArgs.push('--no-offline'); } return executeCliCommand(executable, cliArgs, { // Keep default log level to allow JSON output }); } export async function getCodexDaily(parameters: z.infer) { const raw = await runCodexCliJson('daily', parameters); return codexDailyResponseSchema.parse(JSON.parse(raw)); } export async function getCodexMonthly(parameters: z.infer) { const raw = await runCodexCliJson('monthly', parameters); return codexMonthlyResponseSchema.parse(JSON.parse(raw)); } ================================================ FILE: apps/mcp/src/command.ts ================================================ import type { LoadOptions } from 'ccusage/data-loader'; import process from 'node:process'; import { serve } from '@hono/node-server'; import { getClaudePaths } from 'ccusage/data-loader'; import { logger } from 'ccusage/logger'; import { cli, define } from 'gunshi'; import { description, name, version } from '../package.json'; import { createMcpHttpApp, createMcpServer, startMcpServerStdio } from './mcp.ts'; type McpType = (typeof MCP_TYPE_CHOICES)[number]; type Mode = LoadOptions['mode']; const MCP_DEFAULT_PORT = 8080; const MODE_CHOICES = ['auto', 'calculate', 'display'] as const satisfies readonly Mode[]; const MCP_TYPE_CHOICES = ['stdio', 'http'] as const satisfies readonly string[]; type CommandOptions = LoadOptions & { port?: number; type?: McpType; }; export const mcpCommand = define({ name: 'mcp', description: 'Start MCP server with usage reporting tools', args: { mode: { type: 'enum', short: 'm', description: 'Cost calculation mode for usage reports', choices: MODE_CHOICES, default: 'auto' satisfies Mode, }, type: { type: 'enum', short: 't', description: 'Transport type for MCP server', choices: MCP_TYPE_CHOICES, default: 'stdio' satisfies McpType, }, port: { type: 'number', short: 'p', description: `Port for HTTP transport (default: ${MCP_DEFAULT_PORT})`, default: MCP_DEFAULT_PORT, }, }, async run(ctx) { const { type: mcpType, mode, port } = ctx.values; if (mcpType === 'stdio') { logger.level = 0; } const paths = getClaudePaths(); if (paths.length === 0) { logger.error('No valid Claude data directory found'); throw new Error('No valid Claude data directory found'); } const options: CommandOptions = { claudePath: paths.at(0), mode, }; switch (mcpType) { case 'stdio': { const server = createMcpServer(options); await startMcpServerStdio(server); return; } case 'http': { const app = createMcpHttpApp(options); serve({ fetch: app.fetch, port, }); logger.info(`MCP server is running on http://localhost:${port}`); return; } default: { mcpType satisfies never; throw new Error(`Unsupported MCP type: ${mcpType as string}`); } } }, }); export async function run(argv: string[] = process.argv.slice(2)): Promise { // When invoked through npx/bunx, the binary name might be passed as the first argument // Filter it out if it matches the expected binary name let args = argv; if (args[0] === 'ccusage-mcp') { args = args.slice(1); } await cli(args, mcpCommand, { name, version, description, subCommands: new Map(), }); } ================================================ FILE: apps/mcp/src/consts.ts ================================================ export const DEFAULT_LOCALE = 'en-CA'; export const DATE_FILTER_REGEX = /^\d{8}$/; ================================================ FILE: apps/mcp/src/index.ts ================================================ #!/usr/bin/env node import { run } from './command.ts'; // eslint-disable-next-line antfu/no-top-level-await await run(); ================================================ FILE: apps/mcp/src/mcp-utils.ts ================================================ import type { LoadOptions } from 'ccusage/data-loader'; import { getClaudePaths } from 'ccusage/data-loader'; export function defaultOptions(): LoadOptions { const paths = getClaudePaths(); if (paths.length === 0) { throw new Error( 'No valid Claude path found. Ensure getClaudePaths() returns at least one valid path.', ); } return { claudePath: paths[0] } as const satisfies LoadOptions; } ================================================ FILE: apps/mcp/src/mcp.ts ================================================ /** * @fileoverview MCP (Model Context Protocol) server implementation * * This module provides MCP server functionality for exposing ccusage data * through the Model Context Protocol. It includes both stdio and HTTP transport * options for integration with various MCP clients. * * @module mcp */ import type { LoadOptions } from 'ccusage/data-loader'; import { StreamableHTTPTransport } from '@hono/mcp'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createFixture } from 'fs-fixture'; import { Hono } from 'hono/tiny'; import { name, version } from '../package.json'; import { ccusageParametersSchema, ccusageParametersShape, getCcusageBlocks, getCcusageDaily, getCcusageMonthly, getCcusageSession, } from './ccusage.ts'; import { codexParametersSchema, codexParametersShape, getCodexDaily, getCodexMonthly, } from './codex.ts'; import { defaultOptions } from './mcp-utils.ts'; /** * Creates an MCP server with tools for showing usage reports. * Registers tools for daily, session, monthly, and blocks usage data. * * @param options - Configuration options for the MCP server * @param options.claudePath - Path to Claude's data directory * @returns Configured MCP server instance with registered tools */ export function createMcpServer(options?: LoadOptions): McpServer { const server = new McpServer({ name, version, }); const { claudePath = '' } = options ?? defaultOptions(); if (claudePath === '') { throw new Error('Claude path is required'); } // Register daily tool server.registerTool( 'daily', { description: 'Show usage report grouped by date', inputSchema: ccusageParametersShape, }, async (args) => { const parameters = ccusageParametersSchema.parse(args); const jsonOutput = await getCcusageDaily(parameters, claudePath); return { content: [ { type: 'text', text: JSON.stringify(jsonOutput, null, 2), }, ], }; }, ); // Register session tool server.registerTool( 'session', { description: 'Show usage report grouped by conversation session', inputSchema: ccusageParametersShape, }, async (args) => { const parameters = ccusageParametersSchema.parse(args); const jsonOutput = await getCcusageSession(parameters, claudePath); return { content: [ { type: 'text', text: JSON.stringify(jsonOutput, null, 2), }, ], }; }, ); // Register monthly tool server.registerTool( 'monthly', { description: 'Show usage report grouped by month', inputSchema: ccusageParametersShape, }, async (args) => { const parameters = ccusageParametersSchema.parse(args); const jsonOutput = await getCcusageMonthly(parameters, claudePath); return { content: [ { type: 'text', text: JSON.stringify(jsonOutput, null, 2), }, ], }; }, ); // Register blocks tool server.registerTool( 'blocks', { description: 'Show usage report grouped by session billing blocks', inputSchema: ccusageParametersShape, }, async (args) => { const parameters = ccusageParametersSchema.parse(args); const jsonOutput = await getCcusageBlocks(parameters, claudePath); return { content: [ { type: 'text', text: JSON.stringify(jsonOutput, null, 2), }, ], }; }, ); // Register Codex daily tool server.registerTool( 'codex-daily', { description: 'Show Codex usage grouped by day', inputSchema: codexParametersShape, }, async (args) => { const parameters = codexParametersSchema.parse(args); const codexDaily = await getCodexDaily(parameters); return { content: [ { type: 'text', text: JSON.stringify(codexDaily, null, 2), }, ], }; }, ); // Register Codex monthly tool server.registerTool( 'codex-monthly', { description: 'Show Codex usage grouped by month', inputSchema: codexParametersShape, }, async (args) => { const parameters = codexParametersSchema.parse(args); const codexMonthly = await getCodexMonthly(parameters); return { content: [ { type: 'text', text: JSON.stringify(codexMonthly, null, 2), }, ], }; }, ); return server; } /** * Start the MCP server with stdio transport. * Used for traditional MCP client connections via standard input/output. * * @param server - The MCP server instance to start */ export async function startMcpServerStdio(server: McpServer): Promise { const transport = new StdioServerTransport(); await server.connect(transport); } /** * Create Hono app for MCP HTTP server. * Provides HTTP transport support for MCP protocol using Hono framework. * Handles POST requests for MCP communication and returns appropriate errors for other methods. * * @param options - Configuration options for the MCP server * @param options.claudePath - Path to Claude's data directory * @returns Configured Hono application for HTTP MCP transport */ export function createMcpHttpApp(options?: LoadOptions): Hono { const app = new Hono(); const mcpServer = createMcpServer(options ?? defaultOptions()); app.all('/', async (c) => { const transport = new StreamableHTTPTransport(); await mcpServer.connect(transport); return transport.handleRequest(c); }); return app; } if (import.meta.vitest != null) { /* eslint-disable ts/no-unsafe-assignment, ts/no-unsafe-member-access, ts/no-unsafe-call */ describe('MCP Server', () => { describe('createMcpServer', () => { it('should create MCP server with default options', () => { const server = createMcpServer(); expect(server).toBeDefined(); }); it('should create MCP server with custom options', () => { const server = createMcpServer({ claudePath: '/custom/path' }); expect(server).toBeDefined(); }); }); describe('stdio transport', () => { it('should connect via stdio transport and list tools', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await client.listTools(); expect(result.tools).toHaveLength(6); const toolNames = result.tools.map((tool) => tool.name); expect(toolNames).toContain('daily'); expect(toolNames).toContain('session'); expect(toolNames).toContain('monthly'); expect(toolNames).toContain('blocks'); expect(toolNames).toContain('codex-daily'); expect(toolNames).toContain('codex-monthly'); await client.close(); await server.close(); }); it('should call daily tool successfully', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await client.callTool({ name: 'daily', arguments: { mode: 'auto' }, }); expect(result).toHaveProperty('content'); expect(Array.isArray(result.content)).toBe(true); expect(result.content).toHaveLength(1); expect((result.content as any).at(0)).toHaveProperty('type', 'text'); expect((result.content as any).at(0)).toHaveProperty('text'); const data = JSON.parse((result.content as any).at(0).text as string); expect(data).toHaveProperty('daily'); expect(data).toHaveProperty('totals'); expect(Array.isArray(data.daily)).toBe(true); await client.close(); await server.close(); }); it('should call session tool successfully', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await client.callTool({ name: 'session', arguments: { mode: 'auto' }, }); expect(result).toHaveProperty('content'); expect(result.content).toHaveLength(1); expect((result.content as any)[0]).toHaveProperty('type', 'text'); expect((result.content as any)[0]).toHaveProperty('text'); const data = JSON.parse((result.content as any)[0].text as string); expect(data).toHaveProperty('sessions'); expect(data).toHaveProperty('totals'); expect(Array.isArray(data.sessions)).toBe(true); await client.close(); await server.close(); }); it('should call monthly tool successfully', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await client.callTool({ name: 'monthly', arguments: { mode: 'auto' }, }); expect(result).toHaveProperty('content'); expect(result.content).toHaveLength(1); expect((result.content as any)[0]).toHaveProperty('type', 'text'); expect((result.content as any)[0]).toHaveProperty('text'); const data = JSON.parse((result.content as any)[0].text as string); expect(data).toHaveProperty('monthly'); expect(data).toHaveProperty('totals'); expect(Array.isArray(data.monthly)).toBe(true); await client.close(); await server.close(); }); it('should call blocks tool successfully', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await client.callTool({ name: 'blocks', arguments: { mode: 'auto' }, }); expect(result).toHaveProperty('content'); expect(result.content).toHaveLength(1); expect((result.content as any)[0]).toHaveProperty('type', 'text'); expect((result.content as any)[0]).toHaveProperty('text'); const data = JSON.parse((result.content as any)[0].text as string); expect(data).toHaveProperty('blocks'); expect(Array.isArray(data.blocks)).toBe(true); await client.close(); await server.close(); }); }); describe('HTTP transport', () => { it('should create HTTP app', () => { const app = createMcpHttpApp(); expect(app).toBeDefined(); }); it('should handle invalid JSON in POST request', async () => { const app = createMcpHttpApp(); const response = await app.request('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'invalid json', }); expect(response.status).toBe(406); const data = await response.json(); expect(data).toMatchObject({ jsonrpc: '2.0', error: { code: -32000, message: 'Not Acceptable: Client must accept both application/json and text/event-stream', }, id: null, }); }); it('should handle MCP initialize request', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const app = createMcpHttpApp({ claudePath: fixture.path }); const response = await app.request('/', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', }, body: JSON.stringify({ jsonrpc: '2.0', method: 'initialize', params: { protocolVersion: '1.0.0', capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' }, }, id: 1, }), }); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('text/event-stream'); const text = await response.text(); expect(text).toContain('event: message'); expect(text).toContain('data: '); // Extract the JSON data from the SSE response const dataLine = text.split('\n').find((line) => line.startsWith('data: ')); expect(dataLine).toBeDefined(); const data = JSON.parse(dataLine!.replace('data: ', '')); expect(data.jsonrpc).toBe('2.0'); expect(data.id).toBe(1); expect(data.result).toHaveProperty('protocolVersion'); expect(data.result).toHaveProperty('capabilities'); expect(data.result.serverInfo).toEqual({ name, version }); }); it('should handle MCP callTool request for daily tool', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const app = createMcpHttpApp({ claudePath: fixture.path }); // First initialize await app.request('/', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', }, body: JSON.stringify({ jsonrpc: '2.0', method: 'initialize', params: { protocolVersion: '1.0.0', capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' }, }, id: 1, }), }); // Then call tool const response = await app.request('/', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', }, body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/call', params: { name: 'daily', arguments: { mode: 'auto' }, }, id: 2, }), }); expect(response.status).toBe(200); const text = await response.text(); expect(text).toContain('event: message'); expect(text).toContain('data: '); // Extract the JSON data from the SSE response const dataLine = text.split('\n').find((line) => line.startsWith('data: ')); expect(dataLine).toBeDefined(); const data = JSON.parse(dataLine!.replace('data: ', '')); expect(data.jsonrpc).toBe('2.0'); expect(data.id).toBe(2); expect(data.result).toHaveProperty('content'); expect(Array.isArray(data.result.content)).toBe(true); }); }); describe('error handling', () => { it('should handle tool call with invalid arguments', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // Test with invalid mode enum value - MCP SDK returns isError response const result = await client.callTool({ name: 'daily', arguments: { mode: 'invalid_mode' }, }); expect(result.isError).toBe(true); expect(result.content).toBeDefined(); assert(Array.isArray(result.content)); const textContent = result.content[0] as { type: string; text: string }; expect(textContent.type).toBe('text'); expect(textContent.text).toContain('Invalid'); await client.close(); await server.close(); }); it('should handle tool call with invalid date format', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // Test with invalid date format - MCP SDK returns isError response const result = await client.callTool({ name: 'daily', arguments: { since: 'not-a-date', until: '2024-invalid' }, }); expect(result.isError).toBe(true); expect(result.content).toBeDefined(); assert(Array.isArray(result.content)); const textContent = result.content[0] as { type: string; text: string }; expect(textContent.type).toBe('text'); expect(textContent.text).toContain('Date must be in YYYYMMDD format'); await client.close(); await server.close(); }); it('should handle tool call with unknown tool name', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // Test with unknown tool name - MCP SDK returns isError response const result = await client.callTool({ name: 'unknown-tool', arguments: {}, }); expect(result.isError).toBe(true); expect(result.content).toBeDefined(); assert(Array.isArray(result.content)); const textContent = result.content[0] as { type: string; text: string }; expect(textContent.type).toBe('text'); expect(textContent.text).toContain('Tool unknown-tool not found'); await client.close(); await server.close(); }); }); describe('edge cases', () => { it('should handle empty data directory', async () => { await using fixture = await createFixture({ 'projects/.keep': '', }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await client.callTool({ name: 'daily', arguments: { mode: 'auto' }, }); expect(result).toHaveProperty('content'); expect(Array.isArray(result.content)).toBe(true); expect(result.content).toHaveLength(1); expect((result.content as any)[0]).toHaveProperty('type', 'text'); const data = JSON.parse((result.content as any)[0].text as string); expect(data).toHaveProperty('daily'); expect(data).toHaveProperty('totals'); expect(Array.isArray(data.daily)).toBe(true); expect(data.daily).toHaveLength(0); await client.close(); await server.close(); }); it('should handle malformed JSONL files', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': 'invalid json\n{"valid": "json"}', }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await client.callTool({ name: 'daily', arguments: { mode: 'auto' }, }); expect(result).toHaveProperty('content'); expect(Array.isArray(result.content)).toBe(true); expect(result.content).toHaveLength(1); const data = JSON.parse((result.content as any)[0].text as string); expect(data).toHaveProperty('daily'); expect(data).toHaveProperty('totals'); expect(Array.isArray(data.daily)).toBe(true); // Should still return data, as malformed lines are silently skipped expect(data.daily).toHaveLength(0); await client.close(); await server.close(); }); it('should handle missing Claude directory', async () => { const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: '/nonexistent/path' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await client.callTool({ name: 'daily', arguments: { mode: 'auto' }, }); expect(result).toHaveProperty('content'); expect(Array.isArray(result.content)).toBe(true); expect(result.content).toHaveLength(1); const data = JSON.parse((result.content as any)[0].text as string); expect(data).toHaveProperty('daily'); expect(data).toHaveProperty('totals'); expect(Array.isArray(data.daily)).toBe(true); expect(data.daily).toHaveLength(0); await client.close(); await server.close(); }); it('should handle concurrent tool calls', async () => { await using fixture = await createFixture({ 'projects/test-project/session1/usage.jsonl': JSON.stringify({ timestamp: '2024-01-01T12:00:00Z', costUSD: 0.001, version: '1.0.0', message: { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 50, output_tokens: 10 }, }, }), }); const client = new Client({ name: 'test-client', version: '1.0.0' }); const server = createMcpServer({ claudePath: fixture.path }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // Call multiple tools concurrently const [dailyResult, sessionResult, monthlyResult, blocksResult] = await Promise.all([ client.callTool({ name: 'daily', arguments: { mode: 'auto' } }), client.callTool({ name: 'session', arguments: { mode: 'auto' } }), client.callTool({ name: 'monthly', arguments: { mode: 'auto' } }), client.callTool({ name: 'blocks', arguments: { mode: 'auto' } }), ]); expect(dailyResult).toHaveProperty('content'); expect(sessionResult).toHaveProperty('content'); expect(monthlyResult).toHaveProperty('content'); expect(blocksResult).toHaveProperty('content'); // Verify all responses are valid JSON objects with expected structure const dailyData = JSON.parse((dailyResult.content as any)[0].text as string); const sessionData = JSON.parse((sessionResult.content as any)[0].text as string); const monthlyData = JSON.parse((monthlyResult.content as any)[0].text as string); const blocksData = JSON.parse((blocksResult.content as any)[0].text as string); expect(dailyData).toHaveProperty('daily'); expect(Array.isArray(dailyData.daily)).toBe(true); expect(sessionData).toHaveProperty('sessions'); expect(Array.isArray(sessionData.sessions)).toBe(true); expect(monthlyData).toHaveProperty('monthly'); expect(Array.isArray(monthlyData.monthly)).toBe(true); expect(blocksData).toHaveProperty('blocks'); expect(Array.isArray(blocksData.blocks)).toBe(true); await client.close(); await server.close(); }); }); }); } ================================================ FILE: apps/mcp/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "jsx": "react-jsx", "lib": ["ESNext"], "moduleDetection": "force", "module": "Preserve", "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["vitest/globals", "vitest/importMeta"], "allowImportingTsExtensions": true, "allowJs": true, "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, "noUnusedLocals": false, "noUnusedParameters": false, "noEmit": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true }, "exclude": ["dist"] } ================================================ FILE: apps/mcp/tsdown.config.ts ================================================ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/index.ts'], outDir: 'dist', format: 'esm', clean: true, sourcemap: false, minify: 'dce-only', treeshake: true, fixedExtension: false, dts: { tsgo: true, }, publint: true, unused: true, exports: { devExports: true, }, nodeProtocol: true, define: { 'import.meta.vitest': 'undefined', }, }); ================================================ FILE: apps/mcp/vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { watch: false, includeSource: ['src/**/*.{js,ts}'], globals: true, }, }); ================================================ FILE: apps/opencode/CLAUDE.md ================================================ # OpenCode CLI Notes ## Log Sources - OpenCode session usage is recorded under `${OPENCODE_DATA_DIR:-~/.local/share/opencode}/storage/message/` (the CLI resolves `OPENCODE_DATA_DIR` and falls back to `~/.local/share/opencode`). - Each message is stored as an individual JSON file (not JSONL like Claude or Codex). - Message structure includes `tokens.input`, `tokens.output`, `tokens.cache.read`, and `tokens.cache.write`. ## Token Fields - `input`: total input tokens sent to the model. - `output`: output tokens (completion text). - `cache.read`: cached portion of the input (prompt-caching). - `cache.write`: cache creation tokens. - Pre-calculated `cost` field may be present in OpenCode messages. ## Cost Calculation - OpenCode messages may include pre-calculated `cost` field in USD. - When `cost` is not present, costs should be calculated using model pricing data. - Token mapping: - `inputTokens` ← `tokens.input` - `outputTokens` ← `tokens.output` - `cacheReadInputTokens` ← `tokens.cache.read` - `cacheCreationInputTokens` ← `tokens.cache.write` ## CLI Usage - Treat OpenCode as a sibling to `apps/ccusage` and `apps/codex`. - Reuse shared packages (`@ccusage/terminal`, `@ccusage/internal`) wherever possible. - OpenCode is packaged as a bundled CLI. Keep every runtime dependency in `devDependencies`. - Entry point uses Gunshi framework. - Data discovery relies on `OPENCODE_DATA_DIR` environment variable. - Default path: `~/.local/share/opencode`. ## Testing Notes - Tests rely on `fs-fixture` with `using` to ensure cleanup. - All vitest blocks live alongside implementation files via `if (import.meta.vitest != null)`. - Vitest globals are enabled - use `describe`, `it`, `expect` directly without imports. ================================================ FILE: apps/opencode/README.md ================================================
ccusage logo

@ccusage/opencode

Socket Badge npm version NPM Downloads install size DeepWiki

> Analyze [OpenCode](https://github.com/opencode-ai/opencode) usage logs with the same reporting experience as ccusage. ## Quick Start ```bash # Recommended - always include @latest npx @ccusage/opencode@latest --help bunx @ccusage/opencode@latest --help # Alternative package runners pnpm dlx @ccusage/opencode pnpx @ccusage/opencode # Using deno (with security flags) deno run -E -R=$HOME/.local/share/opencode/ -S=homedir -N='raw.githubusercontent.com:443' npm:@ccusage/opencode@latest --help ``` ### Recommended: Shell Alias Since `npx @ccusage/opencode@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias: ```bash # bash/zsh: alias ccusage-opencode='bunx @ccusage/opencode@latest' # fish: alias ccusage-opencode 'bunx @ccusage/opencode@latest' # Then simply run: ccusage-opencode daily ccusage-opencode monthly --json ``` > 💡 The CLI looks for OpenCode usage data under `OPENCODE_DATA_DIR` (defaults to `~/.local/share/opencode`). ## Common Commands ```bash # Daily usage grouped by date (default command) npx @ccusage/opencode@latest daily # Weekly usage grouped by ISO week npx @ccusage/opencode@latest weekly # Monthly usage grouped by month npx @ccusage/opencode@latest monthly # Session-level detailed report npx @ccusage/opencode@latest session # JSON output for scripting npx @ccusage/opencode@latest daily --json # Compact mode for screenshots/sharing npx @ccusage/opencode@latest daily --compact ``` Useful environment variables: - `OPENCODE_DATA_DIR` – override the OpenCode data directory (defaults to `~/.local/share/opencode`) - `LOG_LEVEL` – control consola log verbosity (0 silent … 5 trace) ## Features - 📊 **Daily Reports**: View token usage and costs aggregated by date - 📅 **Weekly Reports**: View usage grouped by ISO week (YYYY-Www) - 🗓️ **Monthly Reports**: View usage aggregated by month (YYYY-MM) - 💬 **Session Reports**: View usage grouped by conversation sessions - 📈 **Responsive Tables**: Automatic layout adjustment for terminal width - 🤖 **Model Tracking**: See which Claude models you're using (Opus, Sonnet, Haiku, etc.) - 💵 **Accurate Cost Calculation**: Uses LiteLLM pricing database to calculate costs from token data - 🔄 **Cache Token Support**: Tracks and displays cache creation and cache read tokens separately - 📄 **JSON Output**: Export data in structured JSON format with `--json` - 📱 **Compact Mode**: Use `--compact` flag for narrow terminals, perfect for screenshots ## Cost Calculation OpenCode stores `cost: 0` in message files, so this CLI calculates accurate costs from token usage data using the LiteLLM pricing database. All models supported by LiteLLM will have accurate pricing. ## Data Location OpenCode stores usage data in: - **Messages**: `~/.local/share/opencode/storage/message/{sessionID}/msg_{messageID}.json` - **Sessions**: `~/.local/share/opencode/storage/session/{projectHash}/{sessionID}.json` Each message file contains token counts (`input`, `output`, `cache.read`, `cache.write`) and model information. ## Documentation For detailed guides and examples, visit **[ccusage.com](https://ccusage.com/)**. ## Sponsors ### Featured Sponsor Check out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)

ccusage: The Claude Code cost scorecard that went viral

## License MIT © [@ryoppippi](https://github.com/ryoppippi) ================================================ FILE: apps/opencode/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; /** @type {import('eslint').Linter.FlatConfig[]} */ const config = ryoppippi( { type: 'app', stylistic: false, }, { rules: { 'test/no-importing-vitest-globals': 'error', }, }, ); export default config; ================================================ FILE: apps/opencode/package.json ================================================ { "name": "@ccusage/opencode", "type": "module", "version": "18.0.10", "description": "Usage analysis tool for OpenCode sessions", "contributors": [ "ryoppippi", "AnishDe12020" ], "license": "MIT", "funding": "https://github.com/ryoppippi/ccusage?sponsor=1", "homepage": "https://github.com/ryoppippi/ccusage#readme", "repository": { "type": "git", "url": "git+https://github.com/ryoppippi/ccusage.git", "directory": "apps/opencode" }, "bugs": { "url": "https://github.com/ryoppippi/ccusage/issues" }, "main": "./dist/index.js", "module": "./dist/index.js", "bin": { "ccusage-opencode": "./src/index.ts" }, "files": [ "dist" ], "publishConfig": { "bin": { "ccusage-opencode": "./dist/index.js" } }, "engines": { "node": ">=20.19.4" }, "scripts": { "build": "tsdown", "format": "pnpm run lint --fix", "lint": "eslint --cache .", "prepack": "pnpm run build && clean-pkg-json", "prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build", "start": "bun ./src/index.ts", "test": "TZ=UTC vitest", "typecheck": "tsgo --noEmit" }, "devDependencies": { "@ccusage/internal": "workspace:*", "@ccusage/terminal": "workspace:*", "@praha/byethrow": "catalog:runtime", "@ryoppippi/eslint-config": "catalog:lint", "@typescript/native-preview": "catalog:types", "clean-pkg-json": "catalog:release", "es-toolkit": "catalog:runtime", "eslint": "catalog:lint", "fast-sort": "catalog:runtime", "fs-fixture": "catalog:testing", "gunshi": "catalog:runtime", "path-type": "catalog:runtime", "picocolors": "catalog:runtime", "tinyglobby": "catalog:runtime", "tsdown": "catalog:build", "unplugin-macros": "catalog:build", "unplugin-unused": "catalog:build", "valibot": "catalog:runtime", "vitest": "catalog:testing" } } ================================================ FILE: apps/opencode/src/commands/daily.ts ================================================ import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages } from '../data-loader.ts'; import { logger } from '../logger.ts'; const TABLE_COLUMN_COUNT = 8; export const dailyCommand = define({ name: 'daily', description: 'Show OpenCode token usage grouped by day', args: { json: { type: 'boolean', short: 'j', description: 'Output in JSON format', }, compact: { type: 'boolean', description: 'Force compact table mode', }, }, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); const entries = await loadOpenCodeMessages(); if (entries.length === 0) { const output = jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No OpenCode usage data found.'; // eslint-disable-next-line no-console console.log(output); return; } using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); const entriesByDate = groupBy(entries, (entry) => entry.timestamp.toISOString().split('T')[0]!); const dailyData: Array<{ date: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; totalCost: number; modelsUsed: string[]; }> = []; for (const [date, dayEntries] of Object.entries(entriesByDate)) { let inputTokens = 0; let outputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; let totalCost = 0; const modelsSet = new Set(); for (const entry of dayEntries) { inputTokens += entry.usage.inputTokens; outputTokens += entry.usage.outputTokens; cacheCreationTokens += entry.usage.cacheCreationInputTokens; cacheReadTokens += entry.usage.cacheReadInputTokens; totalCost += await calculateCostForEntry(entry, fetcher); modelsSet.add(entry.model); } const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; dailyData.push({ date, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), }); } dailyData.sort((a, b) => a.date.localeCompare(b.date)); const totals = { inputTokens: dailyData.reduce((sum, d) => sum + d.inputTokens, 0), outputTokens: dailyData.reduce((sum, d) => sum + d.outputTokens, 0), cacheCreationTokens: dailyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0), cacheReadTokens: dailyData.reduce((sum, d) => sum + d.cacheReadTokens, 0), totalTokens: dailyData.reduce((sum, d) => sum + d.totalTokens, 0), totalCost: dailyData.reduce((sum, d) => sum + d.totalCost, 0), }; if (jsonOutput) { // eslint-disable-next-line no-console console.log( JSON.stringify( { daily: dailyData, totals, }, null, 2, ), ); return; } // eslint-disable-next-line no-console console.log('\n📊 OpenCode Token Usage Report - Daily\n'); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Date', 'Models', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Date', 'Models', 'Input', 'Output', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: Boolean(ctx.values.compact), style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of dailyData) { table.push([ data.date, formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), formatNumber(data.cacheCreationTokens), formatNumber(data.cacheReadTokens), formatNumber(data.totalTokens), formatCurrency(data.totalCost), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]); // eslint-disable-next-line no-console console.log(table.toString()); if (table.isCompactMode()) { // eslint-disable-next-line no-console console.log('\nRunning in Compact Mode'); // eslint-disable-next-line no-console console.log('Expand terminal width to see cache metrics and total tokens'); } }, }); ================================================ FILE: apps/opencode/src/commands/index.ts ================================================ export { dailyCommand } from './daily'; export { monthlyCommand } from './monthly'; export { sessionCommand } from './session'; export { weeklyCommand } from './weekly'; ================================================ FILE: apps/opencode/src/commands/monthly.ts ================================================ import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages } from '../data-loader.ts'; import { logger } from '../logger.ts'; const TABLE_COLUMN_COUNT = 8; export const monthlyCommand = define({ name: 'monthly', description: 'Show OpenCode token usage grouped by month', args: { json: { type: 'boolean', short: 'j', description: 'Output in JSON format', }, compact: { type: 'boolean', description: 'Force compact table mode', }, }, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); const entries = await loadOpenCodeMessages(); if (entries.length === 0) { const output = jsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No OpenCode usage data found.'; // eslint-disable-next-line no-console console.log(output); return; } using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); const entriesByMonth = groupBy(entries, (entry) => entry.timestamp.toISOString().slice(0, 7)); const monthlyData: Array<{ month: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; totalCost: number; modelsUsed: string[]; }> = []; for (const [month, monthEntries] of Object.entries(entriesByMonth)) { let inputTokens = 0; let outputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; let totalCost = 0; const modelsSet = new Set(); for (const entry of monthEntries) { inputTokens += entry.usage.inputTokens; outputTokens += entry.usage.outputTokens; cacheCreationTokens += entry.usage.cacheCreationInputTokens; cacheReadTokens += entry.usage.cacheReadInputTokens; totalCost += await calculateCostForEntry(entry, fetcher); modelsSet.add(entry.model); } const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; monthlyData.push({ month, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), }); } monthlyData.sort((a, b) => a.month.localeCompare(b.month)); const totals = { inputTokens: monthlyData.reduce((sum, d) => sum + d.inputTokens, 0), outputTokens: monthlyData.reduce((sum, d) => sum + d.outputTokens, 0), cacheCreationTokens: monthlyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0), cacheReadTokens: monthlyData.reduce((sum, d) => sum + d.cacheReadTokens, 0), totalTokens: monthlyData.reduce((sum, d) => sum + d.totalTokens, 0), totalCost: monthlyData.reduce((sum, d) => sum + d.totalCost, 0), }; if (jsonOutput) { // eslint-disable-next-line no-console console.log( JSON.stringify( { monthly: monthlyData, totals, }, null, 2, ), ); return; } // eslint-disable-next-line no-console console.log('\n📊 OpenCode Token Usage Report - Monthly\n'); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Month', 'Models', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Month', 'Models', 'Input', 'Output', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: Boolean(ctx.values.compact), style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of monthlyData) { table.push([ data.month, formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), formatNumber(data.cacheCreationTokens), formatNumber(data.cacheReadTokens), formatNumber(data.totalTokens), formatCurrency(data.totalCost), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]); // eslint-disable-next-line no-console console.log(table.toString()); if (table.isCompactMode()) { // eslint-disable-next-line no-console console.log('\nRunning in Compact Mode'); // eslint-disable-next-line no-console console.log('Expand terminal width to see cache metrics and total tokens'); } }, }); ================================================ FILE: apps/opencode/src/commands/session.ts ================================================ import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages, loadOpenCodeSessions } from '../data-loader.ts'; import { logger } from '../logger.ts'; const TABLE_COLUMN_COUNT = 8; export const sessionCommand = define({ name: 'session', description: 'Show OpenCode token usage grouped by session', args: { json: { type: 'boolean', short: 'j', description: 'Output in JSON format', }, compact: { type: 'boolean', description: 'Force compact table mode', }, }, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); const [entries, sessionMetadataMap] = await Promise.all([ loadOpenCodeMessages(), loadOpenCodeSessions(), ]); if (entries.length === 0) { const output = jsonOutput ? JSON.stringify({ sessions: [], totals: null }) : 'No OpenCode usage data found.'; // eslint-disable-next-line no-console console.log(output); return; } using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); const entriesBySession = groupBy(entries, (entry) => entry.sessionID); type SessionData = { sessionID: string; sessionTitle: string; parentID: string | null; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; totalCost: number; modelsUsed: string[]; lastActivity: Date; }; const sessionData: SessionData[] = []; for (const [sessionID, sessionEntries] of Object.entries(entriesBySession)) { let inputTokens = 0; let outputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; let totalCost = 0; const modelsSet = new Set(); let lastActivity = sessionEntries[0]!.timestamp; for (const entry of sessionEntries) { inputTokens += entry.usage.inputTokens; outputTokens += entry.usage.outputTokens; cacheCreationTokens += entry.usage.cacheCreationInputTokens; cacheReadTokens += entry.usage.cacheReadInputTokens; totalCost += await calculateCostForEntry(entry, fetcher); modelsSet.add(entry.model); if (entry.timestamp > lastActivity) { lastActivity = entry.timestamp; } } const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; const metadata = sessionMetadataMap.get(sessionID); sessionData.push({ sessionID, sessionTitle: metadata?.title ?? sessionID, parentID: metadata?.parentID ?? null, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), lastActivity, }); } sessionData.sort((a, b) => a.lastActivity.getTime() - b.lastActivity.getTime()); const totals = { inputTokens: sessionData.reduce((sum, s) => sum + s.inputTokens, 0), outputTokens: sessionData.reduce((sum, s) => sum + s.outputTokens, 0), cacheCreationTokens: sessionData.reduce((sum, s) => sum + s.cacheCreationTokens, 0), cacheReadTokens: sessionData.reduce((sum, s) => sum + s.cacheReadTokens, 0), totalTokens: sessionData.reduce((sum, s) => sum + s.totalTokens, 0), totalCost: sessionData.reduce((sum, s) => sum + s.totalCost, 0), }; if (jsonOutput) { // eslint-disable-next-line no-console console.log( JSON.stringify( { sessions: sessionData, totals, }, null, 2, ), ); return; } // eslint-disable-next-line no-console console.log('\n📊 OpenCode Token Usage Report - Sessions\n'); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Session', 'Models', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Session', 'Models', 'Input', 'Output', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: Boolean(ctx.values.compact), style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); const sessionsByParent = groupBy(sessionData, (s) => s.parentID ?? 'root'); const parentSessions = sessionsByParent.root ?? []; delete sessionsByParent.root; for (const parentSession of parentSessions) { const isParent = sessionsByParent[parentSession.sessionID] != null; const displayTitle = isParent ? pc.bold(parentSession.sessionTitle) : parentSession.sessionTitle; table.push([ displayTitle, formatModelsDisplayMultiline(parentSession.modelsUsed), formatNumber(parentSession.inputTokens), formatNumber(parentSession.outputTokens), formatNumber(parentSession.cacheCreationTokens), formatNumber(parentSession.cacheReadTokens), formatNumber(parentSession.totalTokens), formatCurrency(parentSession.totalCost), ]); const subSessions = sessionsByParent[parentSession.sessionID]; if (subSessions != null && subSessions.length > 0) { for (const subSession of subSessions) { table.push([ ` ↳ ${subSession.sessionTitle}`, formatModelsDisplayMultiline(subSession.modelsUsed), formatNumber(subSession.inputTokens), formatNumber(subSession.outputTokens), formatNumber(subSession.cacheCreationTokens), formatNumber(subSession.cacheReadTokens), formatNumber(subSession.totalTokens), formatCurrency(subSession.totalCost), ]); } const subtotalInputTokens = parentSession.inputTokens + subSessions.reduce((sum, s) => sum + s.inputTokens, 0); const subtotalOutputTokens = parentSession.outputTokens + subSessions.reduce((sum, s) => sum + s.outputTokens, 0); const subtotalCacheCreationTokens = parentSession.cacheCreationTokens + subSessions.reduce((sum, s) => sum + s.cacheCreationTokens, 0); const subtotalCacheReadTokens = parentSession.cacheReadTokens + subSessions.reduce((sum, s) => sum + s.cacheReadTokens, 0); const subtotalTotalTokens = parentSession.totalTokens + subSessions.reduce((sum, s) => sum + s.totalTokens, 0); const subtotalCost = parentSession.totalCost + subSessions.reduce((sum, s) => sum + s.totalCost, 0); table.push([ pc.dim(' Total (with subagents)'), '', pc.yellow(formatNumber(subtotalInputTokens)), pc.yellow(formatNumber(subtotalOutputTokens)), pc.yellow(formatNumber(subtotalCacheCreationTokens)), pc.yellow(formatNumber(subtotalCacheReadTokens)), pc.yellow(formatNumber(subtotalTotalTokens)), pc.yellow(formatCurrency(subtotalCost)), ]); } } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]); // eslint-disable-next-line no-console console.log(table.toString()); if (table.isCompactMode()) { // eslint-disable-next-line no-console console.log('\nRunning in Compact Mode'); // eslint-disable-next-line no-console console.log('Expand terminal width to see cache metrics and total tokens'); } }, }); ================================================ FILE: apps/opencode/src/commands/weekly.ts ================================================ import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import { addEmptySeparatorRow, formatCurrency, formatDateCompact, formatModelsDisplayMultiline, formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages } from '../data-loader.ts'; import { logger } from '../logger.ts'; const TABLE_COLUMN_COUNT = 8; /** * Get ISO week number for a date * ISO week starts on Monday, first week contains Jan 4th * @param date - Date to get ISO week for * @returns Week string in format YYYY-Www (e.g., "2025-W51") */ function getISOWeek(date: Date): string { // Copy date to avoid mutating original const d = new Date(date.getTime()); // Set to nearest Thursday: current date + 4 - current day number // Make Sunday's day number 7 const dayNum = d.getDay() || 7; d.setDate(d.getDate() + 4 - dayNum); // Get first day of year const yearStart = new Date(d.getFullYear(), 0, 1); // Calculate full weeks to nearest Thursday const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); // Return formatted string return `${d.getFullYear()}-W${String(weekNo).padStart(2, '0')}`; } export const weeklyCommand = define({ name: 'weekly', description: 'Show OpenCode token usage grouped by week (ISO week format)', args: { json: { type: 'boolean', short: 'j', description: 'Output in JSON format', }, compact: { type: 'boolean', description: 'Force compact table mode', }, }, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); const entries = await loadOpenCodeMessages(); if (entries.length === 0) { const output = jsonOutput ? JSON.stringify({ weekly: [], totals: null }) : 'No OpenCode usage data found.'; // eslint-disable-next-line no-console console.log(output); return; } using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); const entriesByWeek = groupBy(entries, (entry) => getISOWeek(entry.timestamp)); const weeklyData: Array<{ week: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; totalCost: number; modelsUsed: string[]; }> = []; for (const [week, weekEntries] of Object.entries(entriesByWeek)) { let inputTokens = 0; let outputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; let totalCost = 0; const modelsSet = new Set(); for (const entry of weekEntries) { inputTokens += entry.usage.inputTokens; outputTokens += entry.usage.outputTokens; cacheCreationTokens += entry.usage.cacheCreationInputTokens; cacheReadTokens += entry.usage.cacheReadInputTokens; totalCost += await calculateCostForEntry(entry, fetcher); modelsSet.add(entry.model); } const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; weeklyData.push({ week, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), }); } weeklyData.sort((a, b) => a.week.localeCompare(b.week)); const totals = { inputTokens: weeklyData.reduce((sum, d) => sum + d.inputTokens, 0), outputTokens: weeklyData.reduce((sum, d) => sum + d.outputTokens, 0), cacheCreationTokens: weeklyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0), cacheReadTokens: weeklyData.reduce((sum, d) => sum + d.cacheReadTokens, 0), totalTokens: weeklyData.reduce((sum, d) => sum + d.totalTokens, 0), totalCost: weeklyData.reduce((sum, d) => sum + d.totalCost, 0), }; if (jsonOutput) { // eslint-disable-next-line no-console console.log( JSON.stringify( { weekly: weeklyData, totals, }, null, 2, ), ); return; } // eslint-disable-next-line no-console console.log('\n📊 OpenCode Token Usage Report - Weekly\n'); const table: ResponsiveTable = new ResponsiveTable({ head: [ 'Week', 'Models', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Cost (USD)', ], colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], compactHead: ['Week', 'Models', 'Input', 'Output', 'Cost (USD)'], compactColAligns: ['left', 'left', 'right', 'right', 'right'], compactThreshold: 100, forceCompact: Boolean(ctx.values.compact), style: { head: ['cyan'] }, dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of weeklyData) { table.push([ data.week, formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), formatNumber(data.cacheCreationTokens), formatNumber(data.cacheReadTokens), formatNumber(data.totalTokens), formatCurrency(data.totalCost), ]); } addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); table.push([ pc.yellow('Total'), '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]); // eslint-disable-next-line no-console console.log(table.toString()); if (table.isCompactMode()) { // eslint-disable-next-line no-console console.log('\nRunning in Compact Mode'); // eslint-disable-next-line no-console console.log('Expand terminal width to see cache metrics and total tokens'); } }, }); if (import.meta.vitest != null) { const { describe, it, expect } = import.meta.vitest; describe('getISOWeek', () => { it('should get ISO week for a date in the middle of the year', () => { const date = new Date('2025-06-15T10:00:00Z'); const week = getISOWeek(date); expect(week).toBe('2025-W24'); }); it('should handle year boundary correctly', () => { // Dec 29, 2025 is a Monday (first week of 2026 in ISO) const date = new Date('2025-12-29T10:00:00Z'); const week = getISOWeek(date); expect(week).toBe('2026-W01'); }); it('should handle first week of year', () => { // Jan 5, 2025 is a Sunday (week 1 of 2025) const date = new Date('2025-01-05T10:00:00Z'); const week = getISOWeek(date); expect(week).toBe('2025-W01'); }); it('should handle last days of previous year belonging to week 1', () => { // Jan 1, 2025 is a Wednesday (week 1 of 2025) const date = new Date('2025-01-01T10:00:00Z'); const week = getISOWeek(date); expect(week).toBe('2025-W01'); }); }); } ================================================ FILE: apps/opencode/src/cost-utils.ts ================================================ import type { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import type { LoadedUsageEntry } from './data-loader.ts'; import { Result } from '@praha/byethrow'; /** * Model aliases for OpenCode-specific model names that don't exist in LiteLLM. * Maps OpenCode model names to their LiteLLM equivalents for pricing lookup. */ const MODEL_ALIASES: Record = { // OpenCode uses -high suffix for higher tier/thinking mode variants 'gemini-3-pro-high': 'gemini-3-pro-preview', }; function resolveModelName(modelName: string): string { return MODEL_ALIASES[modelName] ?? modelName; } /** * Calculate cost for a single usage entry * Uses pre-calculated cost if available, otherwise calculates from tokens */ export async function calculateCostForEntry( entry: LoadedUsageEntry, fetcher: LiteLLMPricingFetcher, ): Promise { if (entry.costUSD != null && entry.costUSD > 0) { return entry.costUSD; } const resolvedModel = resolveModelName(entry.model); const result = await fetcher.calculateCostFromTokens( { input_tokens: entry.usage.inputTokens, output_tokens: entry.usage.outputTokens, cache_creation_input_tokens: entry.usage.cacheCreationInputTokens, cache_read_input_tokens: entry.usage.cacheReadInputTokens, }, resolvedModel, ); return Result.unwrap(result, 0); } ================================================ FILE: apps/opencode/src/data-loader.ts ================================================ /** * @fileoverview Data loading utilities for OpenCode usage analysis * * This module provides functions for loading and parsing OpenCode usage data * from JSON message files stored in OpenCode data directories. * OpenCode stores usage data in ~/.local/share/opencode/storage/message/ * * @module data-loader */ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { isDirectorySync } from 'path-type'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; /** * Default OpenCode data directory path (~/.local/share/opencode) */ const DEFAULT_OPENCODE_PATH = '.local/share/opencode'; /** * OpenCode storage subdirectory containing message data */ const OPENCODE_STORAGE_DIR_NAME = 'storage'; /** * OpenCode messages subdirectory within storage */ const OPENCODE_MESSAGES_DIR_NAME = 'message'; const OPENCODE_SESSIONS_DIR_NAME = 'session'; /** * Environment variable for specifying custom OpenCode data directory */ const OPENCODE_CONFIG_DIR_ENV = 'OPENCODE_DATA_DIR'; /** * User home directory */ const USER_HOME_DIR = homedir(); /** * Branded Valibot schema for model names */ const modelNameSchema = v.pipe( v.string(), v.minLength(1, 'Model name cannot be empty'), v.brand('ModelName'), ); /** * Branded Valibot schema for session IDs */ const sessionIdSchema = v.pipe( v.string(), v.minLength(1, 'Session ID cannot be empty'), v.brand('SessionId'), ); /** * OpenCode message token structure */ export const openCodeTokensSchema = v.object({ input: v.optional(v.number()), output: v.optional(v.number()), reasoning: v.optional(v.number()), cache: v.optional( v.object({ read: v.optional(v.number()), write: v.optional(v.number()), }), ), }); /** * OpenCode message data structure */ export const openCodeMessageSchema = v.object({ id: v.string(), sessionID: v.optional(sessionIdSchema), providerID: v.optional(v.string()), modelID: v.optional(modelNameSchema), time: v.object({ created: v.optional(v.number()), completed: v.optional(v.number()), }), tokens: v.optional(openCodeTokensSchema), cost: v.optional(v.number()), }); export const openCodeSessionSchema = v.object({ id: sessionIdSchema, parentID: v.optional(v.nullable(sessionIdSchema)), title: v.optional(v.string()), projectID: v.optional(v.string()), directory: v.optional(v.string()), }); /** * Represents a single usage data entry loaded from OpenCode files */ export type LoadedUsageEntry = { timestamp: Date; sessionID: string; usage: { inputTokens: number; outputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; }; model: string; costUSD: number | null; }; export type LoadedSessionMetadata = { id: string; parentID: string | null; title: string; projectID: string; directory: string; }; /** * Get OpenCode data directory * @returns Path to OpenCode data directory, or null if not found */ export function getOpenCodePath(): string | null { // Check environment variable first const envPath = process.env[OPENCODE_CONFIG_DIR_ENV]; if (envPath != null && envPath.trim() !== '') { const normalizedPath = path.resolve(envPath); if (isDirectorySync(normalizedPath)) { return normalizedPath; } } // Use default path const defaultPath = path.join(USER_HOME_DIR, DEFAULT_OPENCODE_PATH); if (isDirectorySync(defaultPath)) { return defaultPath; } return null; } /** * Load OpenCode message from JSON file * @param filePath - Path to message JSON file * @returns Parsed message data or null on failure */ async function loadOpenCodeMessage( filePath: string, ): Promise | null> { try { const content = await readFile(filePath, 'utf-8'); const data: unknown = JSON.parse(content); return v.parse(openCodeMessageSchema, data); } catch { return null; } } /** * Convert OpenCode message to LoadedUsageEntry * @param message - Parsed OpenCode message * @returns LoadedUsageEntry suitable for aggregation */ function convertOpenCodeMessageToUsageEntry( message: v.InferOutput, ): LoadedUsageEntry { const createdMs = message.time.created ?? Date.now(); return { timestamp: new Date(createdMs), sessionID: message.sessionID ?? 'unknown', usage: { inputTokens: message.tokens?.input ?? 0, outputTokens: message.tokens?.output ?? 0, cacheCreationInputTokens: message.tokens?.cache?.write ?? 0, cacheReadInputTokens: message.tokens?.cache?.read ?? 0, }, model: message.modelID ?? 'unknown', costUSD: message.cost ?? null, }; } async function loadOpenCodeSession( filePath: string, ): Promise | null> { try { const content = await readFile(filePath, 'utf-8'); const data: unknown = JSON.parse(content); return v.parse(openCodeSessionSchema, data); } catch { return null; } } function convertOpenCodeSessionToMetadata( session: v.InferOutput, ): LoadedSessionMetadata { return { id: session.id, parentID: session.parentID ?? null, title: session.title ?? session.id, projectID: session.projectID ?? 'unknown', directory: session.directory ?? 'unknown', }; } export async function loadOpenCodeSessions(): Promise> { const openCodePath = getOpenCodePath(); if (openCodePath == null) { return new Map(); } const sessionsDir = path.join( openCodePath, OPENCODE_STORAGE_DIR_NAME, OPENCODE_SESSIONS_DIR_NAME, ); if (!isDirectorySync(sessionsDir)) { return new Map(); } const sessionFiles = await glob('**/*.json', { cwd: sessionsDir, absolute: true, }); const sessionMap = new Map(); for (const filePath of sessionFiles) { const session = await loadOpenCodeSession(filePath); if (session == null) { continue; } const metadata = convertOpenCodeSessionToMetadata(session); sessionMap.set(metadata.id, metadata); } return sessionMap; } /** * Load all OpenCode messages * @returns Array of LoadedUsageEntry for aggregation */ export async function loadOpenCodeMessages(): Promise { const openCodePath = getOpenCodePath(); if (openCodePath == null) { return []; } const messagesDir = path.join( openCodePath, OPENCODE_STORAGE_DIR_NAME, OPENCODE_MESSAGES_DIR_NAME, ); if (!isDirectorySync(messagesDir)) { return []; } // Find all message JSON files const messageFiles = await glob('**/*.json', { cwd: messagesDir, absolute: true, }); const entries: LoadedUsageEntry[] = []; const dedupeSet = new Set(); for (const filePath of messageFiles) { const message = await loadOpenCodeMessage(filePath); if (message == null) { continue; } // Skip messages with no tokens if (message.tokens == null || (message.tokens.input === 0 && message.tokens.output === 0)) { continue; } // Skip if no provider or model if (message.providerID == null || message.modelID == null) { continue; } // Deduplicate by message ID const dedupeKey = `${message.id}`; if (dedupeSet.has(dedupeKey)) { continue; } dedupeSet.add(dedupeKey); const entry = convertOpenCodeMessageToUsageEntry(message); entries.push(entry); } return entries; } if (import.meta.vitest != null) { const { describe, it, expect } = import.meta.vitest; describe('data-loader', () => { it('should convert OpenCode message to LoadedUsageEntry', () => { const message = { id: 'msg_123', sessionID: 'ses_456' as v.InferOutput, providerID: 'anthropic', modelID: 'claude-sonnet-4-5' as v.InferOutput, time: { created: 1700000000000, completed: 1700000010000, }, tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 50, write: 25, }, }, cost: 0.001, }; const entry = convertOpenCodeMessageToUsageEntry(message); expect(entry.sessionID).toBe('ses_456'); expect(entry.usage.inputTokens).toBe(100); expect(entry.usage.outputTokens).toBe(200); expect(entry.usage.cacheReadInputTokens).toBe(50); expect(entry.usage.cacheCreationInputTokens).toBe(25); expect(entry.model).toBe('claude-sonnet-4-5'); }); it('should handle missing optional fields', () => { const message = { id: 'msg_123', providerID: 'openai', modelID: 'gpt-5.1' as v.InferOutput, time: { created: 1700000000000, }, tokens: { input: 50, output: 100, }, }; const entry = convertOpenCodeMessageToUsageEntry(message); expect(entry.usage.inputTokens).toBe(50); expect(entry.usage.outputTokens).toBe(100); expect(entry.usage.cacheReadInputTokens).toBe(0); expect(entry.usage.cacheCreationInputTokens).toBe(0); expect(entry.costUSD).toBe(null); }); }); } ================================================ FILE: apps/opencode/src/index.ts ================================================ #!/usr/bin/env node import { run } from './run.ts'; // eslint-disable-next-line antfu/no-top-level-await await run(); ================================================ FILE: apps/opencode/src/logger.ts ================================================ import { createLogger, log as internalLog } from '@ccusage/internal/logger'; import { name } from '../package.json'; export const logger = createLogger(name); export const log = internalLog; ================================================ FILE: apps/opencode/src/run.ts ================================================ import process from 'node:process'; import { cli } from 'gunshi'; import { description, name, version } from '../package.json'; import { dailyCommand, monthlyCommand, sessionCommand, weeklyCommand } from './commands/index.ts'; const subCommands = new Map([ ['daily', dailyCommand], ['monthly', monthlyCommand], ['session', sessionCommand], ['weekly', weeklyCommand], ]); const mainCommand = dailyCommand; export async function run(): Promise { // When invoked through npx, the binary name might be passed as the first argument // Filter it out if it matches the expected binary name let args = process.argv.slice(2); if (args[0] === 'ccusage-opencode') { args = args.slice(1); } await cli(args, mainCommand, { name, version, description, subCommands, renderHeader: null, }); } ================================================ FILE: apps/opencode/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "lib": ["ESNext"], "moduleDetection": "force", "module": "Preserve", "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["vitest/globals", "vitest/importMeta"], "allowImportingTsExtensions": true, "allowJs": false, "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, "noUnusedLocals": false, "noUnusedParameters": false, "noEmit": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true }, "exclude": ["dist"] } ================================================ FILE: apps/opencode/tsdown.config.ts ================================================ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], clean: true, dts: false, shims: true, platform: 'node', target: 'node20', fixedExtension: false, }); ================================================ FILE: apps/opencode/vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, includeSource: ['src/**/*.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], }, }, define: { 'import.meta.vitest': 'undefined', }, }); ================================================ FILE: apps/pi/CLAUDE.md ================================================ # CLAUDE.md - Pi Package This package provides usage tracking for pi-agent. ## Package Overview **Name**: `@ccusage/pi` **Description**: Pi-agent usage tracking **Type**: CLI tool with TypeScript exports ## Development Commands **Testing and Quality:** - `pnpm run test` - Run all tests using vitest - `pnpm run lint` - Lint code using ESLint - `pnpm run format` - Format and auto-fix code with ESLint - `pnpm typecheck` - Type check with TypeScript **Build and Release:** - `pnpm run build` - Build distribution files with tsdown - `pnpm run prerelease` - Full release workflow (lint + typecheck + build) ## Usage ```bash # Show daily pi-agent usage ccusage-pi daily # Show monthly pi-agent usage ccusage-pi monthly # Show session-based pi-agent usage ccusage-pi session # JSON output ccusage-pi daily --json # Custom pi-agent path ccusage-pi daily --pi-path /path/to/sessions ``` ## Architecture This package reads usage data from pi-agent only. **Data Source:** - **Pi-agent**: `~/.pi/agent/sessions/` **Key Modules:** - `src/index.ts` - CLI entry point with Gunshi-based command routing - `src/data-loader.ts` - Loads and aggregates pi-agent JSONL data - `src/_pi-agent.ts` - Pi-agent data parsing and transformation - `src/commands/` - CLI subcommands (daily, monthly, session) ## Dependencies **Key Runtime Dependencies:** - `ccusage` - Main ccusage package (workspace dependency) - `@ccusage/terminal` - Shared terminal utilities - `gunshi` - CLI framework - `valibot` - Schema validation - `tinyglobby` - File globbing **Key Dev Dependencies:** - `vitest` - Testing framework - `tsdown` - TypeScript build tool - `eslint` - Linting and formatting - `fs-fixture` - Test fixture creation ## Testing - **In-Source Testing**: Uses the same testing pattern as the main package - **Vitest Globals Enabled**: Use `describe`, `it`, `expect` directly without imports - **Mock Data**: Uses `fs-fixture` for testing data loading functionality - **CRITICAL**: NEVER use `await import()` dynamic imports anywhere ## Code Style Follow the same code style guidelines as the main ccusage package: - **Error Handling**: Prefer `@praha/byethrow Result` type over try-catch - **Imports**: Use `.ts` extensions for local imports - **Exports**: Only export what's actually used - **Dependencies**: Add as `devDependencies` unless explicitly requested **Post-Change Workflow:** Always run these commands in parallel after code changes: - `pnpm run format` - Auto-fix and format - `pnpm typecheck` - Type checking - `pnpm run test` - Run tests ## Environment Variables | Variable | Description | | -------------- | --------------------------------------------- | | `PI_AGENT_DIR` | Custom path to pi-agent sessions directory | | `LOG_LEVEL` | Adjust logging verbosity (0 silent … 5 trace) | ## Package Exports The package provides the following exports: - `.` - Main CLI entry point ## Binary The package includes a binary `ccusage-pi` that can be used to run the CLI from the command line. ================================================ FILE: apps/pi/README.md ================================================
ccusage logo

@ccusage/pi

Socket Badge npm version NPM Downloads install size DeepWiki

> Analyze [pi-agent](https://github.com/badlogic/pi-mono) session usage with the same reporting experience as ccusage. ## Quick Start ```bash # Recommended - always include @latest npx @ccusage/pi@latest --help bunx @ccusage/pi@latest --help # Alternative package runners pnpm dlx @ccusage/pi pnpx @ccusage/pi ``` ### Recommended: Shell Alias Since `npx @ccusage/pi@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias: ```bash # bash/zsh: alias ccusage-pi='bunx @ccusage/pi@latest' # fish: alias ccusage-pi 'bunx @ccusage/pi@latest' # Then simply run: ccusage-pi daily ccusage-pi monthly --json ``` > 💡 The CLI reads pi-agent session data from `~/.pi/agent/sessions/` (configurable via `PI_AGENT_DIR`). ## Common Commands ```bash # Daily usage grouped by date (default command) npx @ccusage/pi@latest daily # Monthly usage grouped by month npx @ccusage/pi@latest monthly # Session-based usage npx @ccusage/pi@latest session # JSON output for scripting npx @ccusage/pi@latest daily --json # Custom pi-agent path npx @ccusage/pi@latest daily --pi-path /path/to/sessions # Filter by date range npx @ccusage/pi@latest daily --since 2025-12-01 --until 2025-12-19 ``` Useful environment variables: - `PI_AGENT_DIR` – override the pi-agent sessions directory (defaults to `~/.pi/agent/sessions`) - `LOG_LEVEL` – control log verbosity (0 silent … 5 trace) ## What is pi-agent? [Pi-agent](https://github.com/badlogic/pi-mono) is an alternative Claude coding agent. It stores usage data in a similar JSONL format to Claude Code but in a different directory structure. ## Features - 📊 **Daily/Monthly/Session Reports**: Same reporting options as ccusage - 💵 **Accurate Cost Calculation**: Uses LiteLLM pricing database - 📄 **JSON Output**: Export data in structured JSON format with `--json` - 📱 **Compact Mode**: Use `--compact` flag for narrow terminals ## Data Source Pi-agent session data is read from: | Directory | Default Path | | ----------------- | ----------------------- | | Pi-agent sessions | `~/.pi/agent/sessions/` | ## Documentation For detailed guides and examples, visit **[ccusage.com](https://ccusage.com/)**. ## Sponsors ### Featured Sponsor Check out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)

ccusage: The Claude Code cost scorecard that went viral

## License MIT © [@ryoppippi](https://github.com/ryoppippi) ================================================ FILE: apps/pi/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; /** @type {import('eslint').Linter.FlatConfig[]} */ const config = ryoppippi( { type: 'app', stylistic: false, }, { rules: { 'test/no-importing-vitest-globals': 'error', }, }, ); export default config; ================================================ FILE: apps/pi/package.json ================================================ { "name": "@ccusage/pi", "type": "module", "version": "18.0.10", "description": "Pi-agent usage tracking - unified Claude Max usage across Claude Code and pi-agent", "author": "ryoppippi", "contributors": [ "nicobailon" ], "license": "MIT", "funding": "https://github.com/ryoppippi/ccusage?sponsor=1", "homepage": "https://github.com/ryoppippi/ccusage#readme", "repository": { "type": "git", "url": "git+https://github.com/ryoppippi/ccusage.git", "directory": "apps/pi" }, "bugs": { "url": "https://github.com/ryoppippi/ccusage/issues" }, "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { "ccusage-pi": "./src/index.ts" }, "files": [ "README.md", "dist" ], "publishConfig": { "bin": { "ccusage-pi": "./dist/index.js" }, "exports": { ".": "./dist/index.js", "./package.json": "./package.json" } }, "engines": { "node": ">=20.19.4" }, "scripts": { "build": "tsdown", "dev": "bun -b --watch ./src/index.ts", "format": "pnpm run lint --fix", "lint": "eslint --cache .", "prepack": "pnpm run build && clean-pkg-json", "prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build", "start": "bun ./src/index.ts", "test": "TZ=UTC vitest", "typecheck": "tsgo --noEmit" }, "devDependencies": { "@ccusage/internal": "workspace:*", "@ccusage/terminal": "workspace:*", "@praha/byethrow": "catalog:runtime", "@ryoppippi/eslint-config": "catalog:lint", "@typescript/native-preview": "catalog:types", "clean-pkg-json": "catalog:release", "es-toolkit": "catalog:runtime", "eslint": "catalog:lint", "fs-fixture": "catalog:testing", "gunshi": "catalog:runtime", "path-type": "catalog:runtime", "picocolors": "catalog:runtime", "publint": "catalog:lint", "tinyglobby": "catalog:runtime", "tsdown": "catalog:build", "valibot": "catalog:runtime", "vitest": "catalog:testing" } } ================================================ FILE: apps/pi/src/_consts.ts ================================================ import { homedir } from 'node:os'; import path from 'node:path'; export const USER_HOME_DIR = homedir(); export const PI_AGENT_DIR_ENV = 'PI_AGENT_DIR'; export const PI_AGENT_SESSIONS_DIR_NAME = 'sessions'; export const DEFAULT_PI_AGENT_PATH = path.join('.pi', 'agent'); ================================================ FILE: apps/pi/src/_pi-agent.ts ================================================ import path from 'node:path'; import process from 'node:process'; import { isDirectorySync } from 'path-type'; import * as v from 'valibot'; import { DEFAULT_PI_AGENT_PATH, PI_AGENT_DIR_ENV, PI_AGENT_SESSIONS_DIR_NAME, USER_HOME_DIR, } from './_consts.ts'; import { isoTimestampSchema } from './_types.ts'; const piAgentUsageSchema = v.object({ input: v.number(), output: v.number(), cacheRead: v.optional(v.number()), cacheWrite: v.optional(v.number()), totalTokens: v.optional(v.number()), cost: v.optional( v.object({ total: v.optional(v.number()), }), ), }); export const piAgentMessageSchema = v.object({ type: v.optional(v.string()), timestamp: isoTimestampSchema, message: v.object({ role: v.optional(v.string()), model: v.optional(v.string()), usage: v.optional(piAgentUsageSchema), }), }); export type PiAgentMessage = v.InferOutput; export function isPiAgentUsageEntry(data: PiAgentMessage): boolean { const isMessage = data.type == null || data.type === 'message'; return ( isMessage && data.message?.role === 'assistant' && data.message?.usage != null && typeof data.message.usage.input === 'number' && typeof data.message.usage.output === 'number' ); } export function extractPiAgentSessionId(filePath: string): string { const filename = path.basename(filePath, '.jsonl'); const idx = filename.indexOf('_'); return idx !== -1 ? filename.slice(idx + 1) : filename; } export function extractPiAgentProject(filePath: string): string { const normalizedPath = filePath.replace(/[/\\]/g, path.sep); const segments = normalizedPath.split(path.sep); const idx = segments.findIndex((s) => s === 'sessions'); if (idx === -1 || idx + 1 >= segments.length) { return 'unknown'; } return segments[idx + 1] ?? 'unknown'; } export function getPiAgentPaths(customPath?: string): string[] { if (customPath != null && customPath !== '') { const resolved = path.resolve(customPath); if (isDirectorySync(resolved)) { return [resolved]; } } const envPath = (process.env[PI_AGENT_DIR_ENV] ?? '').trim(); if (envPath !== '') { const resolved = path.resolve(envPath); if (isDirectorySync(resolved)) { return [resolved]; } } const defaultPath = path.join(USER_HOME_DIR, DEFAULT_PI_AGENT_PATH, PI_AGENT_SESSIONS_DIR_NAME); if (isDirectorySync(defaultPath)) { return [defaultPath]; } return []; } export function transformPiAgentUsage(data: PiAgentMessage): { usage: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number; }; model: string | undefined; costUSD: number | undefined; totalTokens: number; } | null { if (!isPiAgentUsageEntry(data)) { return null; } const usage = data.message.usage!; const totalTokens = usage.totalTokens ?? usage.input + usage.output + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); return { usage: { input_tokens: usage.input, output_tokens: usage.output, cache_creation_input_tokens: usage.cacheWrite ?? 0, cache_read_input_tokens: usage.cacheRead ?? 0, }, model: data.message.model != null ? `[pi] ${data.message.model}` : undefined, costUSD: usage.cost?.total, totalTokens, }; } if (import.meta.vitest != null) { describe('isPiAgentUsageEntry', () => { it('returns true for valid assistant message with usage', () => { const data: PiAgentMessage = { type: 'message', timestamp: '2024-01-01T00:00:00Z' as v.InferOutput, message: { role: 'assistant', model: 'claude-opus-4-5', usage: { input: 100, output: 50, cacheRead: 10, cacheWrite: 20, }, }, }; expect(isPiAgentUsageEntry(data)).toBe(true); }); it('returns false for user message', () => { const data: PiAgentMessage = { type: 'message', timestamp: '2024-01-01T00:00:00Z' as v.InferOutput, message: { role: 'user', usage: { input: 100, output: 50, }, }, }; expect(isPiAgentUsageEntry(data)).toBe(false); }); it('returns false for non-message type', () => { const data: PiAgentMessage = { type: 'tool_use', timestamp: '2024-01-01T00:00:00Z' as v.InferOutput, message: { role: 'assistant', usage: { input: 100, output: 50, }, }, }; expect(isPiAgentUsageEntry(data)).toBe(false); }); it('returns false when usage is missing', () => { const data: PiAgentMessage = { type: 'message', timestamp: '2024-01-01T00:00:00Z' as v.InferOutput, message: { role: 'assistant', }, }; expect(isPiAgentUsageEntry(data)).toBe(false); }); it('returns true when type is undefined but has assistant with usage', () => { const data: PiAgentMessage = { type: undefined, timestamp: '2024-01-01T00:00:00Z' as v.InferOutput, message: { role: 'assistant', model: 'claude-opus-4-5', usage: { input: 100, output: 50, }, }, }; expect(isPiAgentUsageEntry(data)).toBe(true); }); }); describe('extractPiAgentSessionId', () => { it('extracts session ID from filename with timestamp prefix', () => { const filePath = '/path/to/sessions/project/2025-12-19T08-12-33-794Z_2c16ab69-02b4-46e1-96ad-5b19ef6be8c4.jsonl'; expect(extractPiAgentSessionId(filePath)).toBe('2c16ab69-02b4-46e1-96ad-5b19ef6be8c4'); }); it('returns full filename when no underscore', () => { const filePath = '/path/to/sessions/project/session-id.jsonl'; expect(extractPiAgentSessionId(filePath)).toBe('session-id'); }); }); describe('extractPiAgentProject', () => { it('extracts project name from path', () => { const filePath = '/Users/test/.pi/agent/sessions/--Users-test-project--/file.jsonl'; expect(extractPiAgentProject(filePath)).toBe('--Users-test-project--'); }); it('returns unknown when sessions not in path', () => { const filePath = '/Users/test/.pi/agent/other/project/file.jsonl'; expect(extractPiAgentProject(filePath)).toBe('unknown'); }); }); describe('transformPiAgentUsage', () => { it('transforms valid pi-agent usage to ccusage format', () => { const data: PiAgentMessage = { type: 'message', timestamp: '2024-01-01T00:00:00Z' as v.InferOutput, message: { role: 'assistant', model: 'claude-opus-4-5', usage: { input: 100, output: 50, cacheRead: 10, cacheWrite: 20, totalTokens: 180, cost: { total: 0.05, }, }, }, }; const result = transformPiAgentUsage(data); expect(result).not.toBeNull(); expect(result?.usage.input_tokens).toBe(100); expect(result?.usage.output_tokens).toBe(50); expect(result?.usage.cache_read_input_tokens).toBe(10); expect(result?.usage.cache_creation_input_tokens).toBe(20); expect(result?.model).toBe('[pi] claude-opus-4-5'); expect(result?.costUSD).toBe(0.05); expect(result?.totalTokens).toBe(180); }); it('calculates totalTokens when not provided', () => { const data: PiAgentMessage = { type: 'message', timestamp: '2024-01-01T00:00:00Z' as v.InferOutput, message: { role: 'assistant', model: 'claude-opus-4-5', usage: { input: 100, output: 50, cacheRead: 10, cacheWrite: 20, }, }, }; const result = transformPiAgentUsage(data); expect(result?.totalTokens).toBe(180); }); it('returns null for invalid entry', () => { const data: PiAgentMessage = { type: 'tool_use', timestamp: '2024-01-01T00:00:00Z' as v.InferOutput, message: { role: 'assistant', }, }; expect(transformPiAgentUsage(data)).toBeNull(); }); }); } ================================================ FILE: apps/pi/src/_types.ts ================================================ import * as v from 'valibot'; const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/; export const isoTimestampSchema = v.pipe( v.string(), v.regex(isoTimestampRegex, 'Invalid ISO timestamp'), v.brand('ISOTimestamp'), ); export type ISOTimestamp = v.InferOutput; ================================================ FILE: apps/pi/src/commands/daily.ts ================================================ import process from 'node:process'; import { addEmptySeparatorRow, createUsageReportTable, formatDateCompact, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import { loadPiAgentDailyData } from '../data-loader.ts'; import { log, logger } from '../logger.ts'; export const dailyCommand = define({ name: 'daily', description: 'Show pi-agent usage by date', args: { json: { type: 'boolean', description: 'Output as JSON', default: false, }, since: { type: 'string', description: 'Start date (YYYY-MM-DD or YYYYMMDD)', }, until: { type: 'string', description: 'End date (YYYY-MM-DD or YYYYMMDD)', }, timezone: { type: 'string', short: 'z', description: 'Timezone for date display', }, piPath: { type: 'string', description: 'Path to pi-agent sessions directory', }, order: { type: 'string', description: 'Sort order: asc or desc', default: 'desc', }, breakdown: { type: 'boolean', short: 'b', description: 'Show model breakdown for each entry', default: false, }, }, async run(ctx) { const options = { since: ctx.values.since, until: ctx.values.until, timezone: ctx.values.timezone, order: ctx.values.order as 'asc' | 'desc', piPath: ctx.values.piPath, }; const piData = await loadPiAgentDailyData(options); if (piData.length === 0) { if (ctx.values.json) { log(JSON.stringify([])); } else { logger.warn('No usage data found.'); } process.exit(0); } const totals = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0, }; for (const d of piData) { totals.inputTokens += d.inputTokens; totals.outputTokens += d.outputTokens; totals.cacheCreationTokens += d.cacheCreationTokens; totals.cacheReadTokens += d.cacheReadTokens; totals.totalCost += d.totalCost; } if (ctx.values.json) { log( JSON.stringify( { daily: piData, totals, }, null, 2, ), ); } else { logger.box('Pi-Agent Usage Report - Daily'); const table = createUsageReportTable({ firstColumnName: 'Date', dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of piData) { const row = formatUsageDataRow(data.date, { inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, }); table.push(row); if (ctx.values.breakdown) { pushBreakdownRows(table, data.modelBreakdowns); } } addEmptySeparatorRow(table, 8); const totalsRow = formatTotalsRow({ inputTokens: totals.inputTokens, outputTokens: totals.outputTokens, cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, }); table.push(totalsRow); log(table.toString()); } }, }); ================================================ FILE: apps/pi/src/commands/index.ts ================================================ export { dailyCommand } from './daily.ts'; export { monthlyCommand } from './monthly.ts'; export { sessionCommand } from './session.ts'; ================================================ FILE: apps/pi/src/commands/monthly.ts ================================================ import process from 'node:process'; import { addEmptySeparatorRow, createUsageReportTable, formatDateCompact, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import { loadPiAgentMonthlyData } from '../data-loader.ts'; import { log, logger } from '../logger.ts'; export const monthlyCommand = define({ name: 'monthly', description: 'Show pi-agent usage by month', args: { json: { type: 'boolean', description: 'Output as JSON', default: false, }, since: { type: 'string', description: 'Start date (YYYY-MM-DD or YYYYMMDD)', }, until: { type: 'string', description: 'End date (YYYY-MM-DD or YYYYMMDD)', }, timezone: { type: 'string', short: 'z', description: 'Timezone for date display', }, piPath: { type: 'string', description: 'Path to pi-agent sessions directory', }, order: { type: 'string', description: 'Sort order: asc or desc', default: 'desc', }, breakdown: { type: 'boolean', short: 'b', description: 'Show model breakdown for each entry', default: false, }, }, async run(ctx) { const options = { since: ctx.values.since, until: ctx.values.until, timezone: ctx.values.timezone, order: ctx.values.order as 'asc' | 'desc', piPath: ctx.values.piPath, }; const piData = await loadPiAgentMonthlyData(options); if (piData.length === 0) { if (ctx.values.json) { log(JSON.stringify([])); } else { logger.warn('No usage data found.'); } process.exit(0); } const totals = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0, }; for (const d of piData) { totals.inputTokens += d.inputTokens; totals.outputTokens += d.outputTokens; totals.cacheCreationTokens += d.cacheCreationTokens; totals.cacheReadTokens += d.cacheReadTokens; totals.totalCost += d.totalCost; } if (ctx.values.json) { log( JSON.stringify( { monthly: piData, totals, }, null, 2, ), ); } else { logger.box('Pi-Agent Usage Report - Monthly'); const table = createUsageReportTable({ firstColumnName: 'Month', dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of piData) { const row = formatUsageDataRow(data.month, { inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, }); table.push(row); if (ctx.values.breakdown) { pushBreakdownRows(table, data.modelBreakdowns); } } addEmptySeparatorRow(table, 8); const totalsRow = formatTotalsRow({ inputTokens: totals.inputTokens, outputTokens: totals.outputTokens, cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, }); table.push(totalsRow); log(table.toString()); } }, }); ================================================ FILE: apps/pi/src/commands/session.ts ================================================ import path from 'node:path'; import process from 'node:process'; import { addEmptySeparatorRow, createUsageReportTable, formatDateCompact, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import { loadPiAgentSessionData } from '../data-loader.ts'; import { log, logger } from '../logger.ts'; export const sessionCommand = define({ name: 'session', description: 'Show pi-agent usage by session', args: { json: { type: 'boolean', description: 'Output as JSON', default: false, }, since: { type: 'string', description: 'Start date (YYYY-MM-DD or YYYYMMDD)', }, until: { type: 'string', description: 'End date (YYYY-MM-DD or YYYYMMDD)', }, timezone: { type: 'string', short: 'z', description: 'Timezone for date display', }, piPath: { type: 'string', description: 'Path to pi-agent sessions directory', }, order: { type: 'string', description: 'Sort order: asc or desc', default: 'desc', }, breakdown: { type: 'boolean', short: 'b', description: 'Show model breakdown for each entry', default: false, }, }, async run(ctx) { const options = { since: ctx.values.since, until: ctx.values.until, timezone: ctx.values.timezone, order: ctx.values.order as 'asc' | 'desc', piPath: ctx.values.piPath, }; const piData = await loadPiAgentSessionData(options); if (piData.length === 0) { if (ctx.values.json) { log(JSON.stringify([])); } else { logger.warn('No usage data found.'); } process.exit(0); } const totals = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0, }; for (const d of piData) { totals.inputTokens += d.inputTokens; totals.outputTokens += d.outputTokens; totals.cacheCreationTokens += d.cacheCreationTokens; totals.cacheReadTokens += d.cacheReadTokens; totals.totalCost += d.totalCost; } if (ctx.values.json) { log( JSON.stringify( { sessions: piData, totals, }, null, 2, ), ); } else { logger.box('Pi-Agent Usage Report - Sessions'); const table = createUsageReportTable({ firstColumnName: 'Session', dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); for (const data of piData) { const projectName = path.basename(data.projectPath); const truncatedName = projectName.length > 25 ? `${projectName.slice(0, 22)}...` : projectName; const row = formatUsageDataRow(truncatedName, { inputTokens: data.inputTokens, outputTokens: data.outputTokens, cacheCreationTokens: data.cacheCreationTokens, cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, }); table.push(row); if (ctx.values.breakdown) { pushBreakdownRows(table, data.modelBreakdowns); } } addEmptySeparatorRow(table, 8); const totalsRow = formatTotalsRow({ inputTokens: totals.inputTokens, outputTokens: totals.outputTokens, cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, }); table.push(totalsRow); log(table.toString()); } }, }); ================================================ FILE: apps/pi/src/data-loader.ts ================================================ import fs from 'node:fs'; import readline from 'node:readline'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; import { extractPiAgentProject, extractPiAgentSessionId, getPiAgentPaths, piAgentMessageSchema, transformPiAgentUsage, } from './_pi-agent.ts'; export type Source = 'claude-code' | 'pi-agent'; export type LoadOptions = { piPath?: string; since?: string; until?: string; timezone?: string; order?: 'asc' | 'desc'; }; export type DailyUsageWithSource = { date: string; source: Source; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalCost: number; modelsUsed: string[]; modelBreakdowns: Array<{ modelName: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; }>; }; export type SessionUsageWithSource = { sessionId: string; projectPath: string; source: Source; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalCost: number; lastActivity: string; modelsUsed: string[]; modelBreakdowns: Array<{ modelName: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; }>; }; export type MonthlyUsageWithSource = { month: string; source: Source; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalCost: number; modelsUsed: string[]; modelBreakdowns: Array<{ modelName: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; }>; }; async function processJSONLFileByLine( filePath: string, processor: (line: string) => Promise | void, ): Promise { const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); for await (const line of rl) { const trimmedLine = line.trim(); if (trimmedLine !== '') { await processor(trimmedLine); } } } async function globPiAgentFiles(paths: string[]): Promise { const allFiles: string[] = []; for (const basePath of paths) { const files = await glob(['**/*.jsonl'], { cwd: basePath, absolute: true, }); allFiles.push(...files); } return allFiles; } function formatDate(timestamp: string, timezone?: string): string { const date = new Date(timestamp); const tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; return date.toLocaleDateString('en-CA', { timeZone: tz }); } function formatMonth(timestamp: string, timezone?: string): string { const date = new Date(timestamp); const tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; const formatted = date.toLocaleDateString('en-CA', { timeZone: tz }); return formatted.slice(0, 7); } function normalizeDate(value: string): string { return value.replace(/-/g, ''); } function isInDateRange(date: string, since?: string, until?: string): boolean { const dateKey = normalizeDate(date); if (since != null && dateKey < normalizeDate(since)) { return false; } if (until != null && dateKey > normalizeDate(until)) { return false; } return true; } type EntryData = { timestamp: string; model: string | undefined; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; project: string; sessionId: string; }; export async function loadPiAgentData(options?: LoadOptions): Promise { const piPaths = getPiAgentPaths(options?.piPath); if (piPaths.length === 0) { return []; } const files = await globPiAgentFiles(piPaths); if (files.length === 0) { return []; } const processedHashes = new Set(); const entries: EntryData[] = []; for (const file of files) { const project = extractPiAgentProject(file); const sessionId = extractPiAgentSessionId(file); await processJSONLFileByLine(file, (line) => { try { const parsed = JSON.parse(line) as unknown; const result = v.safeParse(piAgentMessageSchema, parsed); if (!result.success) { return; } const data = result.output; const transformed = transformPiAgentUsage(data); if (transformed == null) { return; } const hash = `pi:${data.timestamp}:${transformed.totalTokens}`; if (processedHashes.has(hash)) { return; } processedHashes.add(hash); entries.push({ timestamp: data.timestamp, model: transformed.model, inputTokens: transformed.usage.input_tokens, outputTokens: transformed.usage.output_tokens, cacheCreationTokens: transformed.usage.cache_creation_input_tokens, cacheReadTokens: transformed.usage.cache_read_input_tokens, cost: transformed.costUSD ?? 0, project, sessionId, }); } catch { // Skip invalid lines } }); } return entries; } function aggregateByModel(entries: EntryData[]): Map< string, { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; } > { const modelMap = new Map< string, { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; } >(); for (const entry of entries) { const model = entry.model ?? 'unknown'; const existing = modelMap.get(model) ?? { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0, }; existing.inputTokens += entry.inputTokens; existing.outputTokens += entry.outputTokens; existing.cacheCreationTokens += entry.cacheCreationTokens; existing.cacheReadTokens += entry.cacheReadTokens; existing.cost += entry.cost; modelMap.set(model, existing); } return modelMap; } function createBreakdowns( modelMap: Map< string, { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; } >, ): Array<{ modelName: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; }> { return Array.from(modelMap.entries()).map(([modelName, data]) => ({ modelName, ...data, })); } function calculateTotals(entries: EntryData[]): { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalCost: number; } { let inputTokens = 0; let outputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; let totalCost = 0; for (const entry of entries) { inputTokens += entry.inputTokens; outputTokens += entry.outputTokens; cacheCreationTokens += entry.cacheCreationTokens; cacheReadTokens += entry.cacheReadTokens; totalCost += entry.cost; } return { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalCost }; } export async function loadPiAgentDailyData(options?: LoadOptions): Promise { const entries = await loadPiAgentData(options); const grouped = new Map(); for (const entry of entries) { const date = formatDate(entry.timestamp, options?.timezone); if (!isInDateRange(date, options?.since, options?.until)) { continue; } const existing = grouped.get(date) ?? []; existing.push(entry); grouped.set(date, existing); } const results: DailyUsageWithSource[] = []; for (const [date, dateEntries] of grouped) { const modelMap = aggregateByModel(dateEntries); const totals = calculateTotals(dateEntries); const modelsUsed = Array.from(modelMap.keys()); const modelBreakdowns = createBreakdowns(modelMap); results.push({ date, source: 'pi-agent', ...totals, modelsUsed, modelBreakdowns, }); } const order = options?.order ?? 'desc'; results.sort((a, b) => order === 'asc' ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date), ); return results; } export async function loadPiAgentSessionData( options?: LoadOptions, ): Promise { const entries = await loadPiAgentData(options); const grouped = new Map(); for (const entry of entries) { const key = `${entry.project}\x00${entry.sessionId}`; const existing = grouped.get(key) ?? []; existing.push(entry); grouped.set(key, existing); } const results: SessionUsageWithSource[] = []; for (const [key, sessionEntries] of grouped) { const [project, sessionId] = key.split('\x00') as [string, string]; const modelMap = aggregateByModel(sessionEntries); const totals = calculateTotals(sessionEntries); const modelsUsed = Array.from(modelMap.keys()); const modelBreakdowns = createBreakdowns(modelMap); const lastActivity = sessionEntries.reduce((latest, entry) => { return entry.timestamp > latest ? entry.timestamp : latest; }, sessionEntries[0]?.timestamp ?? ''); const lastDate = formatDate(lastActivity, options?.timezone); if (!isInDateRange(lastDate, options?.since, options?.until)) { continue; } results.push({ sessionId, projectPath: project, source: 'pi-agent', ...totals, lastActivity: lastDate, modelsUsed, modelBreakdowns, }); } const order = options?.order ?? 'desc'; results.sort((a, b) => order === 'asc' ? a.lastActivity.localeCompare(b.lastActivity) : b.lastActivity.localeCompare(a.lastActivity), ); return results; } export async function loadPiAgentMonthlyData( options?: LoadOptions, ): Promise { const entries = await loadPiAgentData(options); const grouped = new Map(); for (const entry of entries) { const month = formatMonth(entry.timestamp, options?.timezone); const date = formatDate(entry.timestamp, options?.timezone); if (!isInDateRange(date, options?.since, options?.until)) { continue; } const existing = grouped.get(month) ?? []; existing.push(entry); grouped.set(month, existing); } const results: MonthlyUsageWithSource[] = []; for (const [month, monthEntries] of grouped) { const modelMap = aggregateByModel(monthEntries); const totals = calculateTotals(monthEntries); const modelsUsed = Array.from(modelMap.keys()); const modelBreakdowns = createBreakdowns(modelMap); results.push({ month, source: 'pi-agent', ...totals, modelsUsed, modelBreakdowns, }); } const order = options?.order ?? 'desc'; results.sort((a, b) => order === 'asc' ? a.month.localeCompare(b.month) : b.month.localeCompare(a.month), ); return results; } ================================================ FILE: apps/pi/src/index.ts ================================================ #!/usr/bin/env node import process from 'node:process'; import { cli } from 'gunshi'; import { description, name, version } from '../package.json'; import { dailyCommand } from './commands/daily.ts'; import { monthlyCommand } from './commands/monthly.ts'; import { sessionCommand } from './commands/session.ts'; const subCommands = new Map([ ['daily', dailyCommand], ['monthly', monthlyCommand], ['session', sessionCommand], ]); const mainCommand = dailyCommand; async function run(): Promise { let args = process.argv.slice(2); if (args[0] === 'ccusage-pi') { args = args.slice(1); } await cli(args, mainCommand, { name, version, description, subCommands, renderHeader: null, }); } // eslint-disable-next-line antfu/no-top-level-await await run(); ================================================ FILE: apps/pi/src/logger.ts ================================================ import { createLogger, log as internalLog } from '@ccusage/internal/logger'; import { name } from '../package.json'; export const logger = createLogger(name); export const log = internalLog; ================================================ FILE: apps/pi/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "jsx": "react-jsx", "lib": ["ESNext"], "moduleDetection": "force", "module": "Preserve", "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["@types/bun", "vitest/globals", "vitest/importMeta"], "allowImportingTsExtensions": true, "allowJs": true, "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, "noUnusedLocals": false, "noUnusedParameters": false, "noEmit": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true }, "exclude": ["dist"] } ================================================ FILE: apps/pi/tsdown.config.ts ================================================ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/index.ts'], outDir: 'dist', format: 'esm', clean: true, sourcemap: false, minify: 'dce-only', treeshake: true, fixedExtension: false, dts: { tsgo: true, }, publint: true, unused: true, exports: { devExports: true, }, nodeProtocol: true, define: { 'import.meta.vitest': 'undefined', }, }); ================================================ FILE: apps/pi/vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { watch: false, includeSource: ['src/**/*.{js,ts}'], globals: true, }, }); ================================================ FILE: ccusage.example.json ================================================ { "$schema": "./apps/ccusage/config-schema.json", "defaults": { "json": true, "mode": "auto", "timezone": "Asia/Tokyo", "locale": "ja-JP", "offline": false, "breakdown": false }, "commands": { "daily": { "instances": true, "order": "desc", "projectAliases": "ccusage=Usage Tracker,my-long-project-name=Project X" }, "monthly": { "breakdown": true }, "weekly": { "startOfWeek": "monday" }, "blocks": { "tokenLimit": "500000", "sessionLength": 5, "active": false }, "statusline": { "offline": true } } } ================================================ FILE: docs/.gitignore ================================================ # VitePress build output .vitepress/dist/ .vitepress/cache/ # Generated documentation api/ # Dependencies node_modules/ # Temporary files .temp/ .cache/ # OS files .DS_Store Thumbs.db public/_redirects public/config-schema.json ================================================ FILE: docs/.vitepress/config.ts ================================================ import type { DefaultTheme } from 'vitepress'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { cloudflareRedirect } from '@ryoppippi/vite-plugin-cloudflare-redirect'; import { defineConfig } from 'vitepress'; import { groupIconMdPlugin, groupIconVitePlugin } from 'vitepress-plugin-group-icons'; import llmstxt from 'vitepress-plugin-llms'; const typedocSidebarJson = fs.readFileSync( path.join(import.meta.dirname, '../api/typedoc-sidebar.json'), ); const typedocSidebar = JSON.parse(typedocSidebarJson.toString()) as DefaultTheme.SidebarItem[]; export default defineConfig({ title: 'ccusage', description: 'Usage analysis tool for Claude Code', base: '/', cleanUrls: true, ignoreDeadLinks: true, head: [ ['link', { rel: 'icon', href: '/favicon.svg' }], ['meta', { name: 'theme-color', content: '#646cff' }], ['meta', { property: 'og:type', content: 'website' }], ['meta', { property: 'og:locale', content: 'en' }], ['meta', { property: 'og:title', content: 'ccusage | Claude Code Usage Analysis' }], ['meta', { property: 'og:site_name', content: 'ccusage' }], [ 'meta', { property: 'og:image', content: 'https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.png', }, ], ['meta', { property: 'og:url', content: 'https://github.com/ryoppippi/ccusage' }], ], themeConfig: { logo: '/logo.svg', nav: [ { text: 'Guide', link: '/guide/' }, { text: 'API Reference', link: '/api/' }, { text: 'Links', items: [ { text: 'GitHub', link: 'https://github.com/ryoppippi/ccusage' }, { text: 'npm', link: 'https://www.npmjs.com/package/ccusage' }, { text: 'Changelog', link: 'https://github.com/ryoppippi/ccusage/releases' }, { text: 'DeepWiki', link: 'https://deepwiki.com/ryoppippi/ccusage' }, { text: 'Package Stats', link: 'https://tanstack.com/ccusage?npmPackage=ccusage' }, { text: 'Sponsor', link: 'https://github.com/sponsors/ryoppippi' }, ], }, ], sidebar: { '/guide/': [ { text: 'Introduction', items: [ { text: 'Introduction', link: '/guide/' }, { text: 'Getting Started', link: '/guide/getting-started' }, { text: 'Installation', link: '/guide/installation' }, ], }, { text: 'Usage', items: [ { text: 'Daily Reports', link: '/guide/daily-reports' }, { text: 'Weekly Reports', link: '/guide/weekly-reports' }, { text: 'Monthly Reports', link: '/guide/monthly-reports' }, { text: 'Session Reports', link: '/guide/session-reports' }, { text: 'Blocks Reports', link: '/guide/blocks-reports' }, { text: 'Live Monitoring (Removed)', link: '/guide/live-monitoring' }, ], }, { text: 'Codex (Beta)', items: [ { text: 'Overview', link: '/guide/codex/' }, { text: 'Daily Report', link: '/guide/codex/daily' }, { text: 'Monthly Report', link: '/guide/codex/monthly' }, { text: 'Session Report', link: '/guide/codex/session' }, ], }, { text: 'OpenCode (Beta)', items: [{ text: 'Overview', link: '/guide/opencode/' }], }, { text: 'Configuration', items: [ { text: 'Overview', link: '/guide/configuration' }, { text: 'Command-Line Options', link: '/guide/cli-options' }, { text: 'Environment Variables', link: '/guide/environment-variables' }, { text: 'Configuration Files', link: '/guide/config-files' }, { text: 'Directory Detection', link: '/guide/directory-detection' }, { text: 'Custom Paths', link: '/guide/custom-paths' }, { text: 'Cost Calculation Modes', link: '/guide/cost-modes' }, ], }, { text: 'Integration', items: [ { text: 'Library Usage', link: '/guide/library-usage' }, { text: 'MCP Server', link: '/guide/mcp-server' }, { text: 'JSON Output', link: '/guide/json-output' }, { text: 'Statusline Integration', link: '/guide/statusline' }, ], }, { text: 'Community', items: [ { text: 'Related Projects', link: '/guide/related-projects' }, { text: 'Sponsors', link: '/guide/sponsors' }, ], }, ], '/api/': [ { text: 'API Reference', items: [{ text: 'Overview', link: '/api/' }, ...typedocSidebar], }, ], }, socialLinks: [ { icon: 'github', link: 'https://github.com/ryoppippi/ccusage' }, { icon: 'npm', link: 'https://www.npmjs.com/package/ccusage' }, { icon: 'twitter', link: 'https://x.com/cc_usage' }, ], footer: { message: 'Released under the MIT License.', copyright: 'Copyright © 2025 ryoppippi', }, search: { provider: 'local', }, editLink: { pattern: 'https://github.com/ryoppippi/ccusage/edit/main/docs/:path', text: 'Edit this page on GitHub', }, lastUpdated: { text: 'Updated at', formatOptions: { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC', }, }, }, vite: { plugins: [ cloudflareRedirect({ mode: 'generate', entries: [ { from: '/raycast', to: 'https://www.raycast.com/nyatinte/ccusage', status: 302 }, { from: '/gh', to: 'https://github.com/ryoppippi/ccusage', status: 302 }, { from: '/npm', to: 'https://www.npmjs.com/package/ccusage', status: 302 }, { from: '/deepwiki', to: 'https://deepwiki.com/ryoppippi/ccusage', status: 302 }, { from: '/sponsor', to: 'https://github.com/sponsors/ryoppippi', status: 302 }, ], }) as any, groupIconVitePlugin(), ...llmstxt(), ], }, markdown: { config(md) { md.use(groupIconMdPlugin); }, }, }); ================================================ FILE: docs/CLAUDE.md ================================================ # CLAUDE.md - Documentation This directory contains the VitePress-based documentation website for ccusage. ## Package Overview **Name**: `@ccusage/docs` **Description**: Documentation for ccusage **Type**: VitePress documentation site (private package) ## Development Commands **Documentation Development:** - `pnpm run dev` - Start development server with API docs generation and schema copy - `pnpm run build` - Build documentation site for production - `pnpm run preview` - Preview built documentation locally - `pnpm run docs:api` - Generate API documentation from TypeScript source - `pnpm run lint` - Lint documentation files using ESLint - `pnpm run format` - Format and auto-fix documentation files with ESLint - `pnpm typecheck` - Type check TypeScript files **Deployment:** - `pnpm run deploy` - Deploy to Cloudflare using Wrangler ## Architecture **Documentation Structure:** - `guide/` - User guides and tutorials with screenshots - `api/` - Auto-generated API documentation from TypeScript source - `public/` - Static assets including screenshots and config schema - `.vitepress/` - VitePress configuration and theme customization **Key Files:** - `update-api-index.ts` - Script to generate API documentation index - `typedoc.config.mjs` - TypeDoc configuration for API docs generation - `public/config-schema.json` - JSON schema copied from ccusage package during build ## Documentation Guidelines **Screenshot Usage:** - **Placement**: Always place screenshots immediately after main headings (H1) - **Purpose**: Provide immediate visual context before textual explanations - **Guides with Screenshots**: - `/docs/guide/index.md` - Main usage screenshot - `/docs/guide/daily-reports.md` - Daily report output screenshot - `/docs/guide/live-monitoring.md` - Live monitoring dashboard screenshot - `/docs/guide/mcp-server.md` - Claude Desktop integration screenshot - **Image Path**: Use relative paths like `/screenshot.png` for images in `/docs/public/` - **Alt Text**: Always include descriptive alt text for accessibility **Content Organization:** - User-facing guides in `guide/` directory - Auto-generated API reference in `api/` directory - Static assets and schemas in `public/` directory ## Build Process 1. **API Documentation**: `./update-api-index.ts` generates API docs from ccusage TypeScript source 2. **Schema Copy**: `config-schema.json` is copied from the ccusage package to public directory 3. **VitePress Build**: Standard VitePress build process creates static site 4. **Deployment**: Built site is deployed to Cloudflare using Wrangler ## Dependencies **Key Dev Dependencies:** - `vitepress` - Static site generator - `typedoc` - API documentation generation - `typedoc-plugin-markdown` - Markdown output for TypeDoc - `typedoc-vitepress-theme` - VitePress theme for TypeDoc - `wrangler` - Cloudflare deployment tool - `ccusage` - Main package (workspace dependency for API docs) **VitePress Plugins:** - `vitepress-plugin-group-icons` - Group icons in navigation - `vitepress-plugin-llms` - LLM-specific enhancements - `@ryoppippi/vite-plugin-cloudflare-redirect` - Cloudflare redirect handling ## Development Workflow 1. **Start Development**: `pnpm run dev` automatically generates API docs and starts dev server 2. **Edit Content**: Modify markdown files in `guide/` or update source code for API changes 3. **Preview Changes**: Development server automatically reloads on changes 4. **Build for Production**: `pnpm run build` generates final static site 5. **Deploy**: `pnpm run deploy` pushes to Cloudflare ## Content Guidelines - **No console.log**: Documentation scripts should use appropriate logging - **Accessibility**: Always include alt text for images and screenshots - **Visual First**: Lead with screenshots, then explain with text - **Consistency**: Follow established patterns for new documentation pages - **Cross-References**: Link between related guides and API documentation - **ESLint in Markdown**: For code blocks that should skip ESLint parsing (e.g., containing `...` syntax), add `` before the code block ## File Organization ``` docs/ ├── guide/ # User guides and tutorials ├── api/ # Auto-generated API docs ├── public/ # Static assets (screenshots, schemas) ├── .vitepress/ # VitePress configuration ├── package.json # Dependencies and scripts └── CLAUDE.md # This file ``` ================================================ FILE: docs/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; export default ryoppippi({ type: 'app', markdown: true, stylistic: false, }); ================================================ FILE: docs/guide/blocks-reports.md ================================================ # Blocks Reports Blocks reports show your Claude Code usage grouped by 5-hour billing windows, helping you understand Claude's billing cycle and track active session progress. ## Basic Usage ```bash ccusage blocks ``` ## Example Output ``` ╭──────────────────────────────────────────────────╮ │ │ │ Claude Code Token Usage Report - Session Blocks │ │ │ ╰──────────────────────────────────────────────────╯ ┌─────────────────────┬──────────────────┬────────┬─────────┬──────────────┬────────────┬──────────────┬────────────┐ │ Block Start Time │ Models │ Input │ Output │ Cache Create │ Cache Read │ Total Tokens │ Cost (USD) │ ├─────────────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┤ │ 2025-06-21 09:00:00 │ • opus-4 │ 4,512 │ 285,846 │ 512 │ 1,024 │ 291,894 │ $156.40 │ │ ⏰ Active (2h 15m) │ • sonnet-4 │ │ │ │ │ │ │ │ 🔥 Rate: 2.1k/min │ │ │ │ │ │ │ │ │ 📊 Projected: 450k │ │ │ │ │ │ │ │ ├─────────────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┤ │ 2025-06-21 04:00:00 │ • sonnet-4 │ 2,775 │ 186,645 │ 256 │ 768 │ 190,444 │ $98.45 │ │ ✅ Completed (3h 42m)│ │ │ │ │ │ │ │ ├─────────────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┤ │ 2025-06-20 15:30:00 │ • opus-4 │ 1,887 │ 183,055 │ 128 │ 512 │ 185,582 │ $81.73 │ │ ✅ Completed (4h 12m)│ │ │ │ │ │ │ │ ├─────────────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┤ │ Total │ │ 9,174 │ 655,546 │ 896 │ 2,304 │ 667,920 │ $336.58 │ └─────────────────────┴──────────────────┴────────┴─────────┴──────────────┴────────────┴──────────────┴────────────┘ ``` ## Understanding Blocks ### Session Block Concept Claude Code uses **5-hour billing windows** for session tracking: - **Block Start**: Triggered by your first message - **Block Duration**: Lasts exactly 5 hours from start time - **Rolling Windows**: New blocks start with activity after previous block expires - **Billing Relevance**: Matches Claude's internal session tracking - **UTC Time Handling**: Block boundaries are calculated in UTC to ensure consistent behavior across time zones ### Block Status Indicators - **⏰ Active**: Currently running block with time remaining - **✅ Completed**: Finished block that ran its full duration or ended naturally - **⌛ Gap**: Time periods with no activity (shown when relevant) - **🔥 Rate**: Token burn rate (tokens per minute) for active blocks - **📊 Projected**: Estimated total tokens if current rate continues ## Command Options ### Show Active Block Only Focus on your current session with detailed projections: ```bash ccusage blocks --active ``` This shows only the currently active block with: - Time remaining in the 5-hour window - Current token burn rate - Projected final token count and cost ### Show Recent Blocks Display blocks from the last 3 days (including active): ```bash ccusage blocks --recent ``` Perfect for understanding recent usage patterns without scrolling through all historical data. ### Token Limit Tracking Set token limits to monitor quota usage: ```bash # Set explicit token limit ccusage blocks --token-limit 500000 # Use highest previous block as limit ccusage blocks --token-limit max # or short form: ccusage blocks -t max ``` When limits are set, blocks display: - ⚠️ **Warning indicators** when approaching limits - 🚨 **Alert indicators** when exceeding limits - **Progress bars** showing usage relative to limit ### Live Monitoring (Removed) ::: danger REMOVED IN v18 The `blocks --live` monitor feature has been removed in v18.0.0. This feature is available in v17.x. Please use the [statusline command](/guide/statusline) instead for real-time monitoring. ::: Previously available options (v17.x only): ```bash # Basic live monitoring (uses -t max automatically) ccusage blocks --live # Live monitoring with explicit token limit ccusage blocks --live --token-limit 500000 # Custom refresh interval (1-60 seconds) ccusage blocks --live --refresh-interval 5 ``` ### Custom Session Duration Change the block duration (default is 5 hours): ```bash # 3-hour blocks ccusage blocks --session-length 3 # 8-hour blocks ccusage blocks --session-length 8 ``` ### Date Filtering Filter blocks by date range: ```bash # Show blocks from specific date range ccusage blocks --since 20250620 --until 20250621 # Show blocks from last week ccusage blocks --since $(date -d '7 days ago' +%Y%m%d) ``` ### Sort Order ```bash # Show newest blocks first (default) ccusage blocks --order desc # Show oldest blocks first ccusage blocks --order asc ``` ### Cost Calculation Modes ```bash # Use pre-calculated costs when available (default) ccusage blocks --mode auto # Always calculate costs from tokens ccusage blocks --mode calculate # Only show pre-calculated costs ccusage blocks --mode display ``` ### JSON Output Export block data for analysis: ```bash ccusage blocks --json ``` ```json { "blocks": [ { "id": "2025-06-21T09:00:00.000Z", "startTime": "2025-06-21T09:00:00.000Z", "endTime": "2025-06-21T14:00:00.000Z", "actualEndTime": "2025-06-21T11:15:00.000Z", "isActive": true, "tokenCounts": { "inputTokens": 4512, "outputTokens": 285846, "cacheCreationInputTokens": 512, "cacheReadInputTokens": 1024 }, "costUSD": 156.4, "models": ["opus-4", "sonnet-4"] } ] } ``` ### Offline Mode Use cached pricing data without network access: ```bash ccusage blocks --offline # or short form: ccusage blocks -O ``` ## Analysis Use Cases ### Session Planning Understanding 5-hour windows helps with: ```bash # Check current active block ccusage blocks --active ``` - **Time Management**: Know how much time remains in current session - **Usage Pacing**: Monitor if you're on track for reasonable usage - **Break Planning**: Understand when current session will expire ### Usage Optimization ```bash # Find your highest usage patterns ccusage blocks -t max --recent ``` - **Peak Usage Identification**: Which blocks consumed the most tokens - **Efficiency Patterns**: Compare block efficiency (tokens per hour) - **Model Selection Impact**: How model choice affects block costs ### Real-time Session Tracking ```bash # Monitor active sessions in real-time ccusage statusline ``` Perfect for: - **Long coding sessions**: Track progress against historical limits - **Budget management**: Watch costs accumulate in real-time - **Productivity tracking**: Understand work intensity patterns ### Historical Analysis ```bash # Export data for detailed analysis ccusage blocks --json > blocks-history.json # Analyze patterns over time ccusage blocks --since 20250601 --until 20250630 ``` ## Block Analysis Tips ### 1. Understanding Block Efficiency Look for patterns in your block data: - **High-efficiency blocks**: Lots of output tokens for minimal input - **Exploratory blocks**: High input/output ratios (research, debugging) - **Focused blocks**: Steady token burn rates with clear objectives ### 2. Time Management Use blocks to optimize your Claude usage: - **Session planning**: Start important work at the beginning of blocks - **Break timing**: Use block boundaries for natural work breaks - **Batch processing**: Group similar tasks within single blocks ### 3. Cost Optimization Blocks help identify cost patterns: - **Model switching**: When to use Opus vs Sonnet within blocks - **Cache efficiency**: How cache usage affects block costs - **Usage intensity**: Whether short focused sessions or long exploratory ones are more cost-effective ### 4. Quota Management When working with token limits: - **Rate monitoring**: Watch burn rates to avoid exceeding limits - **Early warning**: Set limits below actual quotas for safety margin - **Usage spreading**: Distribute heavy usage across multiple blocks ## Responsive Display Blocks reports adapt to your terminal width: - **Wide terminals (≥100 chars)**: Shows all columns with full timestamps - **Narrow terminals (<100 chars)**: Compact mode with abbreviated times and essential data ## Advanced Features ### Gap Detection Blocks reports automatically detect and display gaps: ``` ┌─────────────────────┬──────────────────┬────────┬─────────┬────────────┐ │ 2025-06-21 09:00:00 │ • opus-4 │ 4,512 │ 285,846 │ $156.40 │ │ ⏰ Active (2h 15m) │ • sonnet-4 │ │ │ │ ├─────────────────────┼──────────────────┼────────┼─────────┼────────────┤ │ 2025-06-20 22:00:00 │ ⌛ 11h gap │ 0 │ 0 │ $0.00 │ │ 2025-06-21 09:00:00 │ │ │ │ │ ├─────────────────────┼──────────────────┼────────┼─────────┼────────────┤ │ 2025-06-20 15:30:00 │ • opus-4 │ 1,887 │ 183,055 │ $81.73 │ │ ✅ Completed (4h 12m)│ │ │ │ │ └─────────────────────┴──────────────────┴────────┴─────────┴────────────┘ ``` ### Burn Rate Calculations For active blocks, the tool calculates: - **Tokens per minute**: Based on activity within the block - **Cost per hour**: Projected hourly spend rate - **Projected totals**: Estimated final tokens/cost if current rate continues ### Progress Visualization When using token limits, blocks show visual progress: - **Green**: Usage well below limit (< 70%) - **Yellow**: Approaching limit (70-90%) - **Red**: At or exceeding limit (≥ 90%) ## Related Commands - [Daily Reports](/guide/daily-reports) - Usage aggregated by calendar date - [Monthly Reports](/guide/monthly-reports) - Monthly usage summaries - [Session Reports](/guide/session-reports) - Individual conversation analysis - [Statusline](/guide/statusline) - Real-time session tracking (replacement for live monitoring) ## Next Steps After understanding block patterns, consider: 1. [Statusline](/guide/statusline) for real-time active session tracking 2. [Session Reports](/guide/session-reports) to analyze individual conversations within blocks 3. [Daily Reports](/guide/daily-reports) to see how blocks aggregate across days ================================================ FILE: docs/guide/cli-options.md ================================================ # Command-Line Options ccusage provides extensive command-line options to customize its behavior. These options take precedence over configuration files and environment variables. ## Global Options All ccusage commands support these global options: ### Date Filtering Filter usage data by date range: ```bash # Filter by date range ccusage daily --since 20250101 --until 20250630 # Show data from a specific date ccusage monthly --since 20250101 # Show data up to a specific date ccusage session --until 20250630 ``` ### Output Format Control how data is displayed: ```bash # JSON output for programmatic use ccusage daily --json ccusage daily -j # Show per-model breakdown ccusage daily --breakdown ccusage daily -b # Combine options ccusage daily --json --breakdown ``` ### Cost Calculation Mode Choose how costs are calculated: ```bash # Auto mode (default) - use costUSD when available ccusage daily --mode auto # Calculate mode - always calculate from tokens ccusage daily --mode calculate # Display mode - only show pre-calculated costUSD ccusage daily --mode display ``` ### Sort Order Control the ordering of results: ```bash # Newest first (default) ccusage daily --order desc # Oldest first ccusage daily --order asc ``` ### Offline Mode Run without network connectivity: ```bash # Use cached pricing data ccusage daily --offline ccusage daily -O ``` ### Timezone Set the timezone for date calculations: ```bash # Use UTC timezone ccusage daily --timezone UTC # Use specific timezone ccusage daily --timezone America/New_York ccusage daily -z Asia/Tokyo # Short alias ccusage monthly -z Europe/London ``` #### Timezone Effect The timezone affects how usage is grouped by date. For example, usage at 11 PM UTC on January 1st would appear on: - **January 1st** when `--timezone UTC` - **January 1st** when `--timezone America/New_York` (6 PM EST) - **January 2nd** when `--timezone Asia/Tokyo` (8 AM JST next day) ### Locale Control date and time formatting: ```bash # US English (12-hour time format) ccusage daily --locale en-US # Japanese (24-hour time format) ccusage blocks --locale ja-JP # German (24-hour time format) ccusage session -l de-DE # Short alias ccusage daily -l fr-FR ``` #### Locale Effects The locale affects display formatting: **Date Format:** - `en-US`: 08/04/2025 - `en-CA`: 2025-08-04 (ISO format, default) - `ja-JP`: 2025/08/04 - `de-DE`: 04.08.2025 **Time Format:** - `en-US`: 3:30:00 PM (12-hour) - Others: 15:30:00 (24-hour) ### Debug Options Get detailed debugging information: ```bash # Debug mode - show pricing mismatches and config loading ccusage daily --debug # Show sample discrepancies ccusage daily --debug --debug-samples 10 ``` ### Configuration File Use a custom configuration file: ```bash # Specify custom config file ccusage daily --config ./my-config.json ccusage monthly --config /path/to/team-config.json ``` ## Command-Specific Options ### Daily Command Additional options for daily reports: ```bash # Group by project ccusage daily --instances ccusage daily -i # Filter to specific project ccusage daily --project myproject ccusage daily -p myproject # Combine project filtering ccusage daily --instances --project myproject ``` ### Weekly Command Options for weekly reports: ```bash # Set week start day ccusage weekly --start-of-week monday ccusage weekly --start-of-week sunday ``` ### Session Command Options for session reports: ```bash # Filter by session ID ccusage session --id abc123-session # Filter by project ccusage session --project myproject ``` ### Blocks Command Options for 5-hour billing blocks: ```bash # Show only active block ccusage blocks --active ccusage blocks -a # Show recent blocks (last 3 days) ccusage blocks --recent ccusage blocks -r # Set token limit for warnings ccusage blocks --token-limit 500000 ccusage blocks --token-limit max # Live monitoring mode ccusage blocks --live ccusage blocks --live --refresh-interval 2 # Customize session length ccusage blocks --session-length 5 ``` > **Note:** The MCP server CLI moved to the dedicated `@ccusage/mcp` package. See the [MCP Server guide](/guide/mcp-server) for usage details. ### Statusline Options for statusline display: ```bash # Basic statusline ccusage statusline # Force offline mode ccusage statusline --offline # Enable caching ccusage statusline --cache # Custom refresh interval ccusage statusline --refresh-interval 5 ``` ## JSON Output Options When using `--json` output, additional processing options are available: ```bash # Apply jq filter to JSON output ccusage daily --json --jq ".data[]" # Filter high-cost days ccusage daily --json --jq ".data[] | select(.cost > 10)" # Extract specific fields ccusage session --json --jq ".data[] | {date, cost}" ``` ## Option Precedence Options are applied in this order (highest to lowest priority): 1. **Command-line arguments** - Direct CLI options 2. **Custom config file** - Via `--config` flag 3. **Local project config** - `.ccusage/ccusage.json` 4. **User config** - `~/.config/claude/ccusage.json` 5. **Legacy config** - `~/.claude/ccusage.json` 6. **Built-in defaults** ## Examples ### Development Workflow ```bash # Daily development check ccusage daily --instances --breakdown # Check specific project costs ccusage daily --project myapp --since 20250101 # Export for reporting ccusage monthly --json > monthly-report.json ``` ### Team Collaboration ```bash # Use team configuration ccusage daily --config ./team-config.json # Consistent timezone for remote team ccusage daily --timezone UTC --locale en-CA # Generate shareable report ccusage weekly --json --jq ".summary" ``` ### Cost Monitoring ```bash # Monitor active usage ccusage blocks --active --live # Check if approaching limits ccusage blocks --token-limit 500000 # Historical analysis ccusage monthly --mode calculate --breakdown ``` ### Debugging Issues ```bash # Debug configuration loading ccusage daily --debug --config ./test-config.json # Check pricing discrepancies ccusage daily --debug --debug-samples 20 # Silent mode for scripts LOG_LEVEL=0 ccusage daily --json ``` ## Short Aliases Many options have short aliases for convenience: | Long Option | Short | Description | | ------------- | ----- | ------------------- | | `--json` | `-j` | JSON output | | `--breakdown` | `-b` | Per-model breakdown | | `--offline` | `-O` | Offline mode | | `--timezone` | `-z` | Set timezone | | `--locale` | `-l` | Set locale | | `--instances` | `-i` | Group by project | | `--project` | `-p` | Filter project | | `--active` | `-a` | Active block only | | `--recent` | `-r` | Recent blocks | ## Related Documentation - [Environment Variables](/guide/environment-variables) - Configure via environment - [Configuration Files](/guide/config-files) - Persistent configuration - [Cost Calculation Modes](/guide/cost-modes) - Understanding cost modes ================================================ FILE: docs/guide/codex/daily.md ================================================ # Codex Daily Report (Beta) The `daily` command mirrors ccusage's daily report but operates on Codex CLI session logs. ```bash # Recommended (fastest) bunx @ccusage/codex@latest daily # Using npx npx @ccusage/codex@latest daily ``` ## Options | Flag | Description | | ---------------------------- | -------------------------------------------------------------- | | `--since` / `--until` | Filter to a specific date range (YYYYMMDD or YYYY-MM-DD) | | `--timezone` | Override timezone used for grouping (defaults to system) | | `--locale` | Adjust date formatting locale | | `--json` | Emit structured JSON instead of a table | | `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | | `--compact` | Force compact table layout (same columns as a narrow terminal) | The output uses the same responsive table component as ccusage, including compact mode support and per-model token summaries. Need higher-level trends? Switch to the [monthly report](./monthly.md) for month-by-month rollups with the same flag set. ================================================ FILE: docs/guide/codex/index.md ================================================ # Codex CLI Overview (Beta) ![Codex CLI daily report](/codex-cli.jpeg) > ⚠️ The Codex companion CLI is experimental. Expect breaking changes while both ccusage and [OpenAI's Codex CLI](https://github.com/openai/codex) continue to evolve. The `@ccusage/codex` package reuses ccusage's responsive tables, pricing cache, and token accounting to analyze OpenAI Codex CLI session logs. ## Installation & Launch ```bash # Recommended - always include @latest npx @ccusage/codex@latest --help bunx @ccusage/codex@latest --help # ⚠️ MUST include @latest with bunx # Alternative package runners pnpm dlx @ccusage/codex --help pnpx @ccusage/codex --help # Using deno (with security flags) deno run -E -R=$HOME/.codex/ -S=homedir -N='raw.githubusercontent.com:443' npm:@ccusage/codex@latest --help ``` ::: warning ⚠️ Critical for bunx users Bun 1.2.x's bunx prioritizes binaries matching the package name suffix when given a scoped package. For `@ccusage/codex`, it looks for a `codex` binary in PATH first. If you have an existing `codex` command installed (e.g., GitHub Copilot's codex), that will be executed instead. **Always use `bunx @ccusage/codex@latest` with the version tag** to force bunx to fetch and run the correct package. ::: ### Recommended: Shell Alias Since `npx @ccusage/codex@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias for convenience: ```bash # bash/zsh: alias ccusage-codex='bunx @ccusage/codex@latest' # fish: alias ccusage-codex 'bunx @ccusage/codex@latest' # Then simply run: ccusage-codex daily ccusage-codex monthly --json ``` ::: tip After adding the alias to your shell config file (`.bashrc`, `.zshrc`, or `config.fish`), restart your shell or run `source` on the config file to apply the changes. ::: ## Data Source The CLI reads Codex session JSONL files located under `CODEX_HOME` (defaults to `~/.codex`). Each file represents a single Codex CLI session and contains running token totals that the tool converts into per-day or per-month deltas. ## What Gets Calculated - **Token deltas** – Each `event_msg` with `payload.type === "token_count"` reports cumulative totals. The CLI subtracts the previous totals to recover per-turn token usage (input, cached input, output, reasoning, total). - **Per-model grouping** – The `turn_context` metadata specifies the active model. We aggregate tokens per day/month and per model. Sessions lacking model metadata (seen in early September 2025 builds) are skipped. - **Pricing** – Rates come from LiteLLM's pricing dataset via the shared `LiteLLMPricingFetcher`. Aliases such as `gpt-5-codex` map to canonical entries (`gpt-5`) so cost calculations remain accurate. - **Legacy fallback** – Early September 2025 logs that never recorded `turn_context` metadata are still included; the CLI assumes `gpt-5` for pricing so you can review the tokens even though the model tag is missing (the JSON output also marks these rows with `"isFallback": true`). - **Cost formula** – Non-cached input uses the standard input price; cached input uses the cache-read price (falling back to the input price when missing); and output tokens are billed at the output price. All prices are per million tokens. Reasoning tokens may be shown for reference, but they are part of the output charge and are not billed separately. - **Totals and reports** – Daily, monthly, and session commands display per-model breakdowns, overall totals, and optional JSON for automation. ## Environment Variables | Variable | Description | | ------------ | ------------------------------------------------------------ | | `CODEX_HOME` | Override the root directory containing Codex session folders | | `LOG_LEVEL` | Adjust consola verbosity (0 silent … 5 trace) | When Codex emits a model alias (for example `gpt-5-codex`), the CLI automatically resolves it to the canonical LiteLLM pricing entry. No manual override is needed. ## Next Steps - [Daily report command](./daily.md) - [Monthly report command](./monthly.md) - [Session report command](./session.md) - Additional reports will mirror the ccusage CLI as the Codex tooling stabilizes. Have feedback or ideas? [Open an issue](https://github.com/ryoppippi/ccusage/issues/new) so we can improve the beta. ## Troubleshooting ::: details Why are there no entries before September 2025? OpenAI's Codex CLI started emitting `token_count` events in [commit 0269096](https://github.com/openai/codex/commit/0269096229e8c8bd95185173706807dc10838c7a) (2025-09-06). Earlier session logs simply don't contain token usage metrics, so `@ccusage/codex` has nothing to aggregate. If you need historic data, rerun those sessions after that Codex update. ::: ::: details What if some September 2025 sessions still get skipped? During the 2025-09 rollouts a few Codex builds emitted `token_count` events without the matching `turn_context` metadata, so the CLI could not determine which model generated the tokens. Those entries are ignored to avoid mispriced reports. If you encounter this, relaunch the Codex CLI to generate fresh logs—the current builds restore the missing metadata. ::: ================================================ FILE: docs/guide/codex/monthly.md ================================================ # Codex Monthly Report (Beta) ![Codex CLI monthly report](/codex-cli.jpeg) The `monthly` command mirrors ccusage's monthly report while operating on Codex CLI session logs. ```bash # Recommended (fastest) bunx @ccusage/codex@latest monthly # Using npx npx @ccusage/codex@latest monthly ``` ## Options | Flag | Description | | ---------------------------- | --------------------------------------------------------------------------- | | `--since` / `--until` | Filter to a specific date range (YYYYMMDD or YYYY-MM-DD) before aggregating | | `--timezone` | Override the timezone used to bucket usage into months | | `--locale` | Adjust month label formatting | | `--json` | Emit structured JSON instead of a table | | `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | | `--compact` | Force compact table layout (same columns as a narrow terminal) | The output uses the same responsive table component as ccusage, including compact mode support, per-model token summaries, and a combined totals row. ================================================ FILE: docs/guide/codex/session.md ================================================ # Codex Session Report (Beta) The `session` command groups Codex CLI usage by individual sessions so you can spot long-running conversations, confirm last activity times, and audit model switches inside a single log. Sessions are listed oldest-to-newest by their last activity timestamp so the output lines up with the daily and monthly views. Each row shows the activity date, the Codex session directory, and a short session identifier (last 8 characters of the log filename) alongside token and cost columns. When your terminal narrows (or `--compact` is passed) the table automatically collapses to just Date, Directory, Session, Input, Output, and Cost to stay readable. ```bash # Recommended (fastest) bunx @ccusage/codex@latest session # Using npx npx @ccusage/codex@latest session ``` ## Options | Flag | Description | | ---------------------------- | ------------------------------------------------------------------------ | | `--since` / `--until` | Filter sessions by their activity date (YYYYMMDD or YYYY-MM-DD) | | `--timezone` | Override the timezone used for date grouping and last-activity display | | `--locale` | Adjust locale for table and timestamp formatting | | `--json` | Emit structured JSON (`{ sessions: [], totals: {} }`) instead of a table | | `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | | `--compact` | Force compact table layout (same columns as a narrow terminal) | JSON output includes a `sessions` array with per-model breakdowns, cached token counts, `lastActivity`, and `isFallback` flags for any events that required the legacy `gpt-5` pricing fallback. Need time-based rollups instead? Check out the [daily](./daily.md) and [monthly](./monthly.md) reports for broader aggregates that reuse the same data source. ================================================ FILE: docs/guide/config-files.md ================================================ # Configuration Files ccusage supports JSON configuration files for persistent settings. Configuration files allow you to set default options for all commands or customize behavior for specific commands without repeating options every time. ## Quick Start ### 1. Use Schema for IDE Support Always include the schema for autocomplete and validation: ```json { "$schema": "https://ccusage.com/config-schema.json" } ``` ### 2. Set Common Defaults Put frequently used options in `defaults`: ```json { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "timezone": "UTC", "locale": "en-CA", "breakdown": true } } ``` ### 3. Override for Specific Commands ```json { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "breakdown": false }, "commands": { "daily": { "breakdown": true // Only daily needs breakdown } } } ``` ### 4. Convert CLI Arguments to Config If you find yourself repeating CLI arguments: ```bash # Before (repeated CLI arguments) ccusage daily --breakdown --instances --timezone UTC ccusage monthly --breakdown --timezone UTC ``` Convert them to a config file: ```json // ccusage.json { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "breakdown": true, "timezone": "UTC" }, "commands": { "daily": { "instances": true } } } ``` Now simpler commands: ```bash ccusage daily ccusage monthly ``` ## Configuration File Locations ccusage searches for configuration files in these locations (in priority order): 1. **Local project**: `.ccusage/ccusage.json` (higher priority) 2. **User config**: `~/.claude/ccusage.json` or `~/.config/claude/ccusage.json` (lower priority) Configuration files are merged in priority order, with local project settings overriding user settings. If you pass a custom config file using `--config`, it will override both local and user configs. Note that configuration files are not required; if none are found, ccusage will use built-in defaults. Also, if you have multiple config files, only the first one found will be used. ## Basic Configuration Create a `ccusage.json` file with your preferred defaults: ```json { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "json": false, "mode": "auto", "offline": false, "timezone": "Asia/Tokyo", "locale": "ja-JP", "breakdown": true } } ``` ## Configuration Structure ### Schema Support Add the `$schema` property to get IntelliSense and validation in your IDE: ```json { "$schema": "https://ccusage.com/config-schema.json" } ``` You can also reference a local schema file after installing ccusage: ```json { "$schema": "./node_modules/ccusage/config-schema.json" } ``` ### Global Defaults The `defaults` section sets default values for all commands: ```json { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "since": "20250101", "until": "20250630", "json": false, "mode": "auto", "debug": false, "debugSamples": 5, "order": "asc", "breakdown": false, "offline": false, "timezone": "UTC", "locale": "en-CA", "jq": ".data[]" } } ``` ### Command-Specific Configuration Override defaults for specific commands using the `commands` section: ```json { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "mode": "auto", "offline": false }, "commands": { "daily": { "instances": true, "breakdown": true }, "blocks": { "active": true, "tokenLimit": "500000" } } } ``` ## Command-Specific Options ### Daily Command ```json { "commands": { "daily": { "instances": true, "project": "my-project", "breakdown": true, "since": "20250101", "until": "20250630" } } } ``` ### Weekly Command ```json { "commands": { "weekly": { "startOfWeek": "monday", "breakdown": true, "timezone": "Europe/London" } } } ``` ### Monthly Command ```json { "commands": { "monthly": { "breakdown": true, "mode": "calculate", "locale": "en-US" } } } ``` ### Session Command ```json { "commands": { "session": { "id": "abc123-session", "project": "my-project", "json": true } } } ``` ### Blocks Command ```json { "commands": { "blocks": { "active": true, "recent": false, "tokenLimit": "max", "sessionLength": 5, "live": false, "refreshInterval": 1 } } } ``` ### Statusline ```json { "commands": { "statusline": { "offline": true, "cache": true, "refreshInterval": 2 } } } ``` ## Custom Configuration Files Use the `--config` option to specify a custom configuration file: ```bash # Use a specific configuration file ccusage daily --config ./my-config.json # Works with all commands ccusage blocks --config /path/to/team-config.json ``` This is useful for: - **Team configurations** - Share configuration files across team members - **Environment-specific settings** - Different configs for development/production - **Project-specific overrides** - Use different settings for different projects ## Configuration Example For a complete configuration example, see [`/ccusage.example.json`](/ccusage.example.json) in the repository root, which demonstrates: - Global defaults configuration - Command-specific overrides - All available options with proper types ## Configuration Priority Settings are applied in this priority order (highest to lowest): 1. **Command-line arguments** (e.g., `--json`, `--offline`) 2. **Custom config file** (specified with `--config /path/to/config.json`) 3. **Local project config** (`.ccusage/ccusage.json`) 4. **User config** (`~/.config/claude/ccusage.json`) 5. **Legacy config** (`~/.claude/ccusage.json`) 6. **Built-in defaults** Example: ```json // .ccusage/ccusage.json { "defaults": { "mode": "calculate" } } ``` ```bash # Config file sets mode to "calculate" ccusage daily # Uses mode: calculate # But CLI argument overrides it ccusage daily --mode display # Uses mode: display ``` ## Debugging Configuration Use the `--debug` flag to see configuration loading details: ```bash # Debug configuration loading ccusage daily --debug # Debug custom config file ccusage daily --debug --config ./my-config.json ``` Debug output shows: - Which config files are checked and found - Schema and option details from loaded configs - How options are merged from different sources - Final values used for each option Example debug output: ``` [ccusage] ℹ Debug mode enabled - showing config loading details [ccusage] ℹ Searching for config files: • Checking: .ccusage/ccusage.json (found ✓) • Checking: ~/.config/claude/ccusage.json (found ✓) • Checking: ~/.claude/ccusage.json (not found) [ccusage] ℹ Loaded config from: .ccusage/ccusage.json • Schema: https://ccusage.com/config-schema.json • Has defaults: yes (3 options) • Has command configs: yes (daily) [ccusage] ℹ Merging options for 'daily' command: • From defaults: mode="auto", offline=false • From command config: instances=true • From CLI args: debug=true • Final merged options: { mode: "auto" (from defaults), offline: false (from defaults), instances: true (from command config), debug: true (from CLI) } ``` ## Best Practices ### Version Control For project configs, commit `.ccusage/ccusage.json` to version control: ```bash # Add to git git add .ccusage/ccusage.json git commit -m "Add ccusage configuration" ``` ### Document Team Configs Add comments using a README alongside team configs: ``` team-configs/ ├── ccusage.json └── README.md # Explain configuration choices ``` ## Troubleshooting ### Config Not Being Applied 1. Check file location is correct 2. Verify JSON syntax is valid 3. Use `--debug` to see loading details 4. Ensure option names match exactly ### Invalid JSON Use a JSON validator or IDE with JSON support: ```bash # Validate JSON syntax jq . < ccusage.json ``` ### Schema Validation Errors Ensure option values match expected types: ```json { "defaults": { "tokenLimit": "500000", // ✅ String or number "active": true, // ✅ Boolean "refreshInterval": 2 // ✅ Number } } ``` ## Related Documentation - [Command-Line Options](/guide/cli-options) - Available CLI arguments - [Environment Variables](/guide/environment-variables) - Environment configuration - [Configuration Overview](/guide/configuration) - Complete configuration guide ================================================ FILE: docs/guide/configuration.md ================================================ # Configuration Overview ccusage provides multiple ways to configure its behavior, allowing you to customize it for your specific needs. Configuration can be done through command-line options, environment variables, configuration files, or a combination of all three. ## Configuration Methods ccusage supports four configuration methods, each with its own use case: 1. **[Command-Line Options](/guide/cli-options)** - Direct control for individual commands 2. **[Environment Variables](/guide/environment-variables)** - System-wide or session settings 3. **[Configuration Files](/guide/config-files)** - Persistent, shareable settings 4. **[Directory Detection](/guide/directory-detection)** - Automatic Claude data discovery ## Configuration Priority Settings are applied in this priority order (highest to lowest): 1. **Command-line arguments** (e.g., `--json`, `--offline`) 2. **Custom config file** (via `--config` flag) 3. **Environment variables** (e.g., `CLAUDE_CONFIG_DIR`, `LOG_LEVEL`) 4. **Local project config** (`.ccusage/ccusage.json`) 5. **User config** (`~/.config/claude/ccusage.json`) 6. **Legacy config** (`~/.claude/ccusage.json`) 7. **Built-in defaults** ### Priority Example ```bash # Configuration file sets mode to "calculate" # .ccusage/ccusage.json { "defaults": { "mode": "calculate" } } # Environment variable sets timezone export CCUSAGE_TIMEZONE="Asia/Tokyo" # Command-line argument takes highest priority ccusage daily --mode display --timezone UTC # Result: mode=display (CLI), timezone=UTC (CLI) ``` ## Quick Start ### Basic Setup 1. **Set your Claude directory** (if not using defaults): ```bash export CLAUDE_CONFIG_DIR="$HOME/.config/claude" ``` 2. **Create a configuration file** for your preferences: ```json { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "timezone": "America/New_York", "locale": "en-US", "breakdown": true } } ``` 3. **Use command-line options** for one-off changes: ```bash ccusage daily --since 20250101 --json ``` ## Common Configuration Scenarios ### Personal Development For individual developers working on multiple projects: ```json // ~/.config/claude/ccusage.json { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "breakdown": true, "timezone": "local" }, "commands": { "daily": { "instances": true } } } ``` ### Team Collaboration For teams sharing configuration: ```json // .ccusage/ccusage.json (committed to repo) { "$schema": "https://ccusage.com/config-schema.json", "defaults": { "timezone": "UTC", "locale": "en-CA", "mode": "auto" } } ``` ### CI/CD Pipeline For automated environments: ```bash # Environment variables export CLAUDE_CONFIG_DIR="/ci/claude-data" export LOG_LEVEL=1 # Warnings only # Run with specific options ccusage daily --offline --json > report.json ``` ### Multiple Claude Installations For users with multiple Claude Code versions: ```bash # Aggregate from multiple directories export CLAUDE_CONFIG_DIR="$HOME/.claude,$HOME/.config/claude" ccusage daily ``` ## Configuration by Feature ### Cost Calculation Control how costs are calculated: - **Mode**: `auto` (default), `calculate`, or `display` - **Offline**: Use cached pricing data - **Breakdown**: Show per-model costs ```bash ccusage daily --mode calculate --breakdown --offline ``` ### Date and Time Customize date/time handling: - **Timezone**: Any valid timezone (e.g., `UTC`, `America/New_York`) - **Locale**: Format preferences (e.g., `en-US`, `ja-JP`) - **Date Range**: Filter with `--since` and `--until` ```bash ccusage daily --timezone UTC --locale en-CA --since 20250101 ``` ### Output Format Control output presentation: - **JSON**: Machine-readable output with `--json` - **JQ Filtering**: Process JSON with `--jq` - **Debug**: Show detailed information with `--debug` ```bash ccusage daily --json --jq ".data[] | select(.cost > 10)" ``` ### Project Analysis Analyze usage by project: - **Instances**: Group by project with `--instances` - **Project Filter**: Focus on specific project with `--project` - **Aliases**: Set custom names via configuration file ```json // .ccusage/ccusage.json { "commands": { "daily": { "projectAliases": "uuid-123=My App,long-name=Backend" } } } ``` ```bash ccusage daily --instances --project "My App" ``` ## Debugging Configuration Use debug mode to understand configuration loading: ```bash # See which config files are loaded ccusage daily --debug # Check environment variables env | grep -E "CLAUDE|CCUSAGE|LOG_LEVEL" # Verbose logging LOG_LEVEL=5 ccusage daily ``` ### Debug Output Debug mode shows: - Config file discovery and loading - Option merging from different sources - Final configuration values - Pricing calculation details ## Best Practices ### 1. Layer Your Configuration Use different configuration methods for different scopes: - **Environment variables**: Machine-specific settings (paths) - **User config**: Personal preferences (timezone, locale) - **Project config**: Team standards (mode, formatting) - **CLI arguments**: One-off overrides ### 2. Use Configuration Files for Teams Share consistent settings across team members: ```bash # Commit to version control git add .ccusage/ccusage.json git commit -m "Add team ccusage configuration" ``` ### 3. Document Your Configuration Add comments or README files explaining configuration choices: ```markdown # ccusage Configuration Our team uses: - UTC timezone for consistency - JSON output for automated processing - Calculate mode for accurate cost tracking ``` ### 4. Validate Configuration Use the schema for validation: ```json { "$schema": "https://ccusage.com/config-schema.json" } ``` ### 5. Keep Secrets Secure Never put sensitive information in configuration files: - ❌ API keys or tokens - ❌ Personal identifiers - ✅ Timezone preferences - ✅ Output formats ## Migration Guide ### From v1 to v2 If upgrading from older versions: 1. Update directory paths (now supports `~/.config/claude`) 2. Migrate environment variables to config files 3. Update any scripts using old CLI options ### From Manual Commands Convert repeated commands to configuration: ```bash # Before: Repeated commands ccusage daily --breakdown --instances --timezone UTC # After: Configuration file { "defaults": { "breakdown": true, "timezone": "UTC" }, "commands": { "daily": { "instances": true } } } # Simplified command ccusage daily ``` ## Troubleshooting ### Common Issues 1. **Configuration not applied**: Check priority order 2. **Invalid JSON**: Validate syntax with `jq` 3. **Directory not found**: Verify `CLAUDE_CONFIG_DIR` 4. **No data**: Check directory permissions ### Getting Help If configuration issues persist: 1. Run with debug mode: `ccusage daily --debug` 2. Check verbose logs: `LOG_LEVEL=5 ccusage daily` 3. Validate JSON config: `jq . < ccusage.json` 4. Report issues on [GitHub](https://github.com/ryoppippi/ccusage/issues) ## Next Steps Explore specific configuration topics: - [Command-Line Options](/guide/cli-options) - All available CLI arguments - [Environment Variables](/guide/environment-variables) - System configuration - [Configuration Files](/guide/config-files) - Persistent settings - [Directory Detection](/guide/directory-detection) - Claude data discovery - [Cost Modes](/guide/cost-modes) - Understanding calculation modes - [Custom Paths](/guide/custom-paths) - Advanced path management ================================================ FILE: docs/guide/cost-modes.md ================================================ # Cost Modes ccusage supports three different cost calculation modes to handle various scenarios and data sources. Understanding these modes helps you get the most accurate cost estimates for your usage analysis. ## Overview Claude Code stores usage data in JSONL files with both token counts and pre-calculated cost information. ccusage can handle this data in different ways depending on your needs: - **`auto`** - Smart mode using the best available data - **`calculate`** - Always calculate from token counts - **`display`** - Only show pre-calculated costs ## Mode Details ### auto (Default) The `auto` mode intelligently chooses the best cost calculation method for each entry: ```bash ccusage daily --mode auto # or simply: ccusage daily ``` #### How it works: 1. **Pre-calculated costs available** → Uses Claude's `costUSD` values 2. **No pre-calculated costs** → Calculates from token counts using model pricing 3. **Mixed data** → Uses the best method for each entry #### Best for: - ✅ **General usage** - Works well for most scenarios - ✅ **Mixed data sets** - Handles old and new data properly - ✅ **Accuracy** - Uses official costs when available - ✅ **Completeness** - Shows estimates for all entries #### Example output: ``` ┌──────────────┬─────────────┬────────┬─────────┬────────────┐ │ Date │ Models │ Input │ Output │ Cost (USD) │ ├──────────────┼─────────────┼────────┼─────────┼────────────┤ │ 2025-01-15 │ • opus-4 │ 1,245 │ 28,756 │ $12.45 │ ← Pre-calculated │ 2024-12-20 │ • sonnet-4 │ 856 │ 19,234 │ $8.67 │ ← Calculated │ 2024-11-10 │ • opus-4 │ 634 │ 15,678 │ $7.23 │ ← Calculated └──────────────┴─────────────┴────────┴─────────┴────────────┘ ``` ### calculate The `calculate` mode always computes costs from token counts using model pricing: ```bash ccusage daily --mode calculate ccusage monthly --mode calculate --breakdown ``` #### How it works: 1. **Ignores `costUSD` values** from Claude Code data 2. **Uses token counts** (input, output, cache) for all entries 3. **Applies current model pricing** from LiteLLM database 4. **Consistent methodology** across all time periods #### Best for: - ✅ **Consistent comparisons** - Same calculation method for all data - ✅ **Token analysis** - Understanding pure token-based costs - ✅ **Historical analysis** - Comparing costs across different time periods - ✅ **Pricing research** - Analyzing cost per token trends #### Example output: ``` ┌──────────────┬─────────────┬────────┬─────────┬────────────┐ │ Date │ Models │ Input │ Output │ Cost (USD) │ ├──────────────┼─────────────┼────────┼─────────┼────────────┤ │ 2025-01-15 │ • opus-4 │ 1,245 │ 28,756 │ $12.38 │ ← Calculated │ 2024-12-20 │ • sonnet-4 │ 856 │ 19,234 │ $8.67 │ ← Calculated │ 2024-11-10 │ • opus-4 │ 634 │ 15,678 │ $7.23 │ ← Calculated └──────────────┴─────────────┴────────┴─────────┴────────────┘ ``` ### display The `display` mode only shows pre-calculated costs from Claude Code: ```bash ccusage daily --mode display ccusage session --mode display --json ``` #### How it works: 1. **Uses only `costUSD` values** from Claude Code data 2. **Shows $0.00** for entries without pre-calculated costs 3. **No token-based calculations** performed 4. **Exact Claude billing data** when available #### Best for: - ✅ **Official costs only** - Shows exactly what Claude calculated - ✅ **Billing verification** - Comparing with actual Claude charges - ✅ **Recent data** - Most accurate for newer usage entries - ✅ **Audit purposes** - Verifying pre-calculated costs #### Example output: ``` ┌──────────────┬─────────────┬────────┬─────────┬────────────┐ │ Date │ Models │ Input │ Output │ Cost (USD) │ ├──────────────┼─────────────┼────────┼─────────┼────────────┤ │ 2025-01-15 │ • opus-4 │ 1,245 │ 28,756 │ $12.45 │ ← Pre-calculated │ 2024-12-20 │ • sonnet-4 │ 856 │ 19,234 │ $0.00 │ ← No cost data │ 2024-11-10 │ • opus-4 │ 634 │ 15,678 │ $0.00 │ ← No cost data └──────────────┴─────────────┴────────┴─────────┴────────────┘ ``` ## Practical Examples ### Scenario 1: Mixed Data Analysis You have data from different time periods with varying cost information: ```bash # Auto mode handles mixed data intelligently ccusage daily --mode auto --since 20241201 # Shows: # - Pre-calculated costs for recent entries (Jan 2025) # - Calculated costs for older entries (Dec 2024) ``` ### Scenario 2: Consistent Cost Comparison You want to compare costs across different months using the same methodology: ```bash # Calculate mode ensures consistent methodology ccusage monthly --mode calculate --breakdown # All months use the same token-based calculation # Useful for trend analysis and cost projections ``` ### Scenario 3: Billing Verification You want to verify Claude's official cost calculations: ```bash # Display mode shows only official Claude costs ccusage daily --mode display --since 20250101 # Compare with your Claude billing dashboard # Entries without costs show $0.00 ``` ### Scenario 4: Historical Analysis Analyzing usage patterns over time: ```bash # Auto mode for complete picture ccusage daily --mode auto --since 20240101 --until 20241231 # Calculate mode for consistent comparison ccusage monthly --mode calculate --order asc ``` ## Cost Calculation Details ### Token-Based Calculation When calculating costs from tokens, ccusage uses: #### Model Pricing Sources - **LiteLLM database** - Up-to-date model pricing - **Automatic updates** - Pricing refreshed regularly - **Multiple models** - Supports Claude Opus, Sonnet, and other models #### Token Types ```typescript type TokenCosts = { input: number; // Input tokens output: number; // Output tokens cacheCreate: number; // Cache creation tokens cacheRead: number; // Cache read tokens }; ``` #### Calculation Formula ```typescript totalCost = inputTokens * inputPrice + outputTokens * outputPrice + cacheCreateTokens * cacheCreatePrice + cacheReadTokens * cacheReadPrice; ``` ### Pre-calculated Costs Claude Code provides `costUSD` values in JSONL files: ```json { "timestamp": "2025-01-15T10:30:00Z", "model": "claude-opus-4-20250514", "usage": { "input_tokens": 1245, "output_tokens": 28756, "cache_creation_input_tokens": 512, "cache_read_input_tokens": 256 }, "costUSD": 12.45 } ``` ## Debug Mode Use debug mode to understand cost calculation discrepancies: ```bash ccusage daily --mode auto --debug ``` Shows: - **Pricing mismatches** between calculated and pre-calculated costs - **Missing cost data** entries - **Calculation details** for each entry - **Sample discrepancies** for investigation ```bash # Show more sample discrepancies ccusage daily --debug --debug-samples 10 ``` ## Mode Selection Guide ### When to use `auto` mode: - **General usage** - Default for most scenarios - **Mixed data sets** - Combining old and new usage data - **Maximum accuracy** - Best available cost information - **Regular reporting** - Daily/monthly usage tracking ### When to use `calculate` mode: - **Consistent analysis** - Comparing different time periods - **Token cost research** - Understanding pure token costs - **Pricing validation** - Verifying calculated vs actual costs - **Historical comparison** - Analyzing cost trends over time ### When to use `display` mode: - **Billing verification** - Comparing with Claude charges - **Official costs only** - Trusting Claude's calculations - **Recent data analysis** - Most accurate for new usage - **Audit purposes** - Verifying pre-calculated costs ## Advanced Usage ### Combining with Other Options ```bash # Calculate mode with breakdown by model ccusage daily --mode calculate --breakdown # Display mode with JSON output for analysis ccusage session --mode display --json | jq '.[] | select(.totalCost > 0)' # Auto mode with date filtering ccusage monthly --mode auto --since 20240101 --order asc ``` ### Performance Considerations - **`display` mode** - Fastest (no calculations) - **`auto` mode** - Moderate (conditional calculations) - **`calculate` mode** - Slowest (always calculates) ### Offline Mode Compatibility ```bash # All modes work with offline pricing data ccusage daily --mode calculate --offline ccusage monthly --mode auto --offline ``` ## Common Issues and Solutions ### Issue: Costs showing as $0.00 **Cause**: Using `display` mode with data that lacks pre-calculated costs **Solution**: ```bash # Switch to auto or calculate mode ccusage daily --mode auto ccusage daily --mode calculate ``` ### Issue: Inconsistent cost calculations **Cause**: Mixed use of different modes or pricing changes **Solution**: ```bash # Use calculate mode for consistency ccusage daily --mode calculate --since 20240101 ``` ### Issue: Large discrepancies in debug mode **Cause**: Pricing updates or model changes **Solution**: ```bash # Check for pricing updates ccusage daily --mode auto # Updates pricing cache ccusage daily --mode calculate --debug # Compare calculations ``` ### Issue: Missing cost data for recent entries **Cause**: Claude Code hasn't calculated costs yet **Solution**: ```bash # Use calculate mode as fallback ccusage daily --mode calculate ``` ## Next Steps After understanding cost modes: - Explore [Configuration](/guide/configuration) for environment setup - Learn about [Custom Paths](/guide/custom-paths) for multiple data sources - Try [Live Monitoring](/guide/live-monitoring) with different cost modes ================================================ FILE: docs/guide/custom-paths.md ================================================ # Custom Paths ccusage supports flexible path configuration to handle various Claude Code installation scenarios and custom data locations. ## Overview By default, ccusage automatically detects Claude Code data in standard locations. However, you can customize these paths for: - **Multiple Claude installations** - Different versions or profiles - **Custom data locations** - Non-standard installation directories - **Shared environments** - Team or organization setups - **Backup/archive analysis** - Analyzing historical data from different locations ## CLAUDE_CONFIG_DIR Environment Variable The primary method for specifying custom paths is the `CLAUDE_CONFIG_DIR` environment variable. ### Single Custom Path Specify one custom directory: ```bash # Set environment variable export CLAUDE_CONFIG_DIR="/path/to/your/claude/data" # Use with any command ccusage daily ccusage monthly --breakdown ccusage blocks --live ``` Example scenarios: ```bash # Custom installation location export CLAUDE_CONFIG_DIR="/opt/claude-code/.claude" # User-specific directory export CLAUDE_CONFIG_DIR="/home/username/Documents/claude-data" # Network drive export CLAUDE_CONFIG_DIR="/mnt/shared/claude-usage" ``` ### Multiple Custom Paths Specify multiple directories separated by commas: ```bash # Multiple installations export CLAUDE_CONFIG_DIR="/path/to/claude1,/path/to/claude2" # Current and archived data export CLAUDE_CONFIG_DIR="~/.claude,/backup/claude-archive" # Team member data aggregation export CLAUDE_CONFIG_DIR="/team/alice/.claude,/team/bob/.claude,/team/charlie/.claude" ``` When multiple paths are specified: - ✅ **Data aggregation** - Usage from all paths is automatically combined - ✅ **Automatic filtering** - Invalid or empty directories are silently skipped - ✅ **Consistent reporting** - All reports show unified data across paths ## Default Path Detection ### Standard Locations When `CLAUDE_CONFIG_DIR` is not set, ccusage searches these locations automatically: 1. **`~/.config/claude/projects/`** - New default (Claude Code v1.0.30+) 2. **`~/.claude/projects/`** - Legacy location (pre-v1.0.30) ### Version Compatibility ::: info Breaking Change Claude Code v1.0.30 moved data from `~/.claude` to `~/.config/claude` without documentation. ccusage handles both locations automatically for seamless compatibility. ::: #### Migration Scenarios **Scenario 1: Fresh Installation** ```bash # Claude Code v1.0.30+ - uses new location ls ~/.config/claude/projects/ # ccusage automatically finds data ccusage daily ``` **Scenario 2: Upgraded Installation** ```bash # Old data still exists ls ~/.claude/projects/ # New data in new location ls ~/.config/claude/projects/ # ccusage combines both automatically ccusage daily # Shows data from both locations ``` **Scenario 3: Manual Migration** ```bash # If you moved data manually export CLAUDE_CONFIG_DIR="/custom/location/claude" ccusage daily ``` ## Path Structure Requirements ### Expected Directory Structure ccusage expects this directory structure: ``` claude-data-directory/ ├── projects/ │ ├── project-1/ │ │ ├── session-1/ │ │ │ ├── file1.jsonl │ │ │ └── file2.jsonl │ │ └── session-2/ │ │ └── file3.jsonl │ └── project-2/ │ └── session-3/ │ └── file4.jsonl ``` ### Validation ccusage validates paths by checking: - **Directory exists** and is readable - **Contains `projects/` subdirectory** - **Has JSONL files** in the expected structure Invalid paths are automatically skipped with debug information available. ## Common Use Cases ### Multiple Claude Profiles If you use multiple Claude profiles or installations: ```bash # Work profile export CLAUDE_CONFIG_DIR="/Users/username/.config/claude-work" # Personal profile export CLAUDE_CONFIG_DIR="/Users/username/.config/claude-personal" # Combined analysis export CLAUDE_CONFIG_DIR="/Users/username/.config/claude-work,/Users/username/.config/claude-personal" ``` ### Team Environments For team usage analysis: ```bash # Individual analysis export CLAUDE_CONFIG_DIR="/shared/claude-data/$USER" ccusage daily # Team aggregate export CLAUDE_CONFIG_DIR="/shared/claude-data/alice,/shared/claude-data/bob" ccusage monthly --breakdown ``` ### Development vs Production Separate environments: ```bash # Development environment export CLAUDE_CONFIG_DIR="/dev/claude-data" ccusage daily --since 20250101 # Production environment export CLAUDE_CONFIG_DIR="/prod/claude-data" ccusage daily --since 20250101 ``` ### Historical Analysis Analyzing archived or backup data: ```bash # Current month export CLAUDE_CONFIG_DIR="~/.config/claude" ccusage monthly # Compare with previous month backup export CLAUDE_CONFIG_DIR="/backup/claude-2024-12" ccusage monthly --since 20241201 --until 20241231 # Combined analysis export CLAUDE_CONFIG_DIR="~/.config/claude,/backup/claude-2024-12" ccusage monthly --since 20241201 ``` ## Shell Integration ### Setting Persistent Environment Variables #### Bash/Zsh Add to `~/.bashrc`, `~/.zshrc`, or `~/.profile`: ```bash # Default Claude data directory export CLAUDE_CONFIG_DIR="$HOME/.config/claude" # Or multiple directories export CLAUDE_CONFIG_DIR="$HOME/.config/claude,$HOME/.claude" ``` #### Fish Shell Add to `~/.config/fish/config.fish`: ```fish # Default Claude data directory set -gx CLAUDE_CONFIG_DIR "$HOME/.config/claude" # Or multiple directories set -gx CLAUDE_CONFIG_DIR "$HOME/.config/claude,$HOME/.claude" ``` ### Temporary Path Override For one-time analysis without changing environment: ```bash # Temporary override for single command CLAUDE_CONFIG_DIR="/tmp/claude-backup" ccusage daily # Multiple commands with temporary override ( export CLAUDE_CONFIG_DIR="/archive/claude-2024" ccusage daily --json > 2024-report.json ccusage monthly --breakdown > 2024-monthly.txt ) ``` ### Aliases and Functions Create convenient aliases: ```bash # ~/.bashrc or ~/.zshrc alias ccu-work="CLAUDE_CONFIG_DIR='/work/claude' ccusage" alias ccu-personal="CLAUDE_CONFIG_DIR='/personal/claude' ccusage" alias ccu-archive="CLAUDE_CONFIG_DIR='/archive/claude' ccusage" # Usage ccu-work daily ccu-personal monthly --breakdown ccu-archive session --since 20240101 ``` Or use functions for more complex setups: ```bash # Function to analyze specific time periods ccu-period() { local period=$1 local path="/archive/claude-$period" if [[ -d "$path" ]]; then CLAUDE_CONFIG_DIR="$path" ccusage daily --since "${period}01" --until "${period}31" else echo "Archive not found: $path" fi } # Usage ccu-period 202412 # December 2024 ccu-period 202501 # January 2025 ``` ## MCP Integration with Custom Paths When using the standalone MCP CLI with custom paths: ### Claude Desktop Configuration ```json { "mcpServers": { "ccusage": { "command": "npx", "args": ["@ccusage/mcp@latest"], "env": { "CLAUDE_CONFIG_DIR": "/path/to/your/claude/data" } }, "ccusage-archive": { "command": "npx", "args": ["@ccusage/mcp@latest"], "env": { "CLAUDE_CONFIG_DIR": "/archive/claude-2024,/archive/claude-2025" } } } } ``` This allows you to have multiple MCP servers analyzing different data sets. ## Troubleshooting Custom Paths ### Path Validation Check if your custom path is valid: ```bash # Test path manually ls -la "$CLAUDE_CONFIG_DIR/projects/" # Run with debug output ccusage daily --debug ``` ### Common Issues #### Path Not Found ```bash # Error: Directory doesn't exist export CLAUDE_CONFIG_DIR="/nonexistent/path" ccusage daily # Result: No data found # Solution: Verify path exists ls -la /nonexistent/path ``` #### Permission Issues ```bash # Error: Permission denied export CLAUDE_CONFIG_DIR="/root/.claude" ccusage daily # May fail if no read permission # Solution: Check permissions ls -la /root/.claude ``` #### Multiple Paths Syntax ```bash # Wrong: Using semicolon or space export CLAUDE_CONFIG_DIR="/path1;/path2" # ❌ export CLAUDE_CONFIG_DIR="/path1 /path2" # ❌ # Correct: Using comma export CLAUDE_CONFIG_DIR="/path1,/path2" # ✅ ``` #### Data Structure Issues ```bash # Wrong structure /custom/claude/ ├── file1.jsonl # ❌ Files in wrong location └── data/ └── file2.jsonl # Correct structure /custom/claude/ └── projects/ └── project1/ └── session1/ └── file1.jsonl ``` ### Debug Mode Use debug mode to troubleshoot path issues: ```bash ccusage daily --debug # Shows: # - Which paths are being searched # - Which paths are valid/invalid # - How many files are found in each path # - Any permission or structure issues ``` ## Performance Considerations ### Large Data Sets When using multiple paths with large data sets: ```bash # Filter by date to improve performance ccusage daily --since 20250101 --until 20250131 # Use JSON output for programmatic processing ccusage daily --json | jq '.[] | select(.totalCost > 10)' ``` ### Network Paths For network-mounted directories: ```bash # Ensure network path is mounted mount | grep claude-data # Consider local caching for frequently accessed data rsync -av /network/claude-data/ /local/cache/claude-data/ export CLAUDE_CONFIG_DIR="/local/cache/claude-data" ``` ## Next Steps After setting up custom paths: - Learn about [Configuration](/guide/configuration) for additional options - Explore [Cost Modes](/guide/cost-modes) for different calculation methods - Set up [Live Monitoring](/guide/live-monitoring) with your custom data ================================================ FILE: docs/guide/daily-reports.md ================================================ # Daily Reports ![Daily usage report showing token usage and costs by date with model breakdown](/screenshot.png) Daily reports show token usage and costs aggregated by calendar date, giving you a clear view of your Claude Code usage patterns over time. ## Basic Usage Show all daily usage: ```bash ccusage daily # or simply: ccusage ``` The daily command is the default, so you can omit it when running ccusage. ## Example Output ![Daily usage report showing token usage and costs by date with model breakdown](/screenshot.png) ## Understanding the Columns ### Basic Columns - **Date**: Calendar date in YYYY-MM-DD format - **Models**: Claude models used that day (shown as bulleted list) - **Input**: Total input tokens sent to Claude - **Output**: Total output tokens received from Claude - **Cost (USD)**: Estimated cost for that day ### Cache Columns - **Cache Create**: Tokens used to create cache entries - **Cache Read**: Tokens read from cache (typically cheaper) ### Responsive Display ccusage automatically adapts to your terminal width: - **Wide terminals (≥100 chars)**: Shows all columns - **Narrow terminals (<100 chars)**: Compact mode with essential columns only ## Command Options ### Date Filtering Filter reports by date range: ```bash # Show usage from December 2024 ccusage daily --since 20241201 --until 20241231 # Show last week ccusage daily --since 20241215 --until 20241222 # Show usage since a specific date ccusage daily --since 20241201 ``` ### Sort Order Control the order of dates: ```bash # Newest dates first (default) ccusage daily --order desc # Oldest dates first ccusage daily --order asc ``` ### Cost Calculation Modes Control how costs are calculated: ```bash # Use pre-calculated costs when available (default) ccusage daily --mode auto # Always calculate costs from tokens ccusage daily --mode calculate # Only show pre-calculated costs ccusage daily --mode display ``` ### Model Breakdown See per-model cost breakdown: ```bash ccusage daily --breakdown ``` This shows costs split by individual models: ``` ┌──────────────┬──────────────────┬────────┬─────────┬────────────┐ │ Date │ Models │ Input │ Output │ Cost (USD) │ ├──────────────┼──────────────────┼────────┼─────────┼────────────┤ │ 2025-06-21 │ opus-4, sonnet-4 │ 277 │ 31,456 │ $17.58 │ ├──────────────┼──────────────────┼────────┼─────────┼────────────┤ │ └─ opus-4 │ │ 100 │ 15,000 │ $10.25 │ ├──────────────┼──────────────────┼────────┼─────────┼────────────┤ │ └─ sonnet-4│ │ 177 │ 16,456 │ $7.33 │ └──────────────┴──────────────────┴────────┴─────────┴────────────┘ ``` ### JSON Output Export data as JSON for further analysis: ```bash ccusage daily --json ``` ```json { "type": "daily", "data": [ { "date": "2025-06-21", "models": ["claude-opus-4-20250514", "claude-sonnet-4-20250514"], "inputTokens": 277, "outputTokens": 31456, "cacheCreationTokens": 512, "cacheReadTokens": 1024, "totalTokens": 33269, "costUSD": 17.58 } ], "summary": { "totalInputTokens": 277, "totalOutputTokens": 31456, "totalCacheCreationTokens": 512, "totalCacheReadTokens": 1024, "totalTokens": 33269, "totalCostUSD": 17.58 } } ``` ### Offline Mode Use cached pricing data without network access: ```bash ccusage daily --offline # or short form: ccusage daily -O ``` ### Project Analysis Group usage by project instead of aggregating across all projects: ```bash # Group daily usage by project ccusage daily --instances ccusage daily -i ``` When using `--instances`, the report shows usage for each project separately: ``` ┌──────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ Project: my-project │ ├──────────────┬──────────────────┬────────┬─────────┬────────────┬────────────┬─────────────┬──────────┤ │ Date │ Models │ Input │ Output │ Cache Create│ Cache Read │ Total Tokens│ Cost (USD)│ ├──────────────┼──────────────────┼────────┼─────────┼────────────┼────────────┼─────────────┼──────────┤ │ 2025-06-21 │ • sonnet-4 │ 277 │ 31,456 │ 512│ 1,024 │ 33,269 │ $7.33│ └──────────────┴──────────────────┴────────┴─────────┴────────────┴────────────┴─────────────┴──────────┘ ┌──────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ Project: other-project │ ├──────────────┬──────────────────┬────────┬─────────┬────────────┬────────────┬─────────────┬──────────┤ │ Date │ Models │ Input │ Output │ Cache Create│ Cache Read │ Total Tokens│ Cost (USD)│ ├──────────────┼──────────────────┼────────┼─────────┼────────────┼────────────┼─────────────┼──────────┤ │ 2025-06-21 │ • opus-4 │ 100 │ 15,000 │ 256│ 512 │ 15,868 │ $10.25│ └──────────────┴──────────────────┴────────┴─────────┴────────────┴────────────┴─────────────┴──────────┘ ``` Filter to a specific project: ```bash # Show only usage from "my-project" ccusage daily --project my-project ccusage daily -p my-project # Combine with instances flag ccusage daily --instances --project my-project ``` ## Common Use Cases ### Track Monthly Spending ```bash # See December 2024 usage ccusage daily --since 20241201 --until 20241231 ``` ### Find Expensive Days ```bash # Sort by cost (highest first) ccusage daily --order desc ``` ### Export for Spreadsheet Analysis ```bash ccusage daily --json > december-usage.json ``` ### Compare Model Usage ```bash # See which models you use most ccusage daily --breakdown ``` ### Check Recent Activity ```bash # Last 7 days ccusage daily --since $(date -d '7 days ago' +%Y%m%d) ``` ### Analyze Project Usage ```bash # See usage breakdown by project ccusage daily --instances # Track specific project costs ccusage daily --project my-important-project --since 20250601 # Compare project usage with JSON export ccusage daily --instances --json > project-analysis.json ``` ### Team Usage Analysis Use project aliases to replace cryptic or long project directory names with readable labels: ```json // .ccusage/ccusage.json - Set custom project names for better reporting { "commands": { "daily": { "projectAliases": "uuid-project=Frontend App,long-name=Backend API" } } } ``` The `projectAliases` setting uses a comma-separated format of `original-name=display-name` pairs. This is especially useful when: - Your projects have UUID-based names (e.g., `a2cd99ed-a586=My App`) - Directory names are long paths that get truncated - You want consistent naming across team reports ```bash # Generate team report with readable project names ccusage daily --instances --since 20250601 # Now shows "Frontend App" instead of "uuid-project" ``` ## Tips 1. **Compact Mode**: If your terminal is narrow, expand it to see all columns 2. **Date Format**: Use YYYYMMDD format for date filters (e.g., 20241225) 3. **Regular Monitoring**: Run daily reports regularly to track usage patterns 4. **JSON Export**: Use `--json` for creating charts or additional analysis ## Related Commands - [Monthly Reports](/guide/monthly-reports) - Aggregate by month - [Session Reports](/guide/session-reports) - Per-conversation analysis - [Blocks Reports](/guide/blocks-reports) - 5-hour billing windows - [Live Monitoring](/guide/live-monitoring) - Real-time tracking ================================================ FILE: docs/guide/directory-detection.md ================================================ # Directory Detection ccusage automatically detects and manages Claude Code data directories. ## Default Directory Locations ccusage automatically searches for Claude Code data in these locations: - **`~/.config/claude/projects/`** - New default location (Claude Code v1.0.30+) - **`~/.claude/projects/`** - Legacy location (pre-v1.0.30) When no custom directory is specified, ccusage searches both locations and aggregates data from all valid directories found. ::: info Breaking Change The directory change from `~/.claude` to `~/.config/claude` in Claude Code v1.0.30 was an undocumented breaking change. ccusage handles both locations automatically to ensure backward compatibility. ::: ## Search Priority When `CLAUDE_CONFIG_DIR` environment variable is not set, ccusage searches in this order: 1. **Primary**: `~/.config/claude/projects/` (preferred for newer installations) 2. **Fallback**: `~/.claude/projects/` (for legacy installations) Data from all valid directories is automatically combined. ## Custom Directory Configuration ### Single Custom Directory Override the default search with a specific directory: ```bash export CLAUDE_CONFIG_DIR="/custom/path/to/claude" ccusage daily ``` ### Multiple Directories Aggregate data from multiple Claude installations: ```bash export CLAUDE_CONFIG_DIR="/path/to/claude1,/path/to/claude2" ccusage daily ``` ## Directory Structure Claude Code stores usage data in a specific structure: ``` ~/.config/claude/projects/ ├── project-name-1/ │ ├── session-id-1.jsonl │ ├── session-id-2.jsonl │ └── session-id-3.jsonl ├── project-name-2/ │ └── session-id-4.jsonl └── project-name-3/ └── session-id-5.jsonl ``` Each: - **Project directory** represents a different Claude Code project/workspace - **JSONL file** contains usage data for a specific session - **Session ID** in the filename matches the `sessionId` field within the file ## Troubleshooting ### No Data Found If ccusage reports no data found: ```bash # Check if directories exist ls -la ~/.claude/projects/ ls -la ~/.config/claude/projects/ # Verify environment variable echo $CLAUDE_CONFIG_DIR # Test with explicit directory export CLAUDE_CONFIG_DIR="/path/to/claude" ccusage daily ``` ### Permission Errors ```bash # Check directory permissions ls -la ~/.claude/ ls -la ~/.config/claude/ # Fix permissions if needed chmod -R 755 ~/.claude/ chmod -R 755 ~/.config/claude/ ``` ### Wrong Directory Detection ```bash # Force specific directory export CLAUDE_CONFIG_DIR="/exact/path/to/claude" ccusage daily # Verify which directory is being used LOG_LEVEL=4 ccusage daily ``` ## Related Documentation - [Environment Variables](/guide/environment-variables) - Configure with CLAUDE_CONFIG_DIR - [Custom Paths](/guide/custom-paths) - Advanced path management - [Configuration Overview](/guide/configuration) - Complete configuration guide ================================================ FILE: docs/guide/environment-variables.md ================================================ # Environment Variables ccusage supports several environment variables for configuration and customization. Environment variables provide a way to configure ccusage without modifying command-line arguments or configuration files. ## CLAUDE_CONFIG_DIR Specifies where ccusage should look for Claude Code data. This is the most important environment variable for ccusage. ### Single Directory Set a single custom Claude data directory: ```bash export CLAUDE_CONFIG_DIR="/path/to/your/claude/data" ccusage daily ``` ### Multiple Directories Set multiple directories (comma-separated) to aggregate data from multiple sources: ```bash export CLAUDE_CONFIG_DIR="/path/to/claude1,/path/to/claude2" ccusage daily ``` When multiple directories are specified, ccusage automatically aggregates usage data from all valid locations. ### Default Behavior When `CLAUDE_CONFIG_DIR` is not set, ccusage automatically searches in: 1. `~/.config/claude/projects/` (new default, Claude Code v1.0.30+) 2. `~/.claude/projects/` (legacy location, pre-v1.0.30) Data from all valid directories is automatically combined. ::: info Directory Change The directory change from `~/.claude` to `~/.config/claude` in Claude Code v1.0.30 was an undocumented breaking change. ccusage handles both locations automatically for backward compatibility. ::: ### Use Cases #### Development Environment ```bash # Set in your shell profile (.bashrc, .zshrc, config.fish) export CLAUDE_CONFIG_DIR="$HOME/.config/claude" ``` #### Multiple Claude Installations ```bash # Aggregate data from different Claude installations export CLAUDE_CONFIG_DIR="$HOME/.claude,$HOME/.config/claude" ``` #### Team Shared Directory ```bash # Use team-shared data directory export CLAUDE_CONFIG_DIR="/team-shared/claude-data/$USER" ``` #### CI/CD Environment ```bash # Use specific directory in CI pipeline export CLAUDE_CONFIG_DIR="/ci-data/claude-logs" ccusage daily --json > usage-report.json ``` ## LOG_LEVEL Controls the verbosity of log output. ccusage uses [consola](https://github.com/unjs/consola) for logging under the hood. ### Log Levels | Level | Value | Description | Use Case | | ------ | ----- | ---------------------------- | ---------------------- | | Silent | `0` | Errors only | Scripts, piping output | | Warn | `1` | Warnings and errors | CI/CD environments | | Log | `2` | Normal logs | General use | | Info | `3` | Informational logs (default) | Standard operation | | Debug | `4` | Debug information | Troubleshooting | | Trace | `5` | All operations | Deep debugging | ### Usage Examples ```bash # Silent mode - only show results LOG_LEVEL=0 ccusage daily # Warning level - for CI/CD LOG_LEVEL=1 ccusage monthly # Debug mode - troubleshooting LOG_LEVEL=4 ccusage session # Trace everything - deep debugging LOG_LEVEL=5 ccusage blocks ``` ### Practical Applications #### Clean Output for Scripts ```bash # Get clean JSON output without logs LOG_LEVEL=0 ccusage daily --json | jq '.summary.totalCost' ``` #### CI/CD Pipeline ```bash # Show only warnings and errors in CI LOG_LEVEL=1 ccusage daily --instances ``` #### Debugging Issues ```bash # Maximum verbosity for troubleshooting LOG_LEVEL=5 ccusage daily --debug ``` #### Piping Output ```bash # Silent logs when piping to other commands LOG_LEVEL=0 ccusage monthly --json | python analyze.py ``` ## Additional Environment Variables ### CCUSAGE_OFFLINE Force offline mode by default: ```bash export CCUSAGE_OFFLINE=1 ccusage daily # Runs in offline mode ``` ### NO_COLOR Disable colored output (standard CLI convention): ```bash export NO_COLOR=1 ccusage daily # No color formatting ``` ### FORCE_COLOR Force colored output even when piping: ```bash export FORCE_COLOR=1 ccusage daily | less -R # Preserves colors ``` ## Setting Environment Variables ### Temporary (Current Session) ```bash # Set for single command LOG_LEVEL=0 ccusage daily # Set for current shell session export CLAUDE_CONFIG_DIR="/custom/path" ccusage daily ``` ### Permanent (Shell Profile) Add to your shell configuration file: #### Bash (~/.bashrc) ```bash export CLAUDE_CONFIG_DIR="$HOME/.config/claude" export LOG_LEVEL=3 ``` #### Zsh (~/.zshrc) ```zsh export CLAUDE_CONFIG_DIR="$HOME/.config/claude" export LOG_LEVEL=3 ``` #### Fish (~/.config/fish/config.fish) ```fish set -x CLAUDE_CONFIG_DIR "$HOME/.config/claude" set -x LOG_LEVEL 3 ``` #### PowerShell (Profile.ps1) ```powershell $env:CLAUDE_CONFIG_DIR = "$env:USERPROFILE\.config\claude" $env:LOG_LEVEL = "3" ``` ## Precedence Environment variables have lower precedence than command-line arguments but higher than configuration files: 1. **Command-line arguments** (highest priority) 2. **Environment variables** 3. **Configuration files** 4. **Built-in defaults** (lowest priority) Example: ```bash # Environment variable sets offline mode export CCUSAGE_OFFLINE=1 # But command-line argument overrides it ccusage daily --no-offline # Runs in online mode ``` ## Debugging To see which environment variables are being used: ```bash # Show all environment variables env | grep -E "CLAUDE|CCUSAGE|LOG_LEVEL" # Debug mode shows environment variable usage LOG_LEVEL=4 ccusage daily --debug ``` ## Related Documentation - [Command-Line Options](/guide/cli-options) - CLI arguments and flags - [Configuration Files](/guide/config-files) - JSON configuration files - [Configuration Overview](/guide/configuration) - Complete configuration guide ================================================ FILE: docs/guide/getting-started.md ================================================ # Getting Started Welcome to ccusage! This guide will help you get up and running with analyzing your Claude Code usage data. ## Prerequisites - Claude Code installed and used (generates JSONL files) - Node.js 20+ or Bun runtime ## Quick Start The fastest way to try ccusage is to run it directly without installation: ::: code-group ```bash [npx] npx ccusage@latest ``` ```bash [bunx] bunx ccusage ``` ```bash [pnpm] pnpm dlx ccusage ``` ```bash [claude x] BUN_BE_BUN=1 claude x ccusage ``` ::: This will show your daily usage report by default. ## Your First Report When you run ccusage for the first time, you'll see a table showing your Claude Code usage by date: ``` ╭──────────────────────────────────────────╮ │ │ │ Claude Code Token Usage Report - Daily │ │ │ ╰──────────────────────────────────────────╯ ┌──────────────┬──────────────────┬────────┬─────────┬────────────┐ │ Date │ Models │ Input │ Output │ Cost (USD) │ ├──────────────┼──────────────────┼────────┼─────────┼────────────┤ │ 2025-06-21 │ • sonnet-4 │ 1,234 │ 15,678 │ $12.34 │ │ 2025-06-20 │ • opus-4 │ 890 │ 12,345 │ $18.92 │ └──────────────┴──────────────────┴────────┴─────────┴────────────┘ ``` ## Understanding the Output ### Columns Explained - **Date**: The date when Claude Code was used - **Models**: Which Claude models were used (Sonnet, Opus, etc.) - **Input**: Number of input tokens sent to Claude - **Output**: Number of output tokens received from Claude - **Cost (USD)**: Estimated cost based on model pricing ### Cache Tokens If you have a wide terminal, you'll also see cache token columns: - **Cache Create**: Tokens used to create cache entries - **Cache Read**: Tokens read from cache (typically cheaper) ## Next Steps Now that you have your first report, explore these features: 1. **[Weekly Reports](/guide/weekly-reports)** - Track usage patterns by week 2. **[Monthly Reports](/guide/monthly-reports)** - See usage aggregated by month 3. **[Session Reports](/guide/session-reports)** - Analyze individual conversations 4. **[Statusline](/guide/statusline)** - Real-time usage display for Claude Code status bar 5. **[Configuration](/guide/configuration)** - Customize ccusage behavior ## Common Use Cases ### Monitor Daily Usage ```bash ccusage daily --since 20241201 --until 20241231 ``` ### Analyze Sessions ```bash ccusage session ``` ### Export for Analysis ```bash ccusage monthly --json > usage-data.json ``` ### Real-time Status Display Add statusline to your Claude Code settings: ```bash # Using jq to add statusline configuration jq '.statusLine = {"type": "command", "command": "bun x ccusage statusline", "padding": 0}' \ ~/.config/claude/settings.json > tmp.json && mv tmp.json ~/.config/claude/settings.json ``` ## Colors ccusage automatically colors the output based on the terminal's capabilities. If you want to disable colors, you can use the `--no-color` flag. Or you can use the `--color` flag to force colors on. ## Automatic Table Adjustment ccusage automatically adjusts its table layout based on terminal width: - **Wide terminals (≥100 characters)**: Full table with all columns including cache metrics, model names, and detailed breakdowns - **Narrow terminals (<100 characters)**: Compact view with essential columns only (Date, Models, Input, Output, Cost) The layout adjusts automatically based on your terminal width - no configuration needed. If you're in compact mode and want to see the full data, simply expand your terminal window. ## Troubleshooting ### No Data Found If ccusage shows no data, check: 1. **Claude Code is installed and used** - ccusage reads from Claude Code's data files 2. **Data directory exists** - Default locations: - `~/.config/claude/projects/` (new default) - `~/.claude/projects/` (legacy) ### Custom Data Directory If your Claude data is in a custom location: ```bash export CLAUDE_CONFIG_DIR="/path/to/your/claude/data" ccusage daily ``` ## Getting Help - Use `ccusage --help` for command options - Visit our [GitHub repository](https://github.com/ryoppippi/ccusage) for issues - Check the [API Reference](/api/) for programmatic usage ================================================ FILE: docs/guide/index.md ================================================ # Introduction ![ccusage daily report showing token usage and costs by date](/screenshot.png) **ccusage** (claude-code-usage) is a powerful CLI tool that analyzes your Claude Code usage from local JSONL files to help you understand your token consumption patterns and estimated costs. ## The Problem Claude Code's Max plan offers unlimited usage, which is fantastic! But many users are curious: - How much am I actually using Claude Code? - Which conversations are the most expensive? - What would I be paying on a pay-per-use plan? - Am I getting good value from my subscription? ## The Solution ccusage analyzes the local JSONL files that Claude Code automatically generates and provides: - **Detailed Usage Reports** - Daily, weekly, monthly, and session-based breakdowns - **Cost Analysis** - Estimated costs based on token usage and model pricing - **Statusline Integration** - Real-time usage display for Claude Code status bar - **Multiple Formats** - Beautiful tables or JSON for further analysis ## How It Works 1. **Claude Code generates JSONL files** containing usage data 2. **ccusage reads these files** from your local machine 3. **Analyzes and aggregates** the data by date, session, or time blocks 4. **Calculates estimated costs** using model pricing information 5. **Presents results** in beautiful tables or JSON format ## Key Features ### 🚀 Ultra-Small Bundle Size Unlike other CLI tools, we pay extreme attention to bundle size. ccusage achieves an incredibly small footprint even without minification, which means you can run it directly without installation using `bunx ccusage` or `BUN_BE_BUN=1 claude x ccusage` for instant access. ### 📊 Multiple Report Types - **Daily Reports** - Usage aggregated by calendar date - **Weekly Reports** - Usage aggregated by week with configurable start day - **Monthly Reports** - Monthly summaries with trends - **Session Reports** - Per-conversation analysis - **Blocks Reports** - 5-hour billing window tracking ### 💰 Cost Analysis - Estimated costs based on token counts and model pricing - Support for different cost calculation modes - Model-specific pricing (Opus vs Sonnet vs other models) - Cache token cost calculation ### 📈 Statusline Integration - Compact real-time usage display for Claude Code status bar hooks - Session cost, daily cost, and block cost tracking - Burn rate calculations with visual indicators - Context usage percentage with color-coded alerts ### 🔧 Flexible Configuration - **JSON Configuration Files** - Set defaults for all commands or customize per-command - **IDE Support** - JSON Schema for autocomplete and validation - **Priority-based Settings** - CLI args > local config > user config > defaults - **Multiple Claude Data Directories** - Automatic detection and aggregation - **Environment Variables** - Traditional configuration options - **Custom Date Filtering** - Flexible time range selection and sorting - **Offline Mode** - Cached pricing data for air-gapped environments ## Data Sources ccusage reads from Claude Code's local data directories: - **New location**: `~/.config/claude/projects/` (Claude Code v1.0.30+) - **Legacy location**: `~/.claude/projects/` (pre-v1.0.30) The tool automatically detects and aggregates data from both locations for compatibility. ## Privacy & Security - **100% Local** - All analysis happens on your machine - **No Data Transmission** - Your usage data never leaves your computer - **Read-Only** - ccusage only reads files, never modifies them - **Open Source** - Full transparency in how your data is processed ## Limitations ::: warning Important Limitations - **Local Files Only** - Only analyzes data from your current machine - **Language Model Tokens** - API calls for tools like Web Search are not included - **Estimate Accuracy** - Costs are estimates and may not reflect actual billing ::: ## Acknowledgments Thanks to [@milliondev](https://note.com/milliondev) for the [original concept and approach](https://note.com/milliondev/n/n1d018da2d769) to Claude Code usage analysis. ## Getting Started Ready to analyze your Claude Code usage? Check out our [Getting Started Guide](/guide/getting-started) to begin exploring your data! ================================================ FILE: docs/guide/installation.md ================================================ # Installation ccusage can be installed and used in several ways depending on your preferences and use case. ## Why No Installation Needed? Thanks to ccusage's incredibly small bundle size, you don't need to install it globally. Unlike other CLI tools, we pay extreme attention to bundle size optimization, achieving an impressively small footprint even without minification. This means: - ✅ Near-instant startup times - ✅ Minimal download overhead - ✅ Always use the latest version - ✅ No global pollution of your system ## Quick Start (Recommended) The fastest way to use ccusage is to run it directly: ::: code-group ```bash [bunx (Recommended)] bunx ccusage ``` ```bash [npx] npx ccusage@latest ``` ```bash [pnpm] pnpm dlx ccusage ``` ```bash [deno] deno run -E -R=$HOME/.claude/projects/ -S=homedir -N='raw.githubusercontent.com:443' npm:ccusage@latest ``` ::: ::: tip Speed Recommendation We strongly recommend using `bunx` instead of `npx` due to the massive speed difference. Bunx caches packages more efficiently, resulting in near-instant startup times after the first run. ::: ::: info Deno Security Consider using `deno run` if you want additional security controls. Deno allows you to specify exact permissions, making it safer to run tools you haven't audited. ::: ### Performance Comparison Here's why runtime choice matters: | Runtime | First Run | Subsequent Runs | Notes | | -------- | --------- | --------------- | ------------------- | | bunx | Fast | **Instant** | Best overall choice | | npx | Slow | Moderate | Widely available | | pnpm dlx | Fast | Fast | Good alternative | | deno | Moderate | Fast | Best for security | ## Global Installation (Optional) While not necessary due to our small bundle size, you can still install ccusage globally if you prefer: ::: code-group ```bash [npm] npm install -g ccusage ``` ```bash [bun] bun install -g ccusage ``` ```bash [yarn] yarn global add ccusage ``` ```bash [pnpm] pnpm add -g ccusage ``` ::: After global installation, run commands directly: ```bash ccusage daily ccusage monthly --breakdown ccusage blocks --live ``` ## Development Installation For development or contributing to ccusage: ```bash # Clone the repository git clone https://github.com/ryoppippi/ccusage.git cd ccusage # Install dependencies bun install # Run directly from source bun run start daily bun run start monthly --json ``` ### Development Scripts ```bash # Run tests bun run test # Type checking bun typecheck # Build distribution bun run build # Lint and format bun run format ``` ## Runtime Requirements ### Node.js - **Minimum**: Node.js 20.x - **Recommended**: Node.js 20.x or later - **LTS versions** are fully supported ### Bun (Alternative) - **Minimum**: Bun 1.2+ - **Recommended**: Latest stable release - Often faster than Node.js for ccusage ### Deno Deno 2.0+ is fully supported with proper permissions: ```bash deno run \ -E \ -R=$HOME/.claude/projects/ \ -S=homedir \ -N='raw.githubusercontent.com:443' \ npm:ccusage@latest ``` Also you can use `offline` mode to run ccusage without network access: ```bash deno run \ -E \ -R=$HOME/.claude/projects/ \ -S=homedir \ npm:ccusage@latest --offline ``` ## Verification After installation, verify ccusage is working: ```bash # Check version ccusage --version # Run help command ccusage --help # Test with daily report ccusage daily ``` ## Updating ### Direct Execution (npx/bunx) Always gets the latest version automatically. ### Global Installation ```bash # Update with npm npm update -g ccusage # Update with bun bun update -g ccusage ``` ### Check Current Version ```bash ccusage --version ``` ## Uninstalling ### Global Installation ::: code-group ```bash [npm] npm uninstall -g ccusage ``` ```bash [bun] bun remove -g ccusage ``` ```bash [yarn] yarn global remove ccusage ``` ```bash [pnpm] pnpm remove -g ccusage ``` ::: ### Development Installation ```bash # Remove cloned repository rm -rf ccusage/ ``` ## Troubleshooting Installation ### Permission Errors If you get permission errors during global installation: ::: code-group ```bash [npm] # Use npx instead of global install npx ccusage@latest # Or configure npm to use a different directory npm config set prefix ~/.npm-global export PATH=~/.npm-global/bin:$PATH ``` ```bash [Node Version Managers] # Use nvm (recommended) nvm install node npm install -g ccusage # Or use fnm fnm install node npm install -g ccusage ``` ::: ### Network Issues If installation fails due to network issues: ```bash # Try with different registry npm install -g ccusage --registry https://registry.npmjs.org # Or use bunx for offline-capable runs bunx ccusage ``` ### Version Conflicts If you have multiple versions installed: ```bash # Check which version is being used which ccusage ccusage --version # Uninstall and reinstall npm uninstall -g ccusage npm install -g ccusage@latest ``` ## Next Steps After installation, check out: - [Getting Started Guide](/guide/getting-started) - Your first usage report - [Configuration](/guide/configuration) - Customize ccusage behavior - [Daily Reports](/guide/daily-reports) - Understand daily usage patterns ================================================ FILE: docs/guide/json-output.md ================================================ # JSON Output ccusage supports structured JSON output for all report types, making it easy to integrate with other tools, scripts, or applications that need to process usage data programmatically. ## Enabling JSON Output Add the `--json` (or `-j`) flag to any command: ```bash # Daily report in JSON format ccusage daily --json # Monthly report in JSON format ccusage monthly --json # Session report in JSON format ccusage session --json # 5-hour blocks report in JSON format ccusage blocks --json ``` ## JSON Structure ### Daily Reports (Standard) Standard daily reports aggregate usage across all projects: ```json { "daily": [ { "date": "2025-05-30", "inputTokens": 277, "outputTokens": 31456, "cacheCreationTokens": 512, "cacheReadTokens": 1024, "totalTokens": 33269, "totalCost": 17.58, "modelsUsed": ["claude-opus-4-20250514", "claude-sonnet-4-20250514"], "modelBreakdowns": [...] } ], "totals": { "inputTokens": 11174, "outputTokens": 720366, "cacheCreationTokens": 896, "cacheReadTokens": 2304, "totalTokens": 734740, "totalCost": 336.47 } } ``` ### Daily Reports (Project-Grouped) When using `--instances`, daily reports group usage by project: ```json { "projects": { "my-frontend-app": [ { "date": "2025-05-30", "inputTokens": 177, "outputTokens": 16456, "cacheCreationTokens": 256, "cacheReadTokens": 512, "totalTokens": 17401, "totalCost": 7.33, "modelsUsed": ["claude-sonnet-4-20250514"], "modelBreakdowns": [...] } ], "backend-api": [ { "date": "2025-05-30", "inputTokens": 100, "outputTokens": 15000, "cacheCreationTokens": 256, "cacheReadTokens": 512, "totalTokens": 15868, "totalCost": 10.25, "modelsUsed": ["claude-opus-4-20250514"], "modelBreakdowns": [...] } ] }, "totals": { "inputTokens": 277, "outputTokens": 31456, "cacheCreationTokens": 512, "cacheReadTokens": 1024, "totalTokens": 33269, "totalCost": 17.58 } } ``` #### Usage ```bash # Standard aggregated output ccusage daily --json # Project-grouped output ccusage daily --instances --json # Filter to specific project ccusage daily --project my-frontend-app --json ``` ### Monthly Reports ```json { "type": "monthly", "data": [ { "month": "2025-05", "models": ["claude-opus-4-20250514", "claude-sonnet-4-20250514"], "inputTokens": 11174, "outputTokens": 720366, "cacheCreationTokens": 896, "cacheReadTokens": 2304, "totalTokens": 734740, "costUSD": 336.47 } ], "summary": { "totalInputTokens": 11174, "totalOutputTokens": 720366, "totalCacheCreationTokens": 896, "totalCacheReadTokens": 2304, "totalTokens": 734740, "totalCostUSD": 336.47 } } ``` ### Session Reports ```json { "type": "session", "data": [ { "session": "session-1", "models": ["claude-opus-4-20250514", "claude-sonnet-4-20250514"], "inputTokens": 4512, "outputTokens": 350846, "cacheCreationTokens": 512, "cacheReadTokens": 1024, "totalTokens": 356894, "costUSD": 156.4, "lastActivity": "2025-05-24" } ], "summary": { "totalInputTokens": 11174, "totalOutputTokens": 720445, "totalCacheCreationTokens": 768, "totalCacheReadTokens": 1792, "totalTokens": 734179, "totalCostUSD": 336.68 } } ``` ### Blocks Reports ```json { "type": "blocks", "data": [ { "blockStart": "2025-05-30T10:00:00.000Z", "blockEnd": "2025-05-30T15:00:00.000Z", "isActive": true, "timeRemaining": "2h 15m", "models": ["claude-sonnet-4-20250514"], "inputTokens": 1250, "outputTokens": 15000, "cacheCreationTokens": 256, "cacheReadTokens": 512, "totalTokens": 17018, "costUSD": 8.75, "burnRate": 2400, "projectedTotal": 25000, "projectedCost": 12.5 } ], "summary": { "totalInputTokens": 11174, "totalOutputTokens": 720366, "totalCacheCreationTokens": 896, "totalCacheReadTokens": 2304, "totalTokens": 734740, "totalCostUSD": 336.47 } } ``` ## Field Descriptions ### Common Fields - `models`: Array of Claude model names used - `inputTokens`: Number of input tokens consumed - `outputTokens`: Number of output tokens generated - `cacheCreationTokens`: Tokens used for cache creation - `cacheReadTokens`: Tokens read from cache - `totalTokens`: Sum of all token types - `costUSD`: Estimated cost in US dollars ### Report-Specific Fields #### Daily Reports - `date`: Date in YYYY-MM-DD format #### Monthly Reports - `month`: Month in YYYY-MM format #### Session Reports - `session`: Session identifier - `lastActivity`: Date of last activity in the session #### Blocks Reports - `blockStart`: ISO timestamp of block start - `blockEnd`: ISO timestamp of block end - `isActive`: Whether the block is currently active - `timeRemaining`: Human-readable time remaining (active blocks only) - `burnRate`: Tokens per hour rate (active blocks only) - `projectedTotal`: Projected total tokens for the block - `projectedCost`: Projected total cost for the block ## Filtering with JSON Output All filtering options work with JSON output: ```bash # Filter by date range ccusage daily --json --since 20250525 --until 20250530 # Different cost calculation modes ccusage monthly --json --mode calculate ccusage session --json --mode display # Sort order ccusage daily --json --order asc # With model breakdown ccusage daily --json --breakdown # Project analysis ccusage daily --json --instances # Group by project ccusage daily --json --project my-project # Filter to project ccusage daily --json --instances --project my-app # Combined usage ``` ### Model Breakdown JSON When using `--breakdown`, the JSON includes per-model details: ```json { "type": "daily", "data": [ { "date": "2025-05-30", "models": ["claude-opus-4-20250514", "claude-sonnet-4-20250514"], "inputTokens": 277, "outputTokens": 31456, "totalTokens": 33269, "costUSD": 17.58, "breakdown": { "claude-opus-4-20250514": { "inputTokens": 100, "outputTokens": 15000, "cacheCreationTokens": 256, "cacheReadTokens": 512, "totalTokens": 15868, "costUSD": 10.25 }, "claude-sonnet-4-20250514": { "inputTokens": 177, "outputTokens": 16456, "cacheCreationTokens": 256, "cacheReadTokens": 512, "totalTokens": 17401, "costUSD": 7.33 } } } ] } ``` ## Using the --jq Option ccusage includes built-in jq processing with the `--jq` option. This allows you to process JSON output directly without using pipes: ```bash # Get total cost directly ccusage daily --jq '.totals.totalCost' # Find the most expensive session ccusage session --jq '.sessions | sort_by(.totalCost) | reverse | .[0]' # Get daily costs as CSV ccusage daily --jq '.daily[] | [.date, .totalCost] | @csv' # List all unique models used ccusage session --jq '[.sessions[].modelsUsed[]] | unique | sort[]' # Get usage by specific date ccusage daily --jq '.daily[] | select(.date == "2025-05-30")' # Calculate average daily cost ccusage daily --jq '[.daily[].totalCost] | add / length' ``` ### Important Notes - The `--jq` option implies `--json` (you don't need to specify both) - Requires jq to be installed on your system - If jq is not installed, you'll get an error message with installation instructions ## Integration Examples ### Using with jq (via pipes) You can also pipe JSON output to jq for advanced filtering and formatting: ```bash # Get total cost for the last 7 days ccusage daily --json --since $(date -d '7 days ago' +%Y%m%d) | jq '.summary.totalCostUSD' # List all unique models used ccusage session --json | jq -r '.data[].models[]' | sort -u # Find the most expensive session ccusage session --json | jq -r '.data | sort_by(.costUSD) | reverse | .[0].session' # Get daily costs as CSV ccusage daily --json | jq -r '.daily[] | [.date, .totalCost] | @csv' # Analyze project costs ccusage daily --instances --json | jq -r '.projects | to_entries[] | [.key, (.value | map(.totalCost) | add)] | @csv' # Find most expensive project ccusage daily --instances --json | jq -r '.projects | to_entries | map({project: .key, total: (.value | map(.totalCost) | add)}) | sort_by(.total) | reverse | .[0].project' # Get usage by project for specific date ccusage daily --instances --json | jq '.projects | to_entries[] | select(.value[].date == "2025-05-30") | {project: .key, usage: .value[0]}' ``` ### Using with Python ```python import json import subprocess # Get daily usage data result = subprocess.run(['ccusage', 'daily', '--json'], capture_output=True, text=True) data = json.loads(result.stdout) # Process the data for day in data['data']: print(f"Date: {day['date']}, Cost: ${day['costUSD']:.2f}") total_cost = data['totals']['totalCost'] print(f"Total cost: ${total_cost:.2f}") # Project analysis example result = subprocess.run(['ccusage', 'daily', '--instances', '--json'], capture_output=True, text=True) project_data = json.loads(result.stdout) if 'projects' in project_data: for project_name, daily_entries in project_data['projects'].items(): project_total = sum(day['totalCost'] for day in daily_entries) print(f"Project {project_name}: ${project_total:.2f}") # Find highest spending project project_totals = { project: sum(day['totalCost'] for day in days) for project, days in project_data['projects'].items() } top_project = max(project_totals, key=project_totals.get) print(f"Highest spending project: {top_project} (${project_totals[top_project]:.2f})") ``` ### Using with Node.js ```javascript import { execSync } from 'node:child_process'; // Get session usage data const output = execSync('ccusage session --json', { encoding: 'utf-8' }); const data = JSON.parse(output); // Find sessions over $10 const expensiveSessions = data.data.filter((session) => session.costUSD > 10); console.log(`Found ${expensiveSessions.length} expensive sessions`); expensiveSessions.forEach((session) => { console.log(`${session.session}: $${session.costUSD.toFixed(2)}`); }); // Project analysis example const projectOutput = execSync('ccusage daily --instances --json', { encoding: 'utf-8' }); const projectData = JSON.parse(projectOutput); if (projectData.projects) { // Calculate total cost per project const projectCosts = Object.entries(projectData.projects).map(([name, days]) => ({ name, totalCost: days.reduce((sum, day) => sum + day.totalCost, 0), totalTokens: days.reduce((sum, day) => sum + day.totalTokens, 0), })); // Sort by cost descending projectCosts.sort((a, b) => b.totalCost - a.totalCost); console.log('Project Usage Summary:'); projectCosts.forEach((project) => { console.log( `${project.name}: $${project.totalCost.toFixed(2)} (${project.totalTokens.toLocaleString()} tokens)`, ); }); } ``` ## Programmatic Usage JSON output is designed for programmatic consumption: - **Consistent structure**: All fields are always present (with 0 or empty values when not applicable) - **Standard types**: Numbers for metrics, strings for identifiers, arrays for lists - **ISO timestamps**: Standardized date/time formats for reliable parsing - **Stable schema**: Field names and structures remain consistent across versions ================================================ FILE: docs/guide/library-usage.md ================================================ # Library Usage While **ccusage** is primarily known as a CLI tool, it can also be used as a library in your JavaScript/TypeScript projects. This allows you to integrate Claude Code usage analysis directly into your applications. ## Installation ```bash npm install ccusage # or yarn add ccusage # or pnpm add ccusage # or bun add ccusage ``` ## Basic Usage The library provides functions to load and analyze Claude Code usage data: ```typescript import { loadDailyUsageData, loadMonthlyUsageData, loadSessionData } from 'ccusage/data-loader'; // Load daily usage data const dailyData = await loadDailyUsageData(); console.log(dailyData); // Load monthly usage data const monthlyData = await loadMonthlyUsageData(); console.log(monthlyData); // Load session data const sessionData = await loadSessionData(); console.log(sessionData); ``` ## Cost Calculation Use the cost calculation utilities to work with token costs: ```typescript import { calculateTotals, getTotalTokens } from 'ccusage/calculate-cost'; // Assume 'usageEntries' is an array of usage data objects const totals = calculateTotals(usageEntries); // Get total tokens from the same entries const totalTokens = getTotalTokens(usageEntries); ``` ## Advanced Configuration You can customize the data loading behavior: ```typescript import { loadDailyUsageData } from 'ccusage/data-loader'; // Load data with custom options const data = await loadDailyUsageData({ mode: 'calculate', // Force cost calculation claudePaths: ['/custom/path/to/claude'], // Custom Claude data paths }); ``` ## TypeScript Support The library is fully typed with TypeScript definitions: ```typescript import type { DailyUsage, ModelBreakdown, MonthlyUsage, SessionUsage, UsageData, } from 'ccusage/data-loader'; // Use the types in your application function processUsageData(data: UsageData[]): void { // Your processing logic here } ``` ## MCP Server Integration You can also create your own MCP server using the dedicated `@ccusage/mcp` package: > **Note**: Install `ccusage` and `@ccusage/mcp` together, for example with `pnpm add ccusage @ccusage/mcp`. ```typescript import { createMcpServer } from '@ccusage/mcp'; // Create an MCP server instance const server = createMcpServer(); // Start the server server.start(); ``` ## API Reference For detailed information about all available functions, types, and options, see the [API Reference](/api/) section. ## Examples Here are some common use cases: ### Building a Web Dashboard ```typescript import { loadDailyUsageData } from 'ccusage/data-loader'; export async function GET() { const data = await loadDailyUsageData(); return Response.json(data); } ``` ### Creating Custom Reports ```typescript import { calculateTotals, loadSessionData } from 'ccusage'; async function generateCustomReport() { const sessions = await loadSessionData(); const report = sessions.map((session) => ({ project: session.project, session: session.session, totalCost: calculateTotals(session.usage).costUSD, })); return report; } ``` ### Monitoring Usage Programmatically ```typescript import { loadDailyUsageData } from 'ccusage/data-loader'; async function checkUsageAlert() { const dailyData = await loadDailyUsageData(); const today = dailyData[0]; // Most recent day if (today.totalCostUSD > 10) { console.warn(`High usage detected: $${today.totalCostUSD}`); } } ``` ## Next Steps - Explore the [API Reference](/api/) for complete documentation - Check out the [MCP Server guide](/guide/mcp-server) for integration examples - See [JSON Output](/guide/json-output) for data format details ================================================ FILE: docs/guide/live-monitoring.md ================================================ # Live Monitoring (Removed) ![Live monitoring dashboard showing real-time token usage, burn rate, and cost projections](/blocks-live.png) ::: danger REMOVED IN v18 The `blocks --live` monitor feature has been removed in v18.0.0. This feature is available in v17.x. ::: ## Historical Reference (v17.x) The following documentation is preserved for users on v17.x. ### Quick Start ```bash ccusage blocks --live ``` This starts live monitoring with automatic token limit detection based on your usage history. ### Features #### Real-time Updates The dashboard refreshes every second, showing: - **Current session progress** with visual progress bar - **Token burn rate** (tokens per minute) - **Time remaining** in current 5-hour block - **Cost projections** based on current usage patterns - **Quota warnings** with color-coded alerts ### Command Options #### Token Limits Set custom token limits for quota warnings: ```bash # Use specific token limit ccusage blocks --live -t 500000 # Use highest previous session as limit (default) ccusage blocks --live -t max ``` #### Refresh Interval Control update frequency: ```bash # Update every 5 seconds ccusage blocks --live --refresh-interval 5 # Update every 10 seconds (lighter on CPU) ccusage blocks --live --refresh-interval 10 ``` ### Keyboard Controls While live monitoring is active: - **Ctrl+C**: Exit monitoring gracefully - **Terminal resize**: Automatically adjusts display ## Related Commands - [Blocks Reports](/guide/blocks-reports) - Static 5-hour block analysis - [Session Reports](/guide/session-reports) - Historical session data - [Daily Reports](/guide/daily-reports) - Day-by-day usage patterns ================================================ FILE: docs/guide/mcp-server.md ================================================ # MCP Server The ccusage MCP server now lives in the dedicated `@ccusage/mcp` package. This keeps the main CLI lightweight while still giving you full access to MCP tools for daily, session, monthly, and billing-block analytics. ## Running the MCP CLI Execute the MCP CLI directly without installation using `bunx` or `npx`: ```bash bunx @ccusage/mcp@latest --help # or npx @ccusage/mcp@latest --help ``` All examples below use `bunx @ccusage/mcp@latest` (you can substitute with `npx @ccusage/mcp@latest` if preferred). ## Starting the MCP Server ### stdio transport (default) ```bash bunx @ccusage/mcp@latest # equivalent: bunx @ccusage/mcp@latest --type stdio ``` The stdio transport is ideal when the MCP client spawns the process directly (for example, Claude Desktop on the same machine). ### HTTP Stream Transport ```bash bunx @ccusage/mcp@latest --type http --port 8080 ``` HTTP mode is useful when you need to expose the server to other hosts or run it as a background service. ### Cost Calculation Mode Control how costs are calculated when generating reports: ```bash # Use cached costUSD values when present, otherwise calculate from tokens (default) bunx @ccusage/mcp@latest --mode auto # Always calculate from tokens using LiteLLM pricing data bunx @ccusage/mcp@latest --mode calculate # Only use pre-calculated costUSD values and default to 0 when missing bunx @ccusage/mcp@latest --mode display ``` All options from the original command remain available, including `CLAUDE_CONFIG_DIR` for custom data locations. ## Available MCP Tools The server still provides four tools with the same schemas as before: - **daily** – aggregated usage per day - **monthly** – aggregated usage per month - **session** – grouped by Claude session ID / project directory - **blocks** – 5-hour billing block summaries Each tool accepts `since`, `until`, and `mode` parameters, plus timezone/locale overrides identical to the ccusage library. ## Testing the MCP Server ### With MCP Inspector ```bash bunx @modelcontextprotocol/inspector bunx @ccusage/mcp@latest # or npx @modelcontextprotocol/inspector npx @ccusage/mcp@latest ``` The Inspector lets you: - Call each tool interactively - Inspect the tool schemas and responses - Debug invalid parameters or unexpected data - Export ready-to-use server definitions ### Manual JSON-RPC Testing ```bash bunx @ccusage/mcp@latest # Now send JSON-RPC to stdin, e.g. list available tools {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} ``` ## Claude Desktop Integration ![Claude Desktop MCP Configuration](/mcp-claude-desktop.avif) Update your Claude Desktop configuration to use direct execution: ```json { "mcpServers": { "ccusage": { "command": "bunx", "args": ["@ccusage/mcp@latest"], "env": {} } } } ``` Or using `npx`: ```json { "mcpServers": { "ccusage": { "command": "npx", "args": ["@ccusage/mcp@latest"], "env": {} } } } ``` Need custom paths or cost modes? Pass them as arguments: ```json { "mcpServers": { "ccusage": { "command": "bunx", "args": ["@ccusage/mcp@latest", "--mode", "calculate", "--type", "http", "--port", "8080"], "env": { "CLAUDE_CONFIG_DIR": "/path/to/claude/data" } } } } ``` After updating the file, restart Claude Desktop so it picks up the new MCP server. ### Example prompts inside Claude Desktop - "Ask the ccusage MCP server for today's usage report" - "Show me the sessions with the highest cost this week" - "Summarize my current billing block" ## Library Usage Prefer to embed the MCP server directly? Import it from the library just like before: ```ts import { createMcpServer } from '@ccusage/mcp'; const server = createMcpServer(); // ...connect it to the transport of your choice ``` See the [Library Usage guide](/guide/library-usage) for more examples. ================================================ FILE: docs/guide/monthly-reports.md ================================================ # Monthly Reports Monthly reports aggregate your Claude Code usage by calendar month, providing a high-level view of your usage patterns and costs over longer time periods. :::warning NOTICE Claude Code can only retain logs for 30 days by default. To be able to check logs for more than a month, you need to change the value of `cleanupPeriodDays` in the settings file. [Claude Code settings - Claude Docs](https://docs.claude.com/en/docs/claude-code/settings#settings-files) ::: ## Basic Usage ```bash ccusage monthly ``` ## Example Output ``` ╭─────────────────────────────────────────────╮ │ │ │ Claude Code Token Usage Report - Monthly │ │ │ ╰─────────────────────────────────────────────╯ ┌─────────┬──────────────────┬─────────┬──────────┬──────────────┬────────────┬──────────────┬────────────┐ │ Month │ Models │ Input │ Output │ Cache Create │ Cache Read │ Total Tokens │ Cost (USD) │ ├─────────┼──────────────────┼─────────┼──────────┼──────────────┼────────────┼──────────────┼────────────┤ │ 2025-06 │ • opus-4 │ 45,231 │ 892,456 │ 2,048 │ 4,096 │ 943,831 │ $1,247.92│ │ │ • sonnet-4 │ │ │ │ │ │ │ │ 2025-05 │ • sonnet-4 │ 38,917 │ 756,234 │ 1,536 │ 3,072 │ 799,759 │ $892.15│ │ 2025-04 │ • opus-4 │ 22,458 │ 534,789 │ 1,024 │ 2,048 │ 560,319 │ $678.43│ ├─────────┼──────────────────┼─────────┼──────────┼──────────────┼────────────┼──────────────┼────────────┤ │ Total │ │ 106,606 │2,183,479 │ 4,608 │ 9,216 │ 2,303,909 │ $2,818.50│ └─────────┴──────────────────┴─────────┴──────────┴──────────────┴────────────┴──────────────┴────────────┘ ``` ## Understanding Monthly Data ### Month Format Months are displayed in YYYY-MM format: - `2025-06` = June 2025 - `2025-05` = May 2025 ### Aggregation Logic All usage within a calendar month is aggregated: - Input/output tokens summed across all days - Costs calculated from total token usage - Models listed if used at any point in the month ## Command Options ### Date Filtering Filter by month range: ```bash # Show specific months ccusage monthly --since 20250101 --until 20250630 # Show usage from 2024 ccusage monthly --since 20240101 --until 20241231 # Show last 6 months ccusage monthly --since $(date -d '6 months ago' +%Y%m%d) ``` ::: tip Date Filtering Even though you specify full dates (YYYYMMDD), monthly reports group by month. The filters determine which months to include. ::: ### Sort Order ```bash # Newest months first (default) ccusage monthly --order desc # Oldest months first ccusage monthly --order asc ``` ### Cost Calculation Modes ```bash # Use pre-calculated costs when available (default) ccusage monthly --mode auto # Always calculate costs from tokens ccusage monthly --mode calculate # Only show pre-calculated costs ccusage monthly --mode display ``` ### Model Breakdown See costs broken down by model: ```bash ccusage monthly --breakdown ``` Example with breakdown: ``` ┌─────────┬──────────────────┬─────────┬──────────┬────────────┐ │ Month │ Models │ Input │ Output │ Cost (USD) │ ├─────────┼──────────────────┼─────────┼──────────┼────────────┤ │ 2025-06 │ opus-4, sonnet-4 │ 45,231 │ 892,456 │ $1,247.92 │ ├─────────┼──────────────────┼─────────┼──────────┼────────────┤ │ └─ opus-4 │ 20,000 │ 400,000 │ $750.50 │ ├─────────┼──────────────────┼─────────┼──────────┼────────────┤ │ └─ sonnet-4 │ 25,231 │ 492,456 │ $497.42 │ └─────────┴──────────────────┴─────────┴──────────┴────────────┘ ``` ### JSON Output ```bash ccusage monthly --json ``` ```json [ { "month": "2025-06", "models": ["opus-4", "sonnet-4"], "inputTokens": 45231, "outputTokens": 892456, "cacheCreationTokens": 2048, "cacheReadTokens": 4096, "totalTokens": 943831, "totalCost": 1247.92 } ] ``` ### Offline Mode ```bash ccusage monthly --offline ``` ## Analysis Use Cases ### Budget Planning Monthly reports help with subscription planning: ```bash # Check last year's usage ccusage monthly --since 20240101 --until 20241231 ``` Look at the total cost to understand what you'd pay on usage-based pricing. ### Usage Trends Track how your usage changes over time: ```bash # Compare year over year ccusage monthly --since 20230101 --until 20231231 # 2023 ccusage monthly --since 20240101 --until 20241231 # 2024 ``` ### Model Migration Analysis See how your model usage evolves: ```bash ccusage monthly --breakdown ``` This helps track transitions between Opus, Sonnet, and other models. ### Seasonal Patterns Identify busy/slow periods: ```bash # Academic year analysis ccusage monthly --since 20240901 --until 20250630 ``` ### Export for Business Analysis ```bash # Create quarterly reports ccusage monthly --since 20241001 --until 20241231 --json > q4-2024.json ``` ## Tips for Monthly Analysis ### 1. Cost Context Monthly totals show: - **Subscription Value**: How much you'd pay with usage-based billing - **Usage Intensity**: Months with heavy Claude usage - **Model Preferences**: Which models you favor over time ### 2. Trend Analysis Look for patterns: - Increasing usage over time - Seasonal variations - Model adoption curves ### 3. Business Planning Use monthly data for: - Team budget planning - Usage forecasting - Subscription optimization ### 4. Comparative Analysis Compare monthly reports with: - Team productivity metrics - Project timelines - Business outcomes ## Related Commands - [Daily Reports](/guide/daily-reports) - Day-by-day breakdown - [Session Reports](/guide/session-reports) - Individual conversations - [Blocks Reports](/guide/blocks-reports) - 5-hour billing periods ## Next Steps After analyzing monthly trends, consider: 1. [Session Reports](/guide/session-reports) to identify high-cost conversations 2. [Live Monitoring](/guide/live-monitoring) to track real-time usage 3. [Library Usage](/guide/library-usage) for programmatic analysis ================================================ FILE: docs/guide/opencode/index.md ================================================ # OpenCode CLI Overview (Beta) > The OpenCode companion CLI is experimental. Expect breaking changes while both ccusage and [OpenCode](https://github.com/sst/opencode) continue to evolve. The `@ccusage/opencode` package reuses ccusage's responsive tables, pricing cache, and token accounting to analyze [OpenCode](https://github.com/sst/opencode) session logs. OpenCode is a terminal-based AI coding assistant that supports multiple AI providers. ## Installation & Launch ::: code-group ```bash [bunx (Recommended)] bunx @ccusage/opencode@latest --help ``` ```bash [npx] npx @ccusage/opencode@latest --help ``` ```bash [pnpm] pnpm dlx @ccusage/opencode --help ``` ```bash [opencode x] BUN_BE_BUN=1 opencode x @ccusage/opencode@latest --help ``` ::: ::: tip opencode x option The `opencode x` option requires the native version of OpenCode. If you installed OpenCode via npm, use the `bunx` or `npx` options instead. ::: ### Recommended: Shell Alias ```bash # bash/zsh alias ccusage-opencode='bunx @ccusage/opencode@latest' # fish alias ccusage-opencode 'bunx @ccusage/opencode@latest' ``` ## Data Source The CLI reads OpenCode message and session JSON files located under `OPENCODE_DATA_DIR` (defaults to `~/.local/share/opencode`). ``` ~/.local/share/opencode/ └── storage/ ├── message/{sessionID}/msg_{messageID}.json └── session/{projectHash}/{sessionID}.json ``` ## Available Commands | Command | Description | See also | | --------- | ---------------------------------------------------- | ----------------------------------------- | | `daily` | Aggregate usage by date (YYYY-MM-DD) | [Daily Reports](/guide/daily-reports) | | `weekly` | Aggregate usage by ISO week (YYYY-Www) | [Weekly Reports](/guide/weekly-reports) | | `monthly` | Aggregate usage by month (YYYY-MM) | [Monthly Reports](/guide/monthly-reports) | | `session` | Per-session breakdown with parent/subagent hierarchy | [Session Reports](/guide/session-reports) | All commands support `--json` for structured output and `--compact` for narrow terminals. See the linked ccusage documentation for detailed flag descriptions. ## Session Hierarchy OpenCode supports subagent sessions. The session report displays: - **Bold titles** for parent sessions with subagents - **Indented rows** (`↳`) for subagent sessions - **Subtotal rows** combining parent + subagents ## Environment Variables | Variable | Description | | ------------------- | ---------------------------------------------------- | | `OPENCODE_DATA_DIR` | Override the root directory containing OpenCode data | | `LOG_LEVEL` | Adjust verbosity (0 silent ... 5 trace) | ## Cost Calculation OpenCode stores `cost: 0` in message files. Costs are calculated from token counts using LiteLLM pricing. Model aliases (e.g., `gemini-3-pro-high` → `gemini-3-pro-preview`) are handled automatically. ## Troubleshooting ::: details No OpenCode usage data found Ensure the data directory exists at `~/.local/share/opencode/storage/message/`. Set `OPENCODE_DATA_DIR` for custom paths. ::: ::: details Costs showing as $0.00 If a model is not in LiteLLM's database, the cost will be $0.00. [Open an issue](https://github.com/ryoppippi/ccusage/issues/new) to request alias support. ::: ================================================ FILE: docs/guide/pi/index.md ================================================ # Pi-Agent Integration The `@ccusage/pi` package provides usage tracking for [pi-agent](https://github.com/badlogic/pi-mono), an alternative Claude coding agent from [shittycodingagent.ai](https://shittycodingagent.ai). ## What is Pi-Agent? Pi-agent is a third-party Claude coding agent that stores usage data in JSONL format. The `@ccusage/pi` package analyses this data to give you a view of your pi-agent usage. ## Installation & Launch ```bash # Recommended - always include @latest npx @ccusage/pi@latest --help bunx @ccusage/pi@latest --help # ⚠️ MUST include @latest with bunx # Alternative package runners pnpm dlx @ccusage/pi --help pnpx @ccusage/pi --help ``` ::: warning ⚠️ Critical for bunx users Bun's bunx prioritizes binaries matching the package name suffix when given a scoped package. **Always use `bunx @ccusage/pi@latest` with the version tag** to force bunx to fetch and run the correct package. ::: ### Recommended: Shell Alias Since `npx @ccusage/pi@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias for convenience: ```bash # bash/zsh: alias ccusage-pi='bunx @ccusage/pi@latest' # fish: alias ccusage-pi 'bunx @ccusage/pi@latest' # Then simply run: ccusage-pi daily ccusage-pi monthly --json ``` ::: tip After adding the alias to your shell config file (`.bashrc`, `.zshrc`, or `config.fish`), restart your shell or run `source` on the config file to apply the changes. ::: ## Data Source The CLI reads usage data from pi-agent: | Source | Default Path | | -------- | ----------------------- | | Pi-agent | `~/.pi/agent/sessions/` | ## Available Commands ```bash # Show daily pi-agent usage ccusage-pi daily # Show monthly pi-agent usage ccusage-pi monthly # Show session-based pi-agent usage ccusage-pi session # JSON output for automation ccusage-pi daily --json # Custom pi-agent path ccusage-pi daily --pi-path /path/to/sessions # Filter by date range ccusage-pi daily --since 2025-12-01 --until 2025-12-19 # Show model breakdown ccusage-pi daily --breakdown ``` ## Environment Variables | Variable | Description | | -------------- | --------------------------------------------- | | `PI_AGENT_DIR` | Custom path to pi-agent sessions directory | | `LOG_LEVEL` | Adjust logging verbosity (0 silent … 5 trace) | ## Daily Report The `daily` command shows daily usage from pi-agent. ```bash # Recommended (fastest) bunx @ccusage/pi@latest daily # Using npx npx @ccusage/pi@latest daily ``` ### Options | Flag | Short | Description | | ------------- | ----- | --------------------------------------------- | | `--since` | | Start date filter (YYYY-MM-DD or YYYYMMDD) | | `--until` | | End date filter (YYYY-MM-DD or YYYYMMDD) | | `--timezone` | `-z` | Override timezone for date grouping | | `--json` | | Emit structured JSON instead of a table | | `--breakdown` | `-b` | Show per-model token breakdown | | `--pi-path` | | Custom path to pi-agent sessions directory | | `--order` | | Sort order: `asc` or `desc` (default: `desc`) | ### Example Output ``` ┌────────────┬────────────┬─────────────┬───────────┬───────────┬────────┬─────────┐ │ Date │ Input │ Output │ Cache Cr. │ Cache Rd. │ Cost │ Models │ ├────────────┼────────────┼─────────────┼───────────┼───────────┼────────┼─────────┤ │ 2025-01-09 │ 567,890 │ 123,456 │ 5,678 │ 45,678 │ $0.89 │ opus-4 │ ├────────────┼────────────┼─────────────┼───────────┼───────────┼────────┼─────────┤ │ Total │ 567,890 │ 123,456 │ 5,678 │ 45,678 │ $0.89 │ │ └────────────┴────────────┴─────────────┴───────────┴───────────┴────────┴─────────┘ ``` ### JSON Output Use `--json` for automation and scripting: ```bash ccusage-pi daily --json ``` Returns structured data: ```json { "daily": [ { "date": "2025-01-09", "source": "pi-agent", "inputTokens": 567890, "outputTokens": 123456, "cacheCreationTokens": 5678, "cacheReadTokens": 45678, "totalCost": 0.89, "modelsUsed": ["claude-opus-4-5-20251101"], "modelBreakdowns": [...] } ], "totals": { "inputTokens": 567890, "outputTokens": 123456, "cacheCreationTokens": 5678, "cacheReadTokens": 45678, "totalCost": 0.89 } } ``` ### Date Filtering Filter to a specific date range: ```bash # Last week ccusage-pi daily --since 2025-01-02 --until 2025-01-09 # Single day ccusage-pi daily --since 2025-01-09 --until 2025-01-09 ``` ## Monthly Report The `monthly` command shows monthly usage from pi-agent. ```bash # Recommended (fastest) bunx @ccusage/pi@latest monthly # Using npx npx @ccusage/pi@latest monthly ``` ### Options | Flag | Short | Description | | ------------- | ----- | --------------------------------------------- | | `--since` | | Start date filter (YYYY-MM-DD or YYYYMMDD) | | `--until` | | End date filter (YYYY-MM-DD or YYYYMMDD) | | `--timezone` | `-z` | Override timezone for date grouping | | `--json` | | Emit structured JSON instead of a table | | `--breakdown` | `-b` | Show per-model token breakdown | | `--pi-path` | | Custom path to pi-agent sessions directory | | `--order` | | Sort order: `asc` or `desc` (default: `desc`) | ### Example Output ``` ┌─────────┬────────────┬─────────────┬───────────┬───────────┬─────────┬─────────┐ │ Month │ Input │ Output │ Cache Cr. │ Cache Rd. │ Cost │ Models │ ├─────────┼────────────┼─────────────┼───────────┼───────────┼─────────┼─────────┤ │ 2025-01 │ 12,345,678 │ 2,345,678 │ 123,456 │ 987,654 │ $12.34 │ opus-4 │ ├─────────┼────────────┼─────────────┼───────────┼───────────┼─────────┼─────────┤ │ Total │ 12,345,678 │ 2,345,678 │ 123,456 │ 987,654 │ $12.34 │ │ └─────────┴────────────┴─────────────┴───────────┴───────────┴─────────┴─────────┘ ``` ### JSON Output Use `--json` for automation and scripting: ```bash ccusage-pi monthly --json ``` Returns structured data: ```json { "monthly": [ { "month": "2025-01", "source": "pi-agent", "inputTokens": 12345678, "outputTokens": 2345678, "cacheCreationTokens": 123456, "cacheReadTokens": 987654, "totalCost": 12.34, "modelsUsed": ["claude-opus-4-5-20251101"], "modelBreakdowns": [...] } ], "totals": { "inputTokens": 12345678, "outputTokens": 2345678, "cacheCreationTokens": 123456, "cacheReadTokens": 987654, "totalCost": 12.34 } } ``` ### Filtering by Date Range You can filter the data to specific months: ```bash # Current year only ccusage-pi monthly --since 2025-01-01 # Specific quarter ccusage-pi monthly --since 2024-10-01 --until 2024-12-31 ``` ## Session Report The `session` command shows usage grouped by individual pi-agent sessions. ```bash # Recommended (fastest) bunx @ccusage/pi@latest session # Using npx npx @ccusage/pi@latest session ``` ### Options | Flag | Short | Description | | ------------- | ----- | --------------------------------------------- | | `--since` | | Start date filter (YYYY-MM-DD or YYYYMMDD) | | `--until` | | End date filter (YYYY-MM-DD or YYYYMMDD) | | `--timezone` | `-z` | Override timezone for date grouping | | `--json` | | Emit structured JSON instead of a table | | `--breakdown` | `-b` | Show per-model token breakdown | | `--pi-path` | | Custom path to pi-agent sessions directory | | `--order` | | Sort order: `asc` or `desc` (default: `desc`) | ### Example Output Sessions are sorted by last activity: ``` ┌──────────────────────────────┬────────────┬───────────┬───────────┬───────────┬────────┬─────────┐ │ Session │ Input │ Output │ Cache Cr. │ Cache Rd. │ Cost │ Models │ ├──────────────────────────────┼────────────┼───────────┼───────────┼───────────┼────────┼─────────┤ │ my-project │ 123,456 │ 23,456 │ 1,234 │ 9,876 │ $0.12 │ opus-4 │ │ another-repo │ 345,678 │ 67,890 │ 3,456 │ 29,876 │ $0.34 │ sonnet-4│ ├──────────────────────────────┼────────────┼───────────┼───────────┼───────────┼────────┼─────────┤ │ Total │ 469,134 │ 91,346 │ 4,690 │ 39,752 │ $0.46 │ │ └──────────────────────────────┴────────────┴───────────┴───────────┴───────────┴────────┴─────────┘ ``` ### Session Identification Sessions are identified by the project folder name from `~/.pi/agent/sessions/{project}/`. Long project names are truncated to 25 characters with `...` suffix for readability. ### JSON Output Use `--json` for detailed session data: ```bash ccusage-pi session --json ``` Returns structured data including full paths: ```json { "sessions": [ { "sessionId": "abc123-def456", "projectPath": "my-project", "source": "pi-agent", "inputTokens": 123456, "outputTokens": 23456, "cacheCreationTokens": 1234, "cacheReadTokens": 9876, "totalCost": 0.12, "lastActivity": "2025-01-09", "modelsUsed": ["claude-opus-4-5-20251101"], "modelBreakdowns": [...] } ], "totals": { "inputTokens": 123456, "outputTokens": 23456, "cacheCreationTokens": 1234, "cacheReadTokens": 9876, "totalCost": 0.12 } } ``` ### Filtering Sessions Filter sessions by their last activity date: ```bash # Sessions active today ccusage-pi session --since 2025-01-09 --until 2025-01-09 # Sessions from the past week ccusage-pi session --since 2025-01-02 ``` ## Related - [ccusage](https://github.com/ryoppippi/ccusage) - Main usage analysis tool for Claude Code - [pi-agent](https://github.com/badlogic/pi-mono) - Alternative Claude coding agent ================================================ FILE: docs/guide/related-projects.md ================================================ # Related Projects Projects that use ccusage internally or extend its functionality: ## Desktop Applications - [claude-usage-tracker-for-mac](https://github.com/penicillin0/claude-usage-tracker-for-mac) - macOS menu bar app for tracking Claude usage - [ClaudeCode_Dashboard](https://github.com/m-sigepon/ClaudeCode_Dashboard) - Web dashboard with charts and visualizations - [Ccusage App](https://github.com/EthanBarlo/ccusage-app) - Native application to display ccusage data in graphs and visualizations - [CCOwl](https://github.com/sivchari/ccowl) - A cross-platform status bar application that monitors Claude Code usage in real-time. ## Extensions & Integrations - [ccusage Raycast Extension](https://www.raycast.com/nyatinte/ccusage) - Raycast integration for quick usage checks - [ccusage.nvim](https://github.com/S1M0N38/ccusage.nvim) - Track Claude Code usage in Neovim ## Web Applications - [viberank](https://viberank.app) - A community-driven leaderboard for Claude Code usage. ([GitHub](https://github.com/sculptdotfun/viberank)) ## Contributing If you've built something that uses ccusage, please feel free to open a pull request to add it to this list! ================================================ FILE: docs/guide/session-reports.md ================================================ # Session Reports Session reports show your Claude Code usage grouped by individual conversation sessions, making it easy to identify which conversations consumed the most tokens and cost the most. ## Basic Usage ```bash ccusage session ``` ## Specific Session Lookup Query individual session details by providing a session ID: ```bash ccusage session --id ``` This is particularly useful for: - **Custom statuslines**: Integrate specific session data into your development environment - **Programmatic usage**: Extract session metrics for scripts and automation - **Detailed analysis**: Get comprehensive data about a single conversation ### Examples ```bash # Get session data in table format ccusage session --id session-abc123-def456 # Get session data as JSON for scripting ccusage session --id session-abc123-def456 --json # Extract just the cost using jq ccusage session --id session-abc123-def456 --json --jq '.totalCost' # Use in a custom statusline script COST=$(ccusage session --id "$SESSION_ID" --json --jq '.totalCost') echo "Current session: \$${COST}" ``` ### Session ID Format Session IDs are the actual filenames (without `.jsonl` extension) stored in Claude's data directories. They typically look like: - `session-20250621-abc123-def456` - `project-conversation-xyz789` You can find session IDs by running `ccusage session` and looking for the files in your Claude data directory. ## Example Output ``` ╭───────────────────────────────────────────────╮ │ │ │ Claude Code Token Usage Report - By Session │ │ │ ╰───────────────────────────────────────────────╯ ┌────────────┬──────────────────┬────────┬─────────┬──────────────┬────────────┬──────────────┬────────────┬───────────────┐ │ Session │ Models │ Input │ Output │ Cache Create │ Cache Read │ Total Tokens │ Cost (USD) │ Last Activity │ ├────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┼───────────────┤ │ abc123-def │ • opus-4 │ 4,512 │ 350,846 │ 512 │ 1,024 │ 356,894 │ $156.40 │ 2025-06-21 │ │ │ • sonnet-4 │ │ │ │ │ │ │ │ │ ghi456-jkl │ • sonnet-4 │ 2,775 │ 186,645 │ 256 │ 768 │ 190,444 │ $98.45 │ 2025-06-20 │ │ mno789-pqr │ • opus-4 │ 1,887 │ 183,055 │ 128 │ 512 │ 185,582 │ $81.73 │ 2025-06-19 │ ├────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┼───────────────┤ │ Total │ │ 9,174 │ 720,546 │ 896 │ 2,304 │ 732,920 │ $336.58 │ │ └────────────┴──────────────────┴────────┴─────────┴──────────────┴────────────┴──────────────┴────────────┴───────────────┘ ``` ## Understanding Session Data ### Session Identification Sessions are displayed using the last two segments of their full identifier: - Full session ID: `project-20250621-session-abc123-def456` - Displayed as: `abc123-def` ### Session Metrics - **Input/Output Tokens**: Total tokens exchanged in the conversation - **Cache Tokens**: Cache creation and read tokens for context efficiency - **Cost**: Estimated USD cost for the entire conversation - **Last Activity**: Date of the most recent message in the session ### Sorting Sessions are sorted by cost (highest first) by default, making it easy to identify your most expensive conversations. ## Command Options ### Session ID Lookup Get detailed information about a specific session: ```bash # Query a specific session by ID ccusage session --id # Get JSON output for a specific session ccusage session --id --json # Short form using -i flag ccusage session -i ``` **Use cases:** - Building custom statuslines that show current session costs - Creating scripts that monitor specific conversation expenses - Debugging or analyzing individual conversation patterns - Integrating session data into development workflows ### Date Filtering Filter sessions by their last activity date: ```bash # Show sessions active since May 25th ccusage session --since 20250525 # Show sessions active in a specific date range ccusage session --since 20250520 --until 20250530 # Show only recent sessions (last week) ccusage session --since $(date -d '7 days ago' +%Y%m%d) ``` ### Cost Calculation Modes ```bash # Use pre-calculated costs when available (default) ccusage session --mode auto # Always calculate costs from tokens ccusage session --mode calculate # Only show pre-calculated costs ccusage session --mode display ``` ### Model Breakdown See per-model cost breakdown within each session: ```bash ccusage session --breakdown ``` Example with breakdown: ``` ┌────────────┬──────────────────┬────────┬─────────┬────────────┬───────────────┐ │ Session │ Models │ Input │ Output │ Cost (USD) │ Last Activity │ ├────────────┼──────────────────┼────────┼─────────┼────────────┼───────────────┤ │ abc123-def │ opus-4, sonnet-4 │ 4,512 │ 350,846 │ $156.40 │ 2025-06-21 │ ├────────────┼──────────────────┼────────┼─────────┼────────────┼───────────────┤ │ └─ opus-4│ │ 2,000 │ 200,000 │ $95.50 │ │ ├────────────┼──────────────────┼────────┼─────────┼────────────┼───────────────┤ │ └─ sonnet-4 │ 2,512 │ 150,846 │ $60.90 │ │ └────────────┴──────────────────┴────────┴─────────┴────────────┴───────────────┘ ``` ### JSON Output Export session data as JSON for further analysis: ```bash ccusage session --json ``` ```json { "sessions": [ { "sessionId": "abc123-def", "inputTokens": 4512, "outputTokens": 350846, "cacheCreationTokens": 512, "cacheReadTokens": 1024, "totalTokens": 356894, "totalCost": 156.4, "lastActivity": "2025-06-21", "modelsUsed": ["opus-4", "sonnet-4"], "modelBreakdowns": [ { "model": "opus-4", "inputTokens": 2000, "outputTokens": 200000, "totalCost": 95.5 } ] } ], "totals": { "inputTokens": 9174, "outputTokens": 720546, "totalCost": 336.58 } } ``` ### Offline Mode Use cached pricing data without network access: ```bash ccusage session --offline # or short form: ccusage session -O ``` ## Analysis Use Cases ### Identify Expensive Conversations Session reports help you understand which conversations are most costly: ```bash ccusage session ``` Look at the top sessions to understand: - Which types of conversations cost the most - Whether long coding sessions or research tasks are more expensive - How model choice (Opus vs Sonnet) affects costs ### Track Conversation Patterns ```bash # See recent conversation activity ccusage session --since 20250615 # Compare different time periods ccusage session --since 20250601 --until 20250615 # First half of month ccusage session --since 20250616 --until 20250630 # Second half of month ``` ### Model Usage Analysis ```bash # See which models you use in different conversations ccusage session --breakdown ``` This helps understand: - Whether you prefer Opus for complex tasks - If Sonnet is sufficient for routine work - How model mixing affects total costs ### Budget Optimization ```bash # Export data for spreadsheet analysis ccusage session --json > sessions.json # Find sessions above a certain cost threshold ccusage session --json | jq '.sessions[] | select(.totalCost > 50)' ``` ## Tips for Session Analysis ### 1. Cost Context Understanding Session costs help you understand: - **Conversation Value**: High-cost sessions should provide proportional value - **Efficiency Patterns**: Some conversation styles may be more token-efficient - **Model Selection**: Whether your model choices align with task complexity ### 2. Usage Optimization Use session data to: - **Identify expensive patterns**: What makes some conversations cost more? - **Optimize conversation flow**: Break long sessions into smaller focused chats - **Choose appropriate models**: Use Sonnet for simpler tasks, Opus for complex ones ### 3. Budget Planning Session analysis helps with: - **Conversation budgeting**: Understanding typical session costs - **Usage forecasting**: Predicting monthly costs based on session patterns - **Value assessment**: Ensuring expensive sessions provide good value ### 4. Comparative Analysis Compare sessions to understand: - **Task types**: Coding vs writing vs research costs - **Model effectiveness**: Whether Opus provides value over Sonnet - **Time patterns**: Whether longer sessions are more or less efficient ## Responsive Display Session reports adapt to your terminal width: - **Wide terminals (≥100 chars)**: Shows all columns including cache metrics - **Narrow terminals (<100 chars)**: Compact mode with essential columns (Session, Models, Input, Output, Cost, Last Activity) When in compact mode, ccusage displays a message explaining how to see the full data. ## Related Commands - [Daily Reports](/guide/daily-reports) - Usage aggregated by date - [Monthly Reports](/guide/monthly-reports) - Monthly summaries - [Blocks Reports](/guide/blocks-reports) - 5-hour billing windows - [Live Monitoring](/guide/live-monitoring) - Real-time session tracking ## Next Steps After analyzing session patterns, consider: 1. [Blocks Reports](/guide/blocks-reports) to understand timing within 5-hour windows 2. [Live Monitoring](/guide/live-monitoring) to track active conversations in real-time 3. [Daily Reports](/guide/daily-reports) to see how session patterns vary by day ================================================ FILE: docs/guide/sponsors.md ================================================ # Sponsors Support ccusage development by becoming a sponsor! Your contribution helps maintain and improve this tool. ## Featured Sponsor Check out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)

ccusage: The Claude Code cost scorecard that went viral

## How to Sponsor Visit [GitHub Sponsors - @ryoppippi](https://github.com/sponsors/ryoppippi) to support the development of ccusage and other open source projects. ## Star History Star History Chart ================================================ FILE: docs/guide/statusline.md ================================================ # Statusline Integration (Beta) 🚀 Display real-time usage statistics in your Claude Code status line. ## Overview The `statusline` command provides a compact, real-time view of your Claude Code usage, designed to integrate with Claude Code's status line hooks. It shows: - 💬 **Current session cost** - Cost for your active conversation session - 💰 **Today's total cost** - Your cumulative spending for the current day - 🚀 **Current session block** - Cost and time remaining in your active 5-hour billing block - 🔥 **Burn rate** - Token consumption rate with visual indicators - 🤖 **Active model** - The Claude model you're currently using ## Setup ### Configure settings.json Add this to your `~/.claude/settings.json` or `~/.config/claude/settings.json`: ::: code-group ```json [bun x (Recommended)] { "statusLine": { "type": "command", "command": "bun x ccusage statusline", "padding": 0 } } ``` ```json [claude x] { "statusLine": { "type": "command", "command": "BUN_BE_BUN=1 claude x ccusage statusline", "padding": 0 } } ``` ```json [npx] { "statusLine": { "type": "command", "command": "npx -y ccusage statusline", "padding": 0 } } ``` ::: ::: tip claude x option The `claude x` option requires the native version of Claude Code (not the npm version). If you installed Claude Code via npm, use the `bun x` or `npx` options instead. ::: By default, statusline uses **offline mode** with cached pricing data for optimal performance. ### Online Mode (Optional) If you need the latest pricing data from LiteLLM API, you can explicitly enable online mode: ```json { "statusLine": { "type": "command", "command": "bun x ccusage statusline --no-offline", // Fetches latest pricing from API "padding": 0 } } ``` ### With Visual Burn Rate (Optional) You can enhance the burn rate display with visual indicators: ```json { "statusLine": { "type": "command", "command": "bun x ccusage statusline --visual-burn-rate emoji", // Add emoji indicators "padding": 0 } } ``` See [Visual Burn Rate](#visual-burn-rate) section for all available options. ### With Cost Source Options (Optional) You can control how session costs are calculated and displayed: ```json { "statusLine": { "type": "command", "command": "bun x ccusage statusline --cost-source both", // Show both CC and ccusage costs "padding": 0 } } ``` See [Cost Source Options](#cost-source-options) section for all available modes. ## Output Format The statusline displays a compact, single-line summary: ``` 🤖 Opus | 💰 $0.23 session / $1.23 today / $0.45 block (2h 45m left) | 🔥 $0.12/hr | 🧠 25,000 (12%) ``` When using `--cost-source both`, the session cost shows both Claude Code and ccusage calculations: ``` 🤖 Opus | 💰 ($0.25 cc / $0.23 ccusage) session / $1.23 today / $0.45 block (2h 45m left) | 🔥 $0.12/hr | 🧠 25,000 (12%) ``` ### Components Explained - **Model** (`🤖 Opus`): Currently active Claude model - **Session Cost** (`💰 $0.23 session`): Cost for the current conversation session (see [Cost Source Options](#cost-source-options) for different calculation modes) - **Today's Cost** (`$1.23 today`): Total cost for the current day across all sessions - **Session Block** (`$0.45 block (2h 45m left)`): Current 5-hour block cost with remaining time - **Burn Rate** (`🔥 $0.12/hr`): Cost burn rate per hour with color-coded indicators: - Green text: Normal (< 2,000 tokens/min) - Yellow text: Moderate (2,000-5,000 tokens/min) - Red text: High (> 5,000 tokens/min) - Optional visual status indicators (see [Visual Burn Rate](#visual-burn-rate)) - **Context Usage** (`🧠 25,000 (12%)`): Shows input tokens with percentage of context limit: - Green text: Low usage (< 50% by default) - Yellow text: Medium usage (50-80% by default) - Red text: High usage (> 80% by default) - Uses Claude Code's [`context_window` data](https://code.claude.com/docs/en/statusline) when available for accurate token counts When no active block exists: ``` 🤖 Opus | 💰 $0.00 session / $0.00 today / No active block ``` ## Technical Details The statusline command: - Reads session information from stdin (provided by Claude Code hooks) - Identifies the active 5-hour billing block - Calculates real-time burn rates and projections - Outputs a single line suitable for status bar display - **Uses offline mode by default** for instant response times without network dependencies - Can be configured to use online mode with `--no-offline` for latest pricing data ## Beta Notice ⚠️ This feature is currently in **beta**. More customization options and features are coming soon: - Custom format templates - Configurable burn rate thresholds - Additional metrics display options - Session-specific cost tracking ### Cost Source Options The `--cost-source` option controls how session costs are calculated and displayed: **Available modes:** - `auto` (default): Prefer Claude Code's pre-calculated cost when available, fallback to ccusage calculation - `ccusage`: Always calculate costs using ccusage's token-based calculation with LiteLLM pricing - `cc`: Always use Claude Code's pre-calculated cost from session data - `both`: Display both Claude Code and ccusage costs side by side for comparison **Command-line usage:** ```bash # Default auto mode bun x ccusage statusline # Always use ccusage calculation bun x ccusage statusline --cost-source ccusage # Always use Claude Code cost bun x ccusage statusline --cost-source cc # Show both costs for comparison bun x ccusage statusline --cost-source both ``` **Settings.json configuration:** ```json { "statusLine": { "type": "command", "command": "bun x ccusage statusline --cost-source both", "padding": 0 } } ``` **When to use each mode:** - **`auto`**: Best for most users, provides accurate costs with fallback reliability - **`ccusage`**: When you want consistent calculation methods across all ccusage commands - **`cc`**: When you trust Claude Code's cost calculations and want minimal processing - **`both`**: For debugging cost discrepancies or comparing calculation methods **Output differences:** - **Single cost modes** (`auto`, `ccusage`, `cc`): `💰 $0.23 session` - **Both mode**: `💰 ($0.25 cc / $0.23 ccusage) session` ## Configuration ### Context Usage Thresholds You can customize the context usage color thresholds using command-line options or configuration files: - `--context-low-threshold` - Percentage below which context usage is shown in green (default: 50) - `--context-medium-threshold` - Percentage below which context usage is shown in yellow (default: 80) **Validation and Safety Features:** - Values are automatically validated to be integers in the 0-100 range - The `LOW` threshold must be less than the `MEDIUM` threshold - Invalid configurations will show clear error messages **Command-line usage:** ```bash bun x ccusage statusline --context-low-threshold 60 --context-medium-threshold 90 ``` **Configuration file usage:** You can also set these options in your configuration file. See the [Configuration Guide](/guide/configuration) for more details. With these settings: - Green: < 60% - Yellow: 60-90% - Red: > 90% **Example usage in Claude Code settings:** ```json { "command": "bun x ccusage statusline --context-low-threshold 60 --context-medium-threshold 90", "timeout": 5000 } ``` ### Visual Burn Rate You can enhance the burn rate display with visual status indicators using the `--visual-burn-rate` option: ```bash # Add to your settings.json command bun x ccusage statusline --visual-burn-rate emoji ``` **Available options:** - `off` (default): No visual indicators, only colored text - `emoji`: Add emoji indicators (🟢/⚠️/🚨) - `text`: Add text status in parentheses (Normal/Moderate/High) - `emoji-text`: Combine both emoji and text indicators **Examples:** ```bash # Default (off) 🔥 $0.12/hr # With emoji 🔥 $0.12/hr 🟢 # With text 🔥 $0.12/hr (Normal) # With both emoji and text 🔥 $0.12/hr 🟢 (Normal) ``` **Status Indicators:** - 🟢 Normal (Green) - ⚠️ Moderate (Yellow) - 🚨 High (Red) ## Troubleshooting ### No Output Displayed If the statusline doesn't show: 1. Verify `ccusage` is in your PATH 2. Check Claude Code logs for any errors 3. Ensure you have valid usage data in your Claude data directory ### Incorrect Costs If costs seem incorrect: - The command uses the same cost calculation as other ccusage commands - Verify with `ccusage daily` or `ccusage blocks` for detailed breakdowns ## Related Commands - [`blocks`](./blocks-reports.md) - Detailed 5-hour billing block analysis - [`daily`](./daily-reports.md) - Daily usage reports - [`session`](./session-reports.md) - Session-based usage analysis ================================================ FILE: docs/guide/weekly-reports.md ================================================ # Weekly Reports Weekly reports aggregate your Claude Code usage by week, providing a mid-range view between daily and monthly reports. This helps identify weekly patterns and trends in your usage. ## Basic Usage Show all weekly usage: ```bash ccusage weekly ``` ## Example Output ``` ┌────────────────┬──────────────────┬────────┬─────────┬─────────────┬────────────┬──────────────┬────────────┐ │ Week │ Models │ Input │ Output │ Cache Create│ Cache Read │ Total Tokens │ Cost (USD) │ ├────────────────┼──────────────────┼────────┼─────────┼─────────────┼────────────┼──────────────┼────────────┤ │ 2025-06-16 │ • opus-4 │ 1,234 │ 156,789 │ 2,048 │ 4,096 │ 164,167 │ $87.56 │ │ │ • sonnet-4 │ │ │ │ │ │ │ ├────────────────┼──────────────────┼────────┼─────────┼─────────────┼────────────┼──────────────┼────────────┤ │ 2025-06-23 │ • sonnet-4 │ 2,456 │ 234,567 │ 3,072 │ 6,144 │ 246,239 │ $104.33 │ ├────────────────┼──────────────────┼────────┼─────────┼─────────────┼────────────┼──────────────┼────────────┤ │ 2025-06-30 │ • opus-4 │ 3,789 │ 345,678 │ 4,096 │ 8,192 │ 361,755 │ $156.78 │ │ │ • sonnet-4 │ │ │ │ │ │ │ └────────────────┴──────────────────┴────────┴─────────┴─────────────┴────────────┴──────────────┴────────────┘ ``` ## Understanding the Columns The columns are identical to daily reports but aggregated by week: - **Week**: Start date of the week (configurable) - **Models**: All Claude models used during the week - **Input/Output**: Total tokens for the week - **Cache Create/Read**: Cache token usage - **Total Tokens**: Sum of all token types - **Cost (USD)**: Estimated cost for the week ## Command Options ### Week Start Day Configure which day starts the week: ```bash # Start week on Sunday (default) ccusage weekly --start-of-week sunday # Start week on Monday ccusage weekly --start-of-week monday ccusage weekly -w monday # Other options: tuesday, wednesday, thursday, friday, saturday ``` ### Date Filtering Filter by date range: ```bash # Show specific period ccusage weekly --since 20250601 --until 20250630 # Show last 4 weeks ccusage weekly --since 20250501 ``` ### Sort Order Control the order of weeks: ```bash # Newest weeks first (default) ccusage weekly --order desc # Oldest weeks first ccusage weekly --order asc ``` ### Model Breakdown See per-model weekly costs: ```bash ccusage weekly --breakdown ``` ``` ┌────────────────┬──────────────────┬────────┬─────────┬────────────┐ │ Week │ Models │ Input │ Output │ Cost (USD) │ ├────────────────┼──────────────────┼────────┼─────────┼────────────┤ │ 2025-06-16 │ opus-4, sonnet-4 │ 1,234 │ 156,789 │ $87.56 │ ├────────────────┼──────────────────┼────────┼─────────┼────────────┤ │ └─ opus-4 │ │ 800 │ 80,000 │ $54.80 │ ├────────────────┼──────────────────┼────────┼─────────┼────────────┤ │ └─ sonnet-4 │ │ 434 │ 76,789 │ $32.76 │ └────────────────┴──────────────────┴────────┴─────────┴────────────┘ ``` ### JSON Output Export weekly data as JSON: ```bash ccusage weekly --json ``` ```json { "weekly": [ { "week": "2025-06-16", "inputTokens": 1234, "outputTokens": 156789, "cacheCreationTokens": 2048, "cacheReadTokens": 4096, "totalTokens": 164167, "totalCost": 87.56, "modelsUsed": ["claude-opus-4-20250514", "claude-sonnet-4-20250514"], "modelBreakdowns": { "claude-opus-4-20250514": { "inputTokens": 800, "outputTokens": 80000, "totalCost": 54.8 }, "claude-sonnet-4-20250514": { "inputTokens": 434, "outputTokens": 76789, "totalCost": 32.76 } } } ], "totals": { "inputTokens": 7479, "outputTokens": 737034, "cacheCreationTokens": 9216, "cacheReadTokens": 18432, "totalTokens": 772161, "totalCost": 348.67 } } ``` ### Project Analysis Group weekly usage by project: ```bash # Show weekly usage per project ccusage weekly --instances # Filter to specific project ccusage weekly --project my-project ``` ### Cost Calculation Modes Control cost calculation: ```bash # Auto mode (default) ccusage weekly --mode auto # Always calculate from tokens ccusage weekly --mode calculate # Only use pre-calculated costs ccusage weekly --mode display ``` ### Offline Mode Use cached pricing data: ```bash ccusage weekly --offline ``` ## Common Use Cases ### Weekly Trends ```bash # See usage trends over past months ccusage weekly --since 20250401 ``` ### Sprint Analysis ```bash # Track usage during 2-week sprints (Monday start) ccusage weekly --start-of-week monday --since 20250601 ``` ### Budget Planning ```bash # Export for weekly budget tracking ccusage weekly --json > weekly-budget.json ``` ### Compare Workweeks ```bash # Monday-Friday work pattern analysis ccusage weekly --start-of-week monday --breakdown ``` ### Team Reporting ```bash # Weekly team usage report ccusage weekly --instances --start-of-week monday ``` ## Tips 1. **Week Start**: Choose a start day that aligns with your work schedule 2. **Breakdown View**: Use `--breakdown` to identify which models drive costs 3. **JSON Export**: Weekly JSON data is perfect for creating trend charts 4. **Project Tracking**: Use `--instances` to track project-specific weekly usage ## Related Commands - [Daily Reports](/guide/daily-reports) - Day-by-day analysis - [Monthly Reports](/guide/monthly-reports) - Monthly aggregates - [Session Reports](/guide/session-reports) - Per-conversation analysis - [Blocks Reports](/guide/blocks-reports) - 5-hour billing windows ================================================ FILE: docs/index.md ================================================ --- layout: home hero: name: ccusage text: Claude Code Usage Analysis tagline: A powerful CLI tool for analyzing Claude Code usage from local JSONL files image: src: /logo.svg alt: ccusage logo actions: - theme: brand text: Get Started link: /guide/ - theme: alt text: View on GitHub link: https://github.com/ryoppippi/ccusage features: - icon: 📊 title: Daily Reports details: View token usage and costs aggregated by date with detailed breakdowns link: /guide/daily-reports - icon: 📆 title: Weekly Reports details: Track usage patterns by week with configurable start day link: /guide/weekly-reports - icon: 📅 title: Monthly Reports details: Analyze usage patterns over monthly periods with cost tracking link: /guide/monthly-reports - icon: 💬 title: Session Reports details: Group usage by conversation sessions for detailed analysis link: /guide/session-reports - icon: ⏰ title: 5-Hour Blocks details: Track usage within Claude's billing windows with active monitoring link: /guide/blocks-reports - icon: 🤖 title: Model Tracking details: See which Claude models you're using (Opus, Sonnet, etc.) - icon: 📋 title: Enhanced Display details: Beautiful tables with responsive layout and smart formatting - icon: 📄 title: JSON Output details: Export data in structured JSON format for programmatic use link: /guide/json-output - icon: 💰 title: Cost Analysis details: Shows estimated costs in USD for each day/month/session - icon: 🔄 title: Cache Support details: Tracks cache creation and cache read tokens separately - icon: 🌐 title: Offline Mode details: Use pre-cached pricing data without network connectivity - icon: 🔌 title: MCP Integration details: Built-in Model Context Protocol server for tool integration link: /guide/mcp-server ---

Support ccusage

If you find ccusage helpful, please consider sponsoring the development!

Featured Sponsor

Check out ccusage: The Claude Code cost scorecard that went viral

ccusage: The Claude Code cost scorecard that went viral
================================================ FILE: docs/package.json ================================================ { "name": "@ccusage/docs", "type": "module", "version": "18.0.10", "private": true, "description": "Documentation for ccusage", "engines": { "node": ">=20.19.4" }, "scripts": { "build": "pnpm run docs:api && cp ../apps/ccusage/config-schema.json public/config-schema.json && vitepress build", "deploy": "wrangler deploy", "dev": "pnpm run docs:api && cp ../apps/ccusage/config-schema.json public/config-schema.json && vitepress dev", "docs:api": "bun ./update-api-index.ts", "format": "pnpm run lint --fix", "lint": "eslint --cache .", "preview": "vitepress preview", "typecheck": "tsgo --noEmit" }, "devDependencies": { "@ryoppippi/eslint-config": "catalog:lint", "@ryoppippi/vite-plugin-cloudflare-redirect": "catalog:docs", "@types/bun": "catalog:types", "@types/react": "catalog:types", "@typescript/native-preview": "catalog:types", "ccusage": "workspace:^", "eslint": "catalog:lint", "eslint-plugin-format": "catalog:lint", "tinyglobby": "catalog:runtime", "typedoc": "catalog:docs", "typedoc-plugin-markdown": "catalog:docs", "typedoc-vitepress-theme": "catalog:docs", "vitepress": "catalog:docs", "vitepress-plugin-group-icons": "catalog:docs", "vitepress-plugin-llms": "catalog:docs", "wrangler": "catalog:docs" } } ================================================ FILE: docs/tsconfig.json ================================================ { "extends": "./node_modules/ccusage/tsconfig.json", "compilerOptions": { "resolveJsonModule": true, "types": ["bun"], "allowImportingTsExtensions": false, "allowJs": true, "noEmit": true, "skipLibCheck": true }, "include": [".vitepress/**/*", "**/*.ts", "**/*.md"], "exclude": ["node_modules", ".vitepress/cache", ".vitepress/dist"] } ================================================ FILE: docs/typedoc.config.ts ================================================ import type { TypeDocOptions } from 'typedoc'; import type { PluginOptions } from 'typedoc-plugin-markdown'; import { globSync } from 'tinyglobby'; type TypedocConfig = TypeDocOptions & PluginOptions & { docsRoot?: string }; const entryPoints = [ ...globSync( [ './node_modules/ccusage/src/*.ts', '!./node_modules/ccusage/src/**/*.test.ts', // Exclude test files '!./node_modules/ccusage/src/_*.ts', // Exclude internal files with underscore prefix ], { absolute: false, onlyFiles: true, }, ), './node_modules/ccusage/src/_consts.ts', // Include constants for documentation ]; export default { // typedoc options // ref: https://typedoc.org/documents/Options.html entryPoints, tsconfig: './node_modules/ccusage/tsconfig.json', out: 'api', plugin: ['typedoc-plugin-markdown', 'typedoc-vitepress-theme'], readme: 'none', excludeInternal: true, groupOrder: ['Variables', 'Functions', 'Class'], categoryOrder: ['*', 'Other'], sort: ['source-order'], // typedoc-plugin-markdown options // ref: https://typedoc-plugin-markdown.org/docs/options entryFileName: 'index', hidePageTitle: false, useCodeBlocks: true, disableSources: true, indexFormat: 'table', parametersFormat: 'table', interfacePropertiesFormat: 'table', classPropertiesFormat: 'table', propertyMembersFormat: 'table', typeAliasPropertiesFormat: 'table', enumMembersFormat: 'table', // typedoc-vitepress-theme options // ref: https://typedoc-plugin-markdown.org/plugins/vitepress/options docsRoot: '.', } satisfies TypedocConfig; ================================================ FILE: docs/update-api-index.ts ================================================ #!/usr/bin/env bun /* eslint-disable antfu/no-top-level-await */ /* eslint-disable no-console */ /** * Post-processing script to update API index with module descriptions */ import { join } from 'node:path'; import process from 'node:process'; const descriptions = { '\\_consts': 'Internal constants (not exported in public API)', 'calculate-cost': 'Cost calculation utilities for usage data analysis', 'data-loader': 'Data loading utilities for Claude Code usage analysis', debug: 'Debug utilities for cost calculation validation', index: 'Main entry point for ccusage CLI tool', logger: 'Logging utilities for the ccusage application', 'pricing-fetcher': 'Model pricing data fetcher for cost calculations', } as const; async function updateApiIndex() { const apiIndexPath = join(import.meta.dirname, 'api', 'index.md'); try { let content = await Bun.file(apiIndexPath).text(); // Replace empty descriptions with actual ones for (const [module, description] of Object.entries(descriptions)) { let linkPath = `${module}/index.md`; // Special case for _consts which links to consts/ if (module === '\\_consts') { linkPath = 'consts/index.md'; } const oldPattern = new RegExp( `\\|\\s*\\[${module}\\]\\(${linkPath}\\)\\s*\\|\\s*-\\s*\\|`, 'g', ); content = content.replace(oldPattern, `| [${module}](${linkPath}) | ${description} |`); } await Bun.write(apiIndexPath, content); console.log('✅ Updated API index with module descriptions'); } catch (error) { console.error('❌ Failed to update API index:', error); process.exit(1); } } async function updateConstsPage() { const constsIndexPath = join(import.meta.dirname, 'api', 'consts', 'index.md'); try { let content = await Bun.file(constsIndexPath).text(); // Add note about constants not being exported (only if not already present) const noteText = '> **Note**: These constants are internal implementation details and are not exported in the public API. They are documented here for reference purposes only.'; if (!content.includes(noteText)) { const oldHeader = '# \\_consts'; const newHeader = `# \\_consts ${noteText}`; content = content.replace(oldHeader, newHeader); } await Bun.write(constsIndexPath, content); console.log('✅ Updated constants page with disclaimer'); } catch (error) { console.error('❌ Failed to update constants page:', error); // Don't exit here as this is optional } } if (import.meta.main) { await Bun.$`bun -b typedoc --excludeInternal --options ./typedoc.config.ts`; await updateApiIndex(); await updateConstsPage(); } ================================================ FILE: docs/wrangler.jsonc ================================================ { "$schema": "../node_modules/wrangler/config-schema.json", "name": "ccusage-guide", "compatibility_date": "2025-07-21", "build": { "command": "pnpm run build", }, "assets": { "directory": ".vitepress/dist/", "not_found_handling": "404-page", }, } ================================================ FILE: eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; export default ryoppippi({ type: 'lib', stylistic: false, ignores: ['apps', 'packages', 'docs', '.claude/settings.local.json'], }); ================================================ FILE: flake.nix ================================================ { description = "Usage analysis tool for Claude Code"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; outputs = { nixpkgs, ... }: let systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); in { devShells = forAllSystems (pkgs: { default = pkgs.mkShellNoCC { buildInputs = with pkgs; [ # Package manager pnpm_10 # Development tools typos typos-lsp jq git gh ]; shellHook = '' # Install dependencies only if node_modules/.pnpm/lock.yaml is older than pnpm-lock.yaml if [ ! -f node_modules/.pnpm/lock.yaml ] || [ pnpm-lock.yaml -nt node_modules/.pnpm/lock.yaml ]; then echo "📦 Installing dependencies..." pnpm install --frozen-lockfile fi ''; }; }); }; } ================================================ FILE: package.json ================================================ { "name": "ccusage-monorepo", "type": "module", "version": "18.0.10", "private": true, "workspaces": [ "apps/*", "docs" ], "packageManager": "pnpm@10.30.1", "engines": { "runtime": [ { "name": "node", "version": "^24.13.0", "onFail": "download" }, { "name": "bun", "version": "^1.3.9", "onFail": "download" } ] }, "scripts": { "build": "pnpm run --filter '*' build", "docs:dev": "pnpm run --filter docs dev", "format": "pnpm --aggregate-output /^format:/", "format:oxfmt": "oxfmt --write .", "format:root": "pnpm run lint:root --fix", "format:submodules": "pnpm --parallel -r --aggregate-output /^format/", "preinstall": "npx only-allow pnpm", "lint": "pnpm --no-bail --aggregate-output /^lint:/", "lint:oxfmt": "oxfmt --check .", "lint:root": "eslint --cache eslint.config.js .", "lint:submodules": "pnpm --parallel -r --no-bail /^lint/", "prepare": "pnpm run /^prepare:/", "prepare:git": "git config --local core.hooksPath .githooks", "prerelease": "pnpm run --filter '*' prerelease", "release": "pnpm bumpp -r", "postrelease": "git checkout ./**/package.json package.json", "test": "pnpm run --filter '*' test", "typecheck": "pnpm run --filter '*' typecheck" }, "dependencies": {}, "devDependencies": { "@gunshi/docs": "catalog:llm-docs", "@praha/byethrow-docs": "catalog:llm-docs", "@ryoppippi/eslint-config": "catalog:lint", "@typescript/native-preview": "catalog:types", "bumpp": "catalog:release", "changelogithub": "catalog:release", "eslint": "catalog:lint", "lint-staged": "catalog:release", "oxfmt": "catalog:lint", "pkg-pr-new": "catalog:release" }, "lint-staged": { "*": [ "pnpm run format" ] } } ================================================ FILE: packages/internal/CLAUDE.md ================================================ # CLAUDE.md - Internal Package This package contains shared internal utilities for the ccusage monorepo. ## Package Overview **Name**: `@ccusage/internal` **Description**: Shared internal utilities for ccusage toolchain **Type**: Internal library (private package) ## Important Notes **CRITICAL**: This is an internal package that gets bundled into the final applications. Therefore: - **Always add this package as a `devDependency`** in apps that use it, NOT as a regular dependency - Apps in this monorepo (ccusage, mcp, codex) are bundled CLIs, so all their runtime dependencies should be in `devDependencies` - The bundler will include the code from this package in the final output ## Available Exports **Utilities:** - `./pricing` - LiteLLM pricing fetcher and utilities - `./pricing-fetch-utils` - Pricing fetch helper functions - `./logger` - Logger factory using consola with LOG_LEVEL support - `./format` - Number formatting utilities (formatTokens, formatCurrency) - `./constants` - Shared constants (DEFAULT_LOCALE, MILLION) ## Development Commands - `pnpm run test` - Run tests - `pnpm run lint` - Lint code - `pnpm run format` - Format and auto-fix code - `pnpm typecheck` - Type check with TypeScript ## Adding New Utilities When adding new shared utilities: 1. Create the utility file in `src/` 2. Add the export to `package.json` exports field 3. Import in consuming apps as `devDependencies`: ```json "devDependencies": { "@ccusage/internal": "workspace:*" } ``` 4. Use the utility: ```typescript import { createLogger } from '@ccusage/internal/logger'; ``` ## Dependencies This package has minimal runtime dependencies that get bundled: - `@praha/byethrow` - Functional error handling - `consola` - Logging - `valibot` - Schema validation ## Pricing Implementation Notes ### Tiered Pricing Support LiteLLM supports tiered pricing for large context window models. Not all models use tiered pricing: **Models WITH tiered pricing:** - **Claude/Anthropic models**: 200k token threshold - Fields: `input_cost_per_token_above_200k_tokens`, `output_cost_per_token_above_200k_tokens` - Cache fields: `cache_creation_input_token_cost_above_200k_tokens`, `cache_read_input_token_cost_above_200k_tokens` - ✅ Currently implemented in cost calculation logic - **Gemini models**: 128k token threshold - Fields: `input_cost_per_token_above_128k_tokens`, `output_cost_per_token_above_128k_tokens` - ⚠️ Schema supports these fields but calculation logic NOT implemented - Would require different threshold handling if Gemini support is added **Models WITHOUT tiered pricing:** - **GPT/OpenAI models**: Flat rate pricing (no token-based tiers) - Note: OpenAI has "tier levels" but these are for API rate limits, not pricing ### ⚠️ IMPORTANT for Future Development When adding support for new models: 1. **Check if the model has tiered pricing** in LiteLLM's schema 2. **Verify the threshold value** (200k for Claude, 128k for Gemini, etc.) 3. **Update calculation logic** if threshold differs from currently implemented 200k 4. **Add comprehensive tests** for boundary conditions at the threshold 5. **Document the pricing structure** in relevant CLAUDE.md files 6. **If cache-specific rates are missing**, fall back to the corresponding input rates (base and above-threshold) to avoid under-charging cached tokens The current implementation in `pricing.ts` only handles 200k threshold. Adding models with different thresholds would require refactoring the `calculateTieredCost` helper function. ## Code Style Follow the same conventions as the main ccusage package: - Use `.ts` extensions for local imports - Prefer `@praha/byethrow Result` type over try-catch - Only export what's actually used by other modules - Use vitest in-source testing with `if (import.meta.vitest != null)` blocks ================================================ FILE: packages/internal/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; /** @type {import('eslint').Linter.FlatConfig[]} */ const config = ryoppippi( { type: 'lib', stylistic: false, }, { rules: { 'test/no-importing-vitest-globals': 'error', }, }, ); export default config; ================================================ FILE: packages/internal/package.json ================================================ { "name": "@ccusage/internal", "type": "module", "version": "18.0.10", "private": true, "description": "Shared internal utilities for ccusage toolchain", "exports": { "./pricing": "./src/pricing.ts", "./pricing-fetch-utils": "./src/pricing-fetch-utils.ts", "./logger": "./src/logger.ts", "./format": "./src/format.ts", "./constants": "./src/constants.ts" }, "scripts": { "format": "pnpm run lint --fix", "lint": "eslint --cache .", "test": "TZ=UTC vitest", "typecheck": "tsgo --noEmit" }, "dependencies": { "@praha/byethrow": "catalog:runtime", "consola": "catalog:runtime", "valibot": "catalog:runtime" }, "devDependencies": { "@ryoppippi/eslint-config": "catalog:lint", "eslint": "catalog:lint", "fs-fixture": "catalog:testing", "vitest": "catalog:testing" } } ================================================ FILE: packages/internal/src/constants.ts ================================================ /** * Default locale for date formatting (en-CA provides YYYY-MM-DD ISO format) * @constant */ export const DEFAULT_LOCALE = 'en-CA'; /** * Common million constant for token calculations * @constant */ export const MILLION = 1_000_000; ================================================ FILE: packages/internal/src/format.ts ================================================ /** * Format a number as tokens with locale-specific formatting * @param value - Token count to format * @returns Formatted token string */ export function formatTokens(value: number): string { return new Intl.NumberFormat('en-US').format(Math.round(value)); } /** * Format a number as USD currency * @param value - Amount in USD * @param locale - Locale for formatting (default: 'en-US') * @returns Formatted currency string */ export function formatCurrency(value: number, locale?: string): string { return new Intl.NumberFormat(locale ?? 'en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 4, maximumFractionDigits: 4, }).format(value); } ================================================ FILE: packages/internal/src/logger.ts ================================================ import type { ConsolaInstance } from 'consola'; import process from 'node:process'; import { consola } from 'consola'; export function createLogger(name: string): ConsolaInstance { const logger: ConsolaInstance = consola.withTag(name); // Apply LOG_LEVEL environment variable if set if (process.env.LOG_LEVEL != null) { const level = Number.parseInt(process.env.LOG_LEVEL, 10); if (!Number.isNaN(level)) { logger.level = level; } } return logger; } // eslint-disable-next-line no-console export const log = console.log; ================================================ FILE: packages/internal/src/pricing-fetch-utils.ts ================================================ import type { LiteLLMModelPricing } from './pricing.ts'; import * as v from 'valibot'; import { LITELLM_PRICING_URL, liteLLMModelPricingSchema } from './pricing.ts'; export type PricingDataset = Record; export function createPricingDataset(): PricingDataset { return Object.create(null) as PricingDataset; } export async function fetchLiteLLMPricingDataset(): Promise { const response = await fetch(LITELLM_PRICING_URL); if (!response.ok) { throw new Error(`Failed to fetch pricing data: ${response.status} ${response.statusText}`); } const rawDataset = (await response.json()) as Record; const dataset = createPricingDataset(); for (const [modelName, modelData] of Object.entries(rawDataset)) { if (modelData == null || typeof modelData !== 'object') { continue; } const parsed = v.safeParse(liteLLMModelPricingSchema, modelData); if (!parsed.success) { continue; } dataset[modelName] = parsed.output; } return dataset; } export function filterPricingDataset( dataset: PricingDataset, predicate: (modelName: string, pricing: LiteLLMModelPricing) => boolean, ): PricingDataset { const filtered = createPricingDataset(); for (const [modelName, pricing] of Object.entries(dataset)) { if (predicate(modelName, pricing)) { filtered[modelName] = pricing; } } return filtered; } ================================================ FILE: packages/internal/src/pricing.ts ================================================ import { Result } from '@praha/byethrow'; import * as v from 'valibot'; export const LITELLM_PRICING_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'; /** * Default token threshold for tiered pricing in 1M context window models. * LiteLLM's pricing schema hard-codes this threshold in field names * (e.g., `input_cost_per_token_above_200k_tokens`). * The threshold parameter in calculateTieredCost allows flexibility for * future models that may use different thresholds. */ const DEFAULT_TIERED_THRESHOLD = 200_000; /** * LiteLLM Model Pricing Schema * * ⚠️ TIERED PRICING NOTE: * Different models use different token thresholds for tiered pricing: * - Claude/Anthropic: 200k tokens (implemented in calculateTieredCost) * - Gemini: 128k tokens (schema fields only, NOT implemented in calculations) * - GPT/OpenAI: No tiered pricing (flat rate) * * When adding support for new models: * 1. Check if model has tiered pricing in LiteLLM data * 2. Verify the threshold value * 3. Update calculateTieredCost logic if threshold differs from 200k * 4. Add tests for tiered pricing boundaries */ export const liteLLMModelPricingSchema = v.object({ input_cost_per_token: v.optional(v.number()), output_cost_per_token: v.optional(v.number()), cache_creation_input_token_cost: v.optional(v.number()), cache_read_input_token_cost: v.optional(v.number()), max_tokens: v.optional(v.number()), max_input_tokens: v.optional(v.number()), max_output_tokens: v.optional(v.number()), // Claude/Anthropic: 1M context window pricing (200k threshold) input_cost_per_token_above_200k_tokens: v.optional(v.number()), output_cost_per_token_above_200k_tokens: v.optional(v.number()), cache_creation_input_token_cost_above_200k_tokens: v.optional(v.number()), cache_read_input_token_cost_above_200k_tokens: v.optional(v.number()), // Gemini: Tiered pricing (128k threshold) - NOT implemented in calculations input_cost_per_token_above_128k_tokens: v.optional(v.number()), output_cost_per_token_above_128k_tokens: v.optional(v.number()), // Provider-specific pricing multipliers (e.g., fast mode, regional pricing) provider_specific_entry: v.optional( v.object({ fast: v.optional(v.number()), }), ), }); export type LiteLLMModelPricing = v.InferOutput; export type PricingLogger = { debug: (...args: unknown[]) => void; error: (...args: unknown[]) => void; info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; }; export type LiteLLMPricingFetcherOptions = { logger?: PricingLogger; offline?: boolean; offlineLoader?: () => Promise>; url?: string; providerPrefixes?: string[]; }; const DEFAULT_PROVIDER_PREFIXES = [ 'anthropic/', 'claude-3-5-', 'claude-3-', 'claude-', 'openai/', 'azure/', 'openrouter/openai/', ]; function createLogger(logger?: PricingLogger): PricingLogger { if (logger != null) { return logger; } return { debug: () => {}, error: () => {}, info: () => {}, warn: () => {}, }; } export class LiteLLMPricingFetcher implements Disposable { private cachedPricing: Map | null = null; private readonly logger: PricingLogger; private readonly offline: boolean; private readonly offlineLoader?: () => Promise>; private readonly url: string; private readonly providerPrefixes: string[]; constructor(options: LiteLLMPricingFetcherOptions = {}) { this.logger = createLogger(options.logger); this.offline = Boolean(options.offline); this.offlineLoader = options.offlineLoader; this.url = options.url ?? LITELLM_PRICING_URL; this.providerPrefixes = options.providerPrefixes ?? DEFAULT_PROVIDER_PREFIXES; } [Symbol.dispose](): void { this.clearCache(); } clearCache(): void { this.cachedPricing = null; } private loadOfflinePricing = Result.try({ try: async () => { if (this.offlineLoader == null) { throw new Error('Offline loader was not provided'); } const pricing = new Map(Object.entries(await this.offlineLoader())); this.cachedPricing = pricing; return pricing; }, catch: (error) => new Error('Failed to load offline pricing data', { cause: error }), }); private async handleFallbackToCachedPricing( originalError: unknown, ): Result.ResultAsync, Error> { this.logger.warn( 'Failed to fetch model pricing from LiteLLM, falling back to cached pricing data', ); this.logger.debug('Fetch error details:', originalError); return Result.pipe( this.loadOfflinePricing(), Result.inspect((pricing) => { this.logger.info(`Using cached pricing data for ${pricing.size} models`); }), Result.inspectError((error) => { this.logger.error('Failed to load cached pricing data as fallback:', error); this.logger.error('Original fetch error:', originalError); }), ); } private async ensurePricingLoaded(): Result.ResultAsync, Error> { return Result.pipe( this.cachedPricing != null ? Result.succeed(this.cachedPricing) : Result.fail(new Error('Cached pricing not available')), Result.orElse(async () => { if (this.offline) { return this.loadOfflinePricing(); } this.logger.warn('Fetching latest model pricing from LiteLLM...'); return Result.pipe( Result.try({ try: fetch(this.url), catch: (error) => new Error('Failed to fetch model pricing from LiteLLM', { cause: error }), }), Result.andThrough((response) => { if (!response.ok) { return Result.fail(new Error(`Failed to fetch pricing data: ${response.statusText}`)); } return Result.succeed(); }), Result.andThen(async (response) => Result.try({ try: response.json() as Promise>, catch: (error) => new Error('Failed to parse pricing data', { cause: error }), }), ), Result.map((data) => { const pricing = new Map(); for (const [modelName, modelData] of Object.entries(data)) { if (typeof modelData !== 'object' || modelData == null) { continue; } const parsed = v.safeParse(liteLLMModelPricingSchema, modelData); if (!parsed.success) { continue; } pricing.set(modelName, parsed.output); } return pricing; }), Result.inspect((pricing) => { this.cachedPricing = pricing; this.logger.info(`Loaded pricing for ${pricing.size} models`); }), Result.orElse(async (error) => this.handleFallbackToCachedPricing(error)), ); }), ); } async fetchModelPricing(): Result.ResultAsync, Error> { return this.ensurePricingLoaded(); } private createMatchingCandidates(modelName: string): string[] { const candidates = new Set(); candidates.add(modelName); for (const prefix of this.providerPrefixes) { candidates.add(`${prefix}${modelName}`); } return Array.from(candidates); } async getModelPricing(modelName: string): Result.ResultAsync { return Result.pipe( this.ensurePricingLoaded(), Result.map((pricing) => { for (const candidate of this.createMatchingCandidates(modelName)) { const direct = pricing.get(candidate); if (direct != null) { return direct; } } const lower = modelName.toLowerCase(); for (const [key, value] of pricing) { const comparison = key.toLowerCase(); if (comparison.includes(lower) || lower.includes(comparison)) { return value; } } return null; }), ); } async getModelContextLimit(modelName: string): Result.ResultAsync { return Result.pipe( this.getModelPricing(modelName), Result.map((pricing) => pricing?.max_input_tokens ?? null), ); } /** * Calculate the total cost for token usage based on model pricing * * Supports tiered pricing for 1M context window models where tokens * above a threshold (default 200k) are charged at a different rate. * Handles all token types: input, output, cache creation, and cache read. * * @param tokens - Token counts for different types * @param tokens.input_tokens - Number of input tokens * @param tokens.output_tokens - Number of output tokens * @param tokens.cache_creation_input_tokens - Number of cache creation input tokens * @param tokens.cache_read_input_tokens - Number of cache read input tokens * @param pricing - Model pricing information from LiteLLM * @returns Total cost in USD */ calculateCostFromPricing( tokens: { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; }, pricing: LiteLLMModelPricing, ): number { /** * Calculate cost with tiered pricing for 1M context window models * * @param totalTokens - Total number of tokens to calculate cost for * @param basePrice - Price per token for tokens up to the threshold * @param tieredPrice - Price per token for tokens above the threshold * @param threshold - Token threshold for tiered pricing (default 200k) * @returns Total cost applying tiered pricing when applicable * * @example * // 300k tokens with base price $3/M and tiered price $6/M * calculateTieredCost(300_000, 3e-6, 6e-6) * // Returns: (200_000 * 3e-6) + (100_000 * 6e-6) = $1.2 */ const calculateTieredCost = ( totalTokens: number | undefined, basePrice: number | undefined, tieredPrice: number | undefined, threshold: number = DEFAULT_TIERED_THRESHOLD, ): number => { if (totalTokens == null || totalTokens <= 0) { return 0; } if (totalTokens > threshold && tieredPrice != null) { const tokensBelowThreshold = Math.min(totalTokens, threshold); const tokensAboveThreshold = Math.max(0, totalTokens - threshold); let tieredCost = tokensAboveThreshold * tieredPrice; if (basePrice != null) { tieredCost += tokensBelowThreshold * basePrice; } return tieredCost; } if (basePrice != null) { return totalTokens * basePrice; } return 0; }; const inputCost = calculateTieredCost( tokens.input_tokens, pricing.input_cost_per_token, pricing.input_cost_per_token_above_200k_tokens, ); const outputCost = calculateTieredCost( tokens.output_tokens, pricing.output_cost_per_token, pricing.output_cost_per_token_above_200k_tokens, ); const cacheCreationCost = calculateTieredCost( tokens.cache_creation_input_tokens, pricing.cache_creation_input_token_cost, pricing.cache_creation_input_token_cost_above_200k_tokens, ); const cacheReadCost = calculateTieredCost( tokens.cache_read_input_tokens, pricing.cache_read_input_token_cost, pricing.cache_read_input_token_cost_above_200k_tokens, ); return inputCost + outputCost + cacheCreationCost + cacheReadCost; } async calculateCostFromTokens( tokens: { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; }, modelName?: string, options?: { speed?: 'standard' | 'fast' }, ): Result.ResultAsync { if (modelName == null || modelName === '') { return Result.succeed(0); } return Result.pipe( this.getModelPricing(modelName), Result.andThen((pricing) => { if (pricing == null) { return Result.fail(new Error(`Model pricing not found for ${modelName}`)); } const baseCost = this.calculateCostFromPricing(tokens, pricing); const multiplier = options?.speed === 'fast' ? (pricing.provider_specific_entry?.fast ?? 1) : 1; return Result.succeed(baseCost * multiplier); }), ); } } if (import.meta.vitest != null) { describe('LiteLLMPricingFetcher', () => { it('returns pricing data from LiteLLM dataset', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'gpt-5': { input_cost_per_token: 1.25e-6, output_cost_per_token: 1e-5, cache_read_input_token_cost: 1.25e-7, }, }), }); const pricing = await Result.unwrap(fetcher.fetchModelPricing()); expect(pricing.size).toBe(1); }); it('calculates cost using pricing information', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'gpt-5': { input_cost_per_token: 1.25e-6, output_cost_per_token: 1e-5, cache_read_input_token_cost: 1.25e-7, }, }), }); const cost = await Result.unwrap( fetcher.calculateCostFromTokens( { input_tokens: 1000, output_tokens: 500, cache_read_input_tokens: 200, }, 'gpt-5', ), ); expect(cost).toBeCloseTo(1000 * 1.25e-6 + 500 * 1e-5 + 200 * 1.25e-7); }); it('calculates tiered pricing for tokens exceeding 200k threshold (300k input, 250k output, 300k cache creation, 250k cache read)', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'anthropic/claude-4-sonnet-20250514': { input_cost_per_token: 3e-6, output_cost_per_token: 1.5e-5, input_cost_per_token_above_200k_tokens: 6e-6, output_cost_per_token_above_200k_tokens: 2.25e-5, cache_creation_input_token_cost: 3.75e-6, cache_read_input_token_cost: 3e-7, cache_creation_input_token_cost_above_200k_tokens: 7.5e-6, cache_read_input_token_cost_above_200k_tokens: 6e-7, }, }), }); // Test comprehensive scenario with all token types above 200k threshold const cost = await Result.unwrap( fetcher.calculateCostFromTokens( { input_tokens: 300_000, output_tokens: 250_000, cache_creation_input_tokens: 300_000, cache_read_input_tokens: 250_000, }, 'anthropic/claude-4-sonnet-20250514', ), ); const expectedCost = 200_000 * 3e-6 + 100_000 * 6e-6 + // input 200_000 * 1.5e-5 + 50_000 * 2.25e-5 + // output 200_000 * 3.75e-6 + 100_000 * 7.5e-6 + // cache creation 200_000 * 3e-7 + 50_000 * 6e-7; // cache read expect(cost).toBeCloseTo(expectedCost); }); it('uses standard pricing for 300k/250k tokens when model lacks tiered pricing', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'gpt-5': { input_cost_per_token: 1e-6, output_cost_per_token: 2e-6, }, }), }); // Should use normal pricing for all tokens const cost = await Result.unwrap( fetcher.calculateCostFromTokens( { input_tokens: 300_000, output_tokens: 250_000, }, 'gpt-5', ), ); expect(cost).toBeCloseTo(300_000 * 1e-6 + 250_000 * 2e-6); }); it('correctly applies pricing at 200k boundary (200k uses base, 200,001 uses tiered, 0 returns 0)', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'claude-4-sonnet-20250514': { input_cost_per_token: 3e-6, input_cost_per_token_above_200k_tokens: 6e-6, }, }), }); // Test with exactly 200k tokens (should use only base price) const cost200k = await Result.unwrap( fetcher.calculateCostFromTokens( { input_tokens: 200_000, output_tokens: 0, }, 'claude-4-sonnet-20250514', ), ); expect(cost200k).toBeCloseTo(200_000 * 3e-6); // Test with 200,001 tokens (should use tiered pricing for 1 token) const cost200k1 = await Result.unwrap( fetcher.calculateCostFromTokens( { input_tokens: 200_001, output_tokens: 0, }, 'claude-4-sonnet-20250514', ), ); expect(cost200k1).toBeCloseTo(200_000 * 3e-6 + 1 * 6e-6); // Test with 0 tokens (should return 0) const costZero = await Result.unwrap( fetcher.calculateCostFromTokens( { input_tokens: 0, output_tokens: 0, }, 'claude-4-sonnet-20250514', ), ); expect(costZero).toBe(0); }); it('charges only for tokens above 200k when base price is missing (300k→100k charged, 100k→0 charged)', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'theoretical-model': { // No base price, only tiered pricing input_cost_per_token_above_200k_tokens: 6e-6, output_cost_per_token_above_200k_tokens: 2.25e-5, }, }), }); // Test with 300k tokens - should only charge for tokens above 200k const cost = await Result.unwrap( fetcher.calculateCostFromTokens( { input_tokens: 300_000, output_tokens: 250_000, }, 'theoretical-model', ), ); // Only 100k input tokens above 200k are charged // Only 50k output tokens above 200k are charged expect(cost).toBeCloseTo(100_000 * 6e-6 + 50_000 * 2.25e-5); // Test with tokens below threshold - should return 0 (no base price) const costBelow = await Result.unwrap( fetcher.calculateCostFromTokens( { input_tokens: 100_000, output_tokens: 100_000, }, 'theoretical-model', ), ); expect(costBelow).toBe(0); }); it('applies fast speed multiplier from provider_specific_entry', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'claude-opus-4-6': { input_cost_per_token: 5e-6, output_cost_per_token: 2.5e-5, provider_specific_entry: { fast: 6.0 }, }, }), }); const tokens = { input_tokens: 1000, output_tokens: 500 }; const standardCost = await Result.unwrap( fetcher.calculateCostFromTokens(tokens, 'claude-opus-4-6'), ); const fastCost = await Result.unwrap( fetcher.calculateCostFromTokens(tokens, 'claude-opus-4-6', { speed: 'fast' }), ); const expectedStandard = 1000 * 5e-6 + 500 * 2.5e-5; expect(standardCost).toBeCloseTo(expectedStandard); expect(fastCost).toBeCloseTo(expectedStandard * 6); }); it('defaults to 1x multiplier when provider_specific_entry has no fast field', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'claude-sonnet-4-6': { input_cost_per_token: 3e-6, output_cost_per_token: 1.5e-5, }, }), }); const tokens = { input_tokens: 1000, output_tokens: 500 }; const standardCost = await Result.unwrap( fetcher.calculateCostFromTokens(tokens, 'claude-sonnet-4-6'), ); const fastCost = await Result.unwrap( fetcher.calculateCostFromTokens(tokens, 'claude-sonnet-4-6', { speed: 'fast' }), ); expect(fastCost).toBeCloseTo(standardCost); }); it('does not apply multiplier when speed is standard', async () => { using fetcher = new LiteLLMPricingFetcher({ offline: true, offlineLoader: async () => ({ 'claude-opus-4-6': { input_cost_per_token: 5e-6, output_cost_per_token: 2.5e-5, provider_specific_entry: { fast: 6.0 }, }, }), }); const tokens = { input_tokens: 1000, output_tokens: 500 }; const noSpeedCost = await Result.unwrap( fetcher.calculateCostFromTokens(tokens, 'claude-opus-4-6'), ); const standardCost = await Result.unwrap( fetcher.calculateCostFromTokens(tokens, 'claude-opus-4-6', { speed: 'standard' }), ); expect(standardCost).toBeCloseTo(noSpeedCost); }); }); } ================================================ FILE: packages/internal/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "lib": ["ESNext"], "moduleDetection": "force", "module": "Preserve", "moduleResolution": "bundler", "types": ["vitest/globals", "vitest/importMeta"], "allowImportingTsExtensions": true, "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, "noUnusedLocals": false, "noUnusedParameters": false, "noEmit": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true }, "exclude": ["dist"] } ================================================ FILE: packages/internal/vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { watch: false, includeSource: ['src/**/*.{js,ts}'], globals: true, }, }); ================================================ FILE: packages/terminal/CLAUDE.md ================================================ # CLAUDE.md - Terminal Package This package provides terminal utilities for the ccusage toolchain. ## Package Overview **Name**: `@ccusage/terminal` **Description**: Terminal utilities for ccusage **Type**: Internal library package (private) ## Development Commands **Testing and Quality:** - `pnpm run test` - Run all tests using vitest - `pnpm run lint` - Lint code using ESLint - `pnpm run format` - Format and auto-fix code with ESLint - `pnpm typecheck` - Type check with TypeScript ## Architecture This package contains terminal utilities used across the ccusage monorepo: **Key Modules:** - `src/table.ts` - Table formatting and rendering utilities - `src/utils.ts` - General terminal utilities **Exports:** - `./table` - Table formatting utilities - `./utils` - Terminal utility functions ## Dependencies **Runtime Dependencies:** - `@oxc-project/runtime` - Runtime utilities - `ansi-escapes` - ANSI escape sequences for terminal manipulation - `cli-table3` - Table formatting for terminal output - `es-toolkit` - Modern JavaScript utility library - `picocolors` - Terminal color support - `string-width` - Get the visual width of strings **Dev Dependencies:** - `vitest` - Testing framework - `eslint` - Linting and formatting ## Testing Guidelines - **In-Source Testing**: Tests are written in the same files using `if (import.meta.vitest != null)` blocks - **Vitest Globals Enabled**: Use `describe`, `it`, `expect` directly without imports - **CRITICAL**: NEVER use `await import()` dynamic imports anywhere, especially in test blocks ## Code Style Follow the same code style guidelines as the main ccusage package: - **Error Handling**: Prefer functional error handling patterns - **Imports**: Use `.ts` extensions for local imports - **Exports**: Only export what's actually used - **No console.log**: Terminal output should be handled through proper utilities **Post-Change Workflow:** Always run these commands in parallel after code changes: - `pnpm run format` - Auto-fix and format - `pnpm typecheck` - Type checking - `pnpm run test` - Run tests ## Important Notes This is a private internal package and should not be published to npm. It exists solely to provide terminal utilities for other packages in the monorepo. ================================================ FILE: packages/terminal/eslint.config.js ================================================ import { ryoppippi } from '@ryoppippi/eslint-config'; export default ryoppippi({ stylistic: false, }); ================================================ FILE: packages/terminal/package.json ================================================ { "name": "@ccusage/terminal", "type": "module", "version": "18.0.10", "private": true, "description": "Terminal utilities for ccusage", "exports": { "./table": "./src/table.ts", "./utils": "./src/utils.ts" }, "scripts": { "format": "pnpm run lint --fix", "lint": "eslint --cache .", "test": "TZ=UTC vitest", "typecheck": "tsgo --noEmit" }, "dependencies": { "@oxc-project/runtime": "catalog:build", "ansi-escapes": "catalog:runtime", "cli-table3": "catalog:runtime", "es-toolkit": "catalog:runtime", "picocolors": "catalog:runtime", "string-width": "catalog:runtime" }, "devDependencies": { "@ryoppippi/eslint-config": "catalog:lint", "eslint": "catalog:lint", "eslint-plugin-format": "catalog:lint", "vitest": "catalog:testing" } } ================================================ FILE: packages/terminal/src/table.ts ================================================ import process from 'node:process'; import Table from 'cli-table3'; import { uniq } from 'es-toolkit'; import pc from 'picocolors'; import stringWidth from 'string-width'; /** * Default locale used for date formatting when not specified * en-CA provides YYYY-MM-DD ISO format */ const DEFAULT_LOCALE = 'en-CA'; /** * Creates a date parts formatter with the specified timezone and locale * @param timezone - Timezone to use * @param locale - Locale to use for formatting * @returns Intl.DateTimeFormat instance */ function createDatePartsFormatter( timezone: string | undefined, locale: string, ): Intl.DateTimeFormat { return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: timezone, }); } /** * Formats a date string to compact format with year on first line and month-day on second * @param dateStr - Input date string (YYYY-MM-DD or ISO timestamp) * @param timezone - Timezone to use for formatting (pass undefined to use system timezone) * @param locale - Locale to use for formatting (defaults to sv-SE for YYYY-MM-DD format) * @returns Formatted date string with newline separator (YYYY\nMM-DD) */ export function formatDateCompact(dateStr: string, timezone?: string, locale?: string): string { // Check if input is in YYYY-MM-DD format const isSimpleDateFormat = /^\d{4}-\d{2}-\d{2}$/.test(dateStr); // For YYYY-MM-DD format, append T00:00:00 to parse as local date // Without this, new Date('YYYY-MM-DD') interprets as UTC midnight const date = isSimpleDateFormat ? timezone != null ? new Date(`${dateStr}T00:00:00Z`) : new Date(`${dateStr}T00:00:00`) : new Date(dateStr); const formatter = createDatePartsFormatter(timezone, locale ?? DEFAULT_LOCALE); const parts = formatter.formatToParts(date); const year = parts.find((p) => p.type === 'year')?.value ?? ''; const month = parts.find((p) => p.type === 'month')?.value ?? ''; const day = parts.find((p) => p.type === 'day')?.value ?? ''; return `${year}\n${month}-${day}`; } /** * Horizontal alignment options for table cells */ export type TableCellAlign = 'left' | 'right' | 'center'; /** * Table row data type supporting strings, numbers, and formatted cell objects */ export type TableRow = (string | number | { content: string; hAlign?: TableCellAlign })[]; /** * Configuration options for creating responsive tables */ export type TableOptions = { head: string[]; colAligns?: TableCellAlign[]; style?: { head?: string[]; }; dateFormatter?: (dateStr: string) => string; compactHead?: string[]; compactColAligns?: TableCellAlign[]; compactThreshold?: number; forceCompact?: boolean; logger?: (message: string) => void; }; /** * Responsive table class that adapts column widths based on terminal size * Automatically adjusts formatting and layout for different screen sizes */ export class ResponsiveTable { private head: string[]; private rows: TableRow[] = []; private colAligns: TableCellAlign[]; private style?: { head?: string[] }; private dateFormatter?: (dateStr: string) => string; private compactHead?: string[]; private compactColAligns?: TableCellAlign[]; private compactThreshold: number; private compactMode = false; private forceCompact: boolean; private logger: (message: string) => void; /** * Creates a new responsive table instance * @param options - Table configuration options */ constructor(options: TableOptions) { this.head = options.head; this.colAligns = options.colAligns ?? Array.from({ length: this.head.length }, () => 'left'); this.style = options.style; this.dateFormatter = options.dateFormatter; this.compactHead = options.compactHead; this.compactColAligns = options.compactColAligns; this.compactThreshold = options.compactThreshold ?? 100; this.forceCompact = options.forceCompact ?? false; this.logger = options.logger ?? console.warn; } /** * Adds a row to the table * @param row - Row data to add */ push(row: TableRow): void { this.rows.push(row); } /** * Filters a row to compact mode columns * @param row - Row to filter * @param compactIndices - Indices of columns to keep in compact mode * @returns Filtered row */ private filterRowToCompact(row: TableRow, compactIndices: number[]): TableRow { return compactIndices.map((index) => row[index] ?? ''); } /** * Gets the current table head and col aligns based on compact mode * @returns Current head and colAligns arrays */ private getCurrentTableConfig(): { head: string[]; colAligns: TableCellAlign[] } { if (this.compactMode && this.compactHead != null && this.compactColAligns != null) { return { head: this.compactHead, colAligns: this.compactColAligns }; } return { head: this.head, colAligns: this.colAligns }; } /** * Gets indices mapping from full table to compact table * @returns Array of column indices to keep in compact mode */ private getCompactIndices(): number[] { if (this.compactHead == null || !this.compactMode) { return Array.from({ length: this.head.length }, (_, i) => i); } // Map compact headers to original indices return this.compactHead.map((compactHeader) => { const index = this.head.indexOf(compactHeader); if (index < 0) { // Log warning for debugging configuration issues this.logger( `Warning: Compact header "${compactHeader}" not found in table headers [${this.head.join(', ')}]. Using first column as fallback.`, ); return 0; // fallback to first column if not found } return index; }); } /** * Returns whether the table is currently in compact mode * @returns True if compact mode is active */ isCompactMode(): boolean { return this.compactMode; } /** * Renders the table as a formatted string * Automatically adjusts layout based on terminal width * @returns Formatted table string */ toString(): string { // Check environment variable first, then process.stdout.columns, then default const terminalWidth = Number.parseInt(process.env.COLUMNS ?? '', 10) || process.stdout.columns || 120; // Determine if we should use compact mode this.compactMode = this.forceCompact || (terminalWidth < this.compactThreshold && this.compactHead != null); // Get current table configuration const { head, colAligns } = this.getCurrentTableConfig(); const compactIndices = this.getCompactIndices(); // Calculate actual content widths first (excluding separator rows) const dataRows = this.rows.filter((row) => !this.isSeparatorRow(row)); // Filter rows to compact mode if needed const processedDataRows = this.compactMode ? dataRows.map((row) => this.filterRowToCompact(row, compactIndices)) : dataRows; const allRows = [ head.map(String), ...processedDataRows.map((row) => row.map((cell) => { if (typeof cell === 'object' && cell != null && 'content' in cell) { return String(cell.content); } return String(cell ?? ''); }), ), ]; const contentWidths = head.map((_, colIndex) => { const maxLength = Math.max(...allRows.map((row) => stringWidth(String(row[colIndex] ?? '')))); return maxLength; }); // Calculate table overhead const numColumns = head.length; const tableOverhead = 3 * numColumns + 1; // borders and separators const availableWidth = terminalWidth - tableOverhead; // Always use content-based widths with generous padding for numeric columns const columnWidths = contentWidths.map((width, index) => { const align = colAligns[index]; // For numeric columns, ensure generous width to prevent truncation if (align === 'right') { return Math.max(width + 3, 11); // At least 11 chars for numbers, +3 padding } else if (index === 1) { // Models column - can be longer return Math.max(width + 2, 15); } return Math.max(width + 2, 10); // Other columns }); // Check if this fits in the terminal const totalRequiredWidth = columnWidths.reduce((sum, width) => sum + width, 0) + tableOverhead; if (totalRequiredWidth > terminalWidth) { // Apply responsive resizing and use compact date format if available const scaleFactor = availableWidth / columnWidths.reduce((sum, width) => sum + width, 0); const adjustedWidths = columnWidths.map((width, index) => { const align = colAligns[index]; let adjustedWidth = Math.floor(width * scaleFactor); // Apply minimum widths based on column type if (align === 'right') { adjustedWidth = Math.max(adjustedWidth, 10); } else if (index === 0) { adjustedWidth = Math.max(adjustedWidth, 10); } else if (index === 1) { adjustedWidth = Math.max(adjustedWidth, 12); } else { adjustedWidth = Math.max(adjustedWidth, 8); } return adjustedWidth; }); const table = new Table({ head, style: this.style, colAligns, colWidths: adjustedWidths, wordWrap: true, wrapOnWordBoundary: true, }); // Add rows with special handling for separators and date formatting for (const row of this.rows) { if (this.isSeparatorRow(row)) { // Skip separator rows - cli-table3 will handle borders automatically continue; } else { // Use compact date format for first column if dateFormatter available let processedRow = row.map((cell, index) => { if ( index === 0 && this.dateFormatter != null && typeof cell === 'string' && this.isDateString(cell) ) { return this.dateFormatter(cell); } return cell; }); // Filter to compact columns if in compact mode if (this.compactMode) { processedRow = this.filterRowToCompact(processedRow, compactIndices); } table.push(processedRow); } } return table.toString(); } else { // Use generous column widths with normal date format const table = new Table({ head, style: this.style, colAligns, colWidths: columnWidths, wordWrap: true, wrapOnWordBoundary: true, }); // Add rows with special handling for separators for (const row of this.rows) { if (this.isSeparatorRow(row)) { // Skip separator rows - cli-table3 will handle borders automatically continue; } else { // Filter to compact columns if in compact mode const processedRow = this.compactMode ? this.filterRowToCompact(row, compactIndices) : row; table.push(processedRow); } } return table.toString(); } } /** * Checks if a row is a separator row (contains only empty cells or dashes) * @param row - Row to check * @returns True if the row is a separator */ private isSeparatorRow(row: TableRow): boolean { // Check for both old-style separator rows (─) and new-style empty rows return row.every((cell) => { if (typeof cell === 'object' && cell != null && 'content' in cell) { return cell.content === '' || /^─+$/.test(cell.content); } return typeof cell === 'string' && (cell === '' || /^─+$/.test(cell)); }); } /** * Checks if a string matches the YYYY-MM-DD date format * @param text - String to check * @returns True if the string is a valid date format */ private isDateString(text: string): boolean { // Check if string matches date format YYYY-MM-DD return /^\d{4}-\d{2}-\d{2}$/.test(text); } } /** * Formats a number with locale-specific thousand separators * @param num - The number to format * @returns Formatted number string with commas as thousand separators */ export function formatNumber(num: number): string { return num.toLocaleString('en-US'); } /** * Formats a number as USD currency with dollar sign and 2 decimal places * @param amount - The amount to format * @returns Formatted currency string (e.g., "$12.34") */ export function formatCurrency(amount: number): string { return `$${amount.toFixed(2)}`; } /** * Formats Claude model names into a shorter, more readable format * Extracts model type and generation from full model name * @param modelName - Full model name (e.g., "claude-sonnet-4-20250514" or "claude-sonnet-4-5-20250929") * @returns Shortened model name (e.g., "sonnet-4" or "sonnet-4-5") or original if pattern doesn't match */ function formatModelName(modelName: string): string { // Handle [pi] prefix - preserve prefix, format the rest const piMatch = modelName.match(/^\[pi\] (.+)$/); if (piMatch?.[1] != null) { return `[pi] ${formatModelName(piMatch[1])}`; } // Handle anthropic/ prefix with dot notation (e.g., "anthropic/claude-opus-4.5" -> "opus-4.5") const anthropicMatch = modelName.match(/^anthropic\/claude-(\w+)-([\d.]+)$/); if (anthropicMatch != null) { return `${anthropicMatch[1]}-${anthropicMatch[2]}`; } // Extract model type from full model name with date suffix (must check before no-date pattern) // e.g., "claude-sonnet-4-20250514" -> "sonnet-4" // e.g., "claude-opus-4-20250514" -> "opus-4" // e.g., "claude-sonnet-4-5-20250929" -> "sonnet-4-5" const match = modelName.match(/^claude-(\w+)-([\d-]+)-(\d{8})$/); if (match != null) { return `${match[1]}-${match[2]}`; } // Handle claude- without date suffix (e.g., "claude-opus-4-5" -> "opus-4-5") const noDateMatch = modelName.match(/^claude-(\w+)-([\d-]+)$/); if (noDateMatch != null) { return `${noDateMatch[1]}-${noDateMatch[2]}`; } // Return original if pattern doesn't match return modelName; } /** * Formats an array of model names for display as a comma-separated string * Removes duplicates and sorts alphabetically * @param models - Array of model names * @returns Formatted string with unique, sorted model names separated by commas */ export function formatModelsDisplay(models: string[]): string { // Format array of models for display const uniqueModels = uniq(models.map(formatModelName)); return uniqueModels.sort().join(', '); } /** * Formats an array of model names for display with each model on a new line * Removes duplicates and sorts alphabetically * @param models - Array of model names * @returns Formatted string with unique, sorted model names as a bulleted list */ export function formatModelsDisplayMultiline(models: string[]): string { // Format array of models for display with newlines and bullet points const uniqueModels = uniq(models.map(formatModelName)); return uniqueModels .sort() .map((model) => `- ${model}`) .join('\n'); } /** * Pushes model breakdown rows to a table * @param table - The table to push rows to * @param table.push - Method to add rows to the table * @param breakdowns - Array of model breakdowns * @param extraColumns - Number of extra empty columns before the data (default: 1 for models column) * @param trailingColumns - Number of extra empty columns after the data (default: 0) */ export function pushBreakdownRows( table: { push: (row: (string | number)[]) => void }, breakdowns: Array<{ modelName: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; cost: number; }>, extraColumns = 1, trailingColumns = 0, ): void { for (const breakdown of breakdowns) { const row: (string | number)[] = [` └─ ${formatModelName(breakdown.modelName)}`]; // Add extra empty columns before data for (let i = 0; i < extraColumns; i++) { row.push(''); } // Add data columns with gray styling const totalTokens = breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens; row.push( pc.gray(formatNumber(breakdown.inputTokens)), pc.gray(formatNumber(breakdown.outputTokens)), pc.gray(formatNumber(breakdown.cacheCreationTokens)), pc.gray(formatNumber(breakdown.cacheReadTokens)), pc.gray(formatNumber(totalTokens)), pc.gray(formatCurrency(breakdown.cost)), ); // Add trailing empty columns for (let i = 0; i < trailingColumns; i++) { row.push(''); } table.push(row); } } /** * Configuration options for creating usage report tables */ export type UsageReportConfig = { /** Name for the first column (Date, Month, Week, Session, etc.) */ firstColumnName: string; /** Whether to include Last Activity column (for session reports) */ includeLastActivity?: boolean; /** Date formatter function for responsive date formatting */ dateFormatter?: (dateStr: string) => string; /** Force compact mode regardless of terminal width */ forceCompact?: boolean; }; /** * Standard usage data structure for table rows */ export type UsageData = { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalCost: number; modelsUsed?: string[]; }; /** * Creates a standard usage report table with consistent styling and layout * @param config - Configuration options for the table * @returns Configured ResponsiveTable instance */ export function createUsageReportTable(config: UsageReportConfig): ResponsiveTable { const baseHeaders = [ config.firstColumnName, 'Models', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Cost (USD)', ]; const baseAligns: TableCellAlign[] = [ 'left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', ]; const compactHeaders = [config.firstColumnName, 'Models', 'Input', 'Output', 'Cost (USD)']; const compactAligns: TableCellAlign[] = ['left', 'left', 'right', 'right', 'right']; // Add Last Activity column for session reports if (config.includeLastActivity ?? false) { baseHeaders.push('Last Activity'); baseAligns.push('left'); compactHeaders.push('Last Activity'); compactAligns.push('left'); } return new ResponsiveTable({ head: baseHeaders, style: { head: ['cyan'] }, colAligns: baseAligns, dateFormatter: config.dateFormatter, compactHead: compactHeaders, compactColAligns: compactAligns, compactThreshold: 100, forceCompact: config.forceCompact, }); } /** * Formats a usage data row for display in the table * @param firstColumnValue - Value for the first column (date, month, etc.) * @param data - Usage data containing tokens and cost information * @param lastActivity - Optional last activity value (for session reports) * @returns Formatted table row */ export function formatUsageDataRow( firstColumnValue: string, data: UsageData, lastActivity?: string, ): (string | number)[] { const totalTokens = data.inputTokens + data.outputTokens + data.cacheCreationTokens + data.cacheReadTokens; const row: (string | number)[] = [ firstColumnValue, data.modelsUsed != null ? formatModelsDisplayMultiline(data.modelsUsed) : '', formatNumber(data.inputTokens), formatNumber(data.outputTokens), formatNumber(data.cacheCreationTokens), formatNumber(data.cacheReadTokens), formatNumber(totalTokens), formatCurrency(data.totalCost), ]; if (lastActivity !== undefined) { row.push(lastActivity); } return row; } /** * Creates a totals row with yellow highlighting * @param totals - Totals data to display * @param includeLastActivity - Whether to include an empty last activity column * @returns Formatted totals row */ export function formatTotalsRow( totals: UsageData, includeLastActivity = false, ): (string | number)[] { const totalTokens = totals.inputTokens + totals.outputTokens + totals.cacheCreationTokens + totals.cacheReadTokens; const row: (string | number)[] = [ pc.yellow('Total'), '', // Empty for Models column in totals pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]; if (includeLastActivity) { row.push(''); // Empty for Last Activity column in totals } return row; } /** * Adds an empty separator row to the table for visual separation * @param table - Table to add separator row to * @param columnCount - Number of columns in the table */ export function addEmptySeparatorRow(table: ResponsiveTable, columnCount: number): void { const emptyRow = Array.from({ length: columnCount }, () => ''); table.push(emptyRow); } if (import.meta.vitest != null) { describe('ResponsiveTable', () => { describe('compact mode behavior', () => { it('should activate compact mode when terminal width is below threshold', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'Model', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate narrow terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '80'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // This triggers compact mode calculation expect(table.isCompactMode()).toBe(true); // Restore original value process.env.COLUMNS = originalColumns; }); it('should not activate compact mode when terminal width is above threshold', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'Model', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate wide terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '120'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // This triggers compact mode calculation expect(table.isCompactMode()).toBe(false); // Restore original value process.env.COLUMNS = originalColumns; }); it('should not activate compact mode when compactHead is not provided', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate narrow terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '80'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // This triggers compact mode calculation expect(table.isCompactMode()).toBe(false); // Restore original value process.env.COLUMNS = originalColumns; }); }); describe('getCurrentTableConfig', () => { it('should return compact config when in compact mode', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], colAligns: ['left', 'left', 'right', 'right', 'right'], compactHead: ['Date', 'Model', 'Cost'], compactColAligns: ['left', 'left', 'right'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate narrow terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '80'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // This triggers compact mode calculation // Access private method for testing // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access const config = (table as any).getCurrentTableConfig(); // eslint-disable-next-line ts/no-unsafe-member-access expect(config.head).toEqual(['Date', 'Model', 'Cost']); // eslint-disable-next-line ts/no-unsafe-member-access expect(config.colAligns).toEqual(['left', 'left', 'right']); // Restore original value process.env.COLUMNS = originalColumns; }); it('should return normal config when not in compact mode', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], colAligns: ['left', 'left', 'right', 'right', 'right'], compactHead: ['Date', 'Model', 'Cost'], compactColAligns: ['left', 'left', 'right'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate wide terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '120'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // This triggers compact mode calculation // Access private method for testing // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access const config = (table as any).getCurrentTableConfig(); // eslint-disable-next-line ts/no-unsafe-member-access expect(config.head).toEqual(['Date', 'Model', 'Input', 'Output', 'Cost']); // eslint-disable-next-line ts/no-unsafe-member-access expect(config.colAligns).toEqual(['left', 'left', 'right', 'right', 'right']); // Restore original value process.env.COLUMNS = originalColumns; }); }); describe('getCompactIndices', () => { it('should return correct indices for existing compact headers', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'Model', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate narrow terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '80'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // This triggers compact mode calculation // Access private method for testing // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access const indices = (table as any).getCompactIndices(); expect(indices).toEqual([0, 1, 4]); // Date (0), Model (1), Cost (4) // Restore original value process.env.COLUMNS = originalColumns; }); it('should fallback to first column for non-existent headers and log warning', () => { // Mock logger.warn to capture warning const mockLogger = vi.fn(); const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'NonExistent', 'Cost'], compactThreshold: 100, logger: mockLogger, }); // Mock process.env.COLUMNS to simulate narrow terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '80'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // This triggers compact mode calculation // Access private method for testing // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access const indices = (table as any).getCompactIndices(); expect(indices).toEqual([0, 0, 4]); // Date (0), fallback to first (0), Cost (4) // Verify warning was logged expect(mockLogger).toHaveBeenCalledWith( 'Warning: Compact header "NonExistent" not found in table headers [Date, Model, Input, Output, Cost]. Using first column as fallback.', ); // Restore original value process.env.COLUMNS = originalColumns; }); it('should return all indices when not in compact mode', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'Model', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate wide terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '120'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // This triggers compact mode calculation // Access private method for testing // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access const indices = (table as any).getCompactIndices(); expect(indices).toEqual([0, 1, 2, 3, 4]); // All columns // Restore original value process.env.COLUMNS = originalColumns; }); it('should return all indices when compactHead is null', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactThreshold: 100, }); // Access private method for testing // eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access const indices = (table as any).getCompactIndices(); expect(indices).toEqual([0, 1, 2, 3, 4]); // All columns }); }); describe('toString with mocked terminal widths', () => { it('should filter columns in compact mode', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate narrow terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '80'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); const output = table.toString(); // Should be in compact mode expect(table.isCompactMode()).toBe(true); // Should contain compact headers expect(output).toContain('Date'); expect(output).toContain('Cost'); // Restore original value process.env.COLUMNS = originalColumns; }); it('should show all columns in normal mode', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS to simulate wide terminal const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '150'; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); const output = table.toString(); // Should contain all headers expect(output).toContain('Date'); expect(output).toContain('Model'); expect(output).toContain('Input'); expect(output).toContain('Output'); expect(output).toContain('Cost'); // Restore original value process.env.COLUMNS = originalColumns; }); it('should handle process.stdout.columns fallback when COLUMNS env var is not set', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS and process.stdout.columns const originalColumns = process.env.COLUMNS; const originalStdoutColumns = process.stdout.columns; process.env.COLUMNS = undefined; // eslint-disable-next-line ts/no-unsafe-member-access (process.stdout as any).columns = 80; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); expect(table.isCompactMode()).toBe(true); // Restore original values process.env.COLUMNS = originalColumns; process.stdout.columns = originalStdoutColumns; }); it('should use default width when both COLUMNS and process.stdout.columns are unavailable', () => { const table = new ResponsiveTable({ head: ['Date', 'Model', 'Input', 'Output', 'Cost'], compactHead: ['Date', 'Cost'], compactThreshold: 100, }); // Mock process.env.COLUMNS and process.stdout.columns const originalColumns = process.env.COLUMNS; const originalStdoutColumns = process.stdout.columns; process.env.COLUMNS = undefined; // eslint-disable-next-line ts/no-unsafe-member-access (process.stdout as any).columns = undefined; table.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']); table.toString(); // Default width is 120, which is above threshold of 100 expect(table.isCompactMode()).toBe(false); // Restore original values process.env.COLUMNS = originalColumns; process.stdout.columns = originalStdoutColumns; }); }); }); describe('formatNumber', () => { it('formats positive numbers with comma separators', () => { expect(formatNumber(1000)).toBe('1,000'); expect(formatNumber(1000000)).toBe('1,000,000'); expect(formatNumber(1234567.89)).toBe('1,234,567.89'); }); it('formats small numbers without separators', () => { expect(formatNumber(0)).toBe('0'); expect(formatNumber(1)).toBe('1'); expect(formatNumber(999)).toBe('999'); }); it('formats negative numbers', () => { expect(formatNumber(-1000)).toBe('-1,000'); expect(formatNumber(-1234567.89)).toBe('-1,234,567.89'); }); it('formats decimal numbers', () => { expect(formatNumber(1234.56)).toBe('1,234.56'); expect(formatNumber(0.123)).toBe('0.123'); }); it('handles edge cases', () => { expect(formatNumber(Number.MAX_SAFE_INTEGER)).toBe('9,007,199,254,740,991'); expect(formatNumber(Number.MIN_SAFE_INTEGER)).toBe('-9,007,199,254,740,991'); }); }); describe('formatCurrency', () => { it('formats positive amounts', () => { expect(formatCurrency(10)).toBe('$10.00'); expect(formatCurrency(100.5)).toBe('$100.50'); expect(formatCurrency(1234.56)).toBe('$1234.56'); }); it('formats zero', () => { expect(formatCurrency(0)).toBe('$0.00'); }); it('formats negative amounts', () => { expect(formatCurrency(-10)).toBe('$-10.00'); expect(formatCurrency(-100.5)).toBe('$-100.50'); }); it('rounds to two decimal places', () => { expect(formatCurrency(10.999)).toBe('$11.00'); expect(formatCurrency(10.994)).toBe('$10.99'); expect(formatCurrency(10.995)).toBe('$10.99'); // JavaScript's toFixed uses banker's rounding }); it('handles small decimal values', () => { expect(formatCurrency(0.01)).toBe('$0.01'); expect(formatCurrency(0.001)).toBe('$0.00'); expect(formatCurrency(0.009)).toBe('$0.01'); }); it('handles large numbers', () => { expect(formatCurrency(1000000)).toBe('$1000000.00'); expect(formatCurrency(9999999.99)).toBe('$9999999.99'); }); }); describe('formatModelsDisplayMultiline', () => { it('formats single model with bullet point', () => { expect(formatModelsDisplayMultiline(['claude-sonnet-4-20250514'])).toBe('- sonnet-4'); }); it('formats multiple models with newlines and bullet points', () => { const models = ['claude-sonnet-4-20250514', 'claude-opus-4-20250514']; expect(formatModelsDisplayMultiline(models)).toBe('- opus-4\n- sonnet-4'); }); it('removes duplicates and sorts with bullet points', () => { const models = [ 'claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-sonnet-4-20250514', ]; expect(formatModelsDisplayMultiline(models)).toBe('- opus-4\n- sonnet-4'); }); it('handles empty array', () => { expect(formatModelsDisplayMultiline([])).toBe(''); }); it('handles models that do not match pattern with bullet points', () => { const models = ['custom-model', 'claude-sonnet-4-20250514']; expect(formatModelsDisplayMultiline(models)).toBe('- custom-model\n- sonnet-4'); }); it('formats Claude 4.5 models correctly', () => { expect(formatModelsDisplayMultiline(['claude-sonnet-4-5-20250929'])).toBe('- sonnet-4-5'); }); it('formats mixed model versions', () => { const models = [ 'claude-sonnet-4-20250514', 'claude-sonnet-4-5-20250929', 'claude-opus-4-1-20250805', ]; expect(formatModelsDisplayMultiline(models)).toBe('- opus-4-1\n- sonnet-4\n- sonnet-4-5'); }); it('formats pi-agent prefixed models', () => { expect(formatModelsDisplayMultiline(['[pi] claude-opus-4-5'])).toBe('- [pi] opus-4-5'); }); it('formats anthropic/ prefixed models with dot notation', () => { expect(formatModelsDisplayMultiline(['anthropic/claude-opus-4.5'])).toBe('- opus-4.5'); }); it('formats models without date suffix', () => { expect(formatModelsDisplayMultiline(['claude-opus-4-5'])).toBe('- opus-4-5'); expect(formatModelsDisplayMultiline(['claude-haiku-4-5'])).toBe('- haiku-4-5'); }); it('formats pi-agent model with anthropic prefix', () => { expect(formatModelsDisplayMultiline(['[pi] anthropic/claude-opus-4.5'])).toBe( '- [pi] opus-4.5', ); }); }); describe('formatDateCompact', () => { it('should format date to compact format with newline', () => { const result = formatDateCompact('2024-08-04', undefined, 'en-US'); expect(result).toBe('2024\n08-04'); }); it('should handle timezone parameter', () => { const result = formatDateCompact('2024-08-04T12:00:00Z', 'UTC', 'en-US'); expect(result).toBe('2024\n08-04'); }); it('should handle YYYY-MM-DD format dates', () => { const result = formatDateCompact('2024-08-04', undefined, 'en-US'); expect(result).toBe('2024\n08-04'); }); it('should handle timezone with YYYY-MM-DD format', () => { const result = formatDateCompact('2024-08-04', 'UTC', 'en-US'); expect(result).toBe('2024\n08-04'); }); it('should use default locale when not specified', () => { const result = formatDateCompact('2024-08-04'); expect(result).toBe('2024\n08-04'); }); }); } ================================================ FILE: packages/terminal/src/utils.ts ================================================ import type { WriteStream } from 'node:tty'; import process from 'node:process'; import * as ansiEscapes from 'ansi-escapes'; import stringWidth from 'string-width'; // DEC synchronized output mode - prevents screen flickering by buffering all terminal writes // until flush() is called. Think of it like double-buffering in graphics programming. // Reference: https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 const SYNC_START = '\x1B[?2026h'; // Start sync mode const SYNC_END = '\x1B[?2026l'; // End sync mode // Line wrap control sequences const DISABLE_LINE_WRAP = '\x1B[?7l'; // Disable automatic line wrapping const ENABLE_LINE_WRAP = '\x1B[?7h'; // Enable automatic line wrapping // ANSI reset sequence const ANSI_RESET = '\u001B[0m'; // Reset all formatting and colors /** * Manages terminal state for live updates * Provides a clean interface for terminal operations with automatic TTY checking * and cursor state management for live monitoring displays */ export class TerminalManager { private stream: WriteStream; private cursorHidden = false; private buffer: string[] = []; private useBuffering = false; private alternateScreenActive = false; private syncMode = false; constructor(stream: WriteStream = process.stdout) { this.stream = stream; } /** * Hides the terminal cursor for cleaner live updates * Only works in TTY environments (real terminals) */ hideCursor(): void { if (!this.cursorHidden && this.stream.isTTY) { // Only hide cursor in TTY environments to prevent issues with non-interactive streams this.stream.write(ansiEscapes.cursorHide); this.cursorHidden = true; } } /** * Shows the terminal cursor * Should be called during cleanup to restore normal terminal behavior */ showCursor(): void { if (this.cursorHidden && this.stream.isTTY) { this.stream.write(ansiEscapes.cursorShow); this.cursorHidden = false; } } /** * Clears the entire screen and moves cursor to top-left corner * Essential for live monitoring displays that need to refresh completely */ clearScreen(): void { if (this.stream.isTTY) { // Only clear screen in TTY environments to prevent issues with non-interactive streams this.stream.write(ansiEscapes.clearScreen); this.stream.write(ansiEscapes.cursorTo(0, 0)); } } /** * Writes text to the terminal stream * Supports buffering mode for performance optimization */ write(text: string): void { if (this.useBuffering) { this.buffer.push(text); } else { this.stream.write(text); } } /** * Enables buffering mode - collects all writes in memory instead of sending immediately * This prevents flickering when doing many rapid updates */ startBuffering(): void { this.useBuffering = true; this.buffer = []; } /** * Sends all buffered content to terminal at once * This creates smooth, atomic updates without flickering */ flush(): void { if (this.useBuffering && this.buffer.length > 0) { // Wrap output in sync mode for truly atomic screen updates if (this.syncMode && this.stream.isTTY) { this.stream.write(SYNC_START + this.buffer.join('') + SYNC_END); } else { this.stream.write(this.buffer.join('')); } this.buffer = []; } this.useBuffering = false; } /** * Switches to alternate screen buffer (like vim/less does) * This preserves what was on screen before and allows full-screen apps */ enterAlternateScreen(): void { if (!this.alternateScreenActive && this.stream.isTTY) { this.stream.write(ansiEscapes.enterAlternativeScreen); // Turn off line wrapping to prevent text from breaking badly this.stream.write(DISABLE_LINE_WRAP); this.alternateScreenActive = true; } } /** * Returns to normal screen, restoring what was there before */ exitAlternateScreen(): void { if (this.alternateScreenActive && this.stream.isTTY) { // Re-enable line wrap this.stream.write(ENABLE_LINE_WRAP); this.stream.write(ansiEscapes.exitAlternativeScreen); this.alternateScreenActive = false; } } /** * Enables sync mode - terminal will wait for END signal before showing updates * Prevents the user from seeing partial/torn screen updates */ enableSyncMode(): void { this.syncMode = true; } /** * Disables synchronized output mode */ disableSyncMode(): void { this.syncMode = false; } /** * Gets terminal width in columns * Falls back to 80 columns if detection fails */ get width(): number { return this.stream.columns || 80; } /** * Gets terminal height in rows * Falls back to 24 rows if detection fails */ get height(): number { return this.stream.rows || 24; } /** * Returns true if output goes to a real terminal (not a file or pipe) * We only send fancy ANSI codes to real terminals */ get isTTY(): boolean { return this.stream.isTTY ?? false; } /** * Restores terminal to normal state - MUST call before program exits * Otherwise user's terminal might be left in a broken state */ cleanup(): void { this.showCursor(); this.exitAlternateScreen(); this.disableSyncMode(); } } /** * Creates a progress bar string with customizable appearance * * Example: createProgressBar(75, 100, 20) -> "[████████████████░░░░] 75.0%" * * @param value - Current progress value * @param max - Maximum value (100% point) * @param width - Character width of the progress bar (excluding brackets and text) * @param options - Customization options for appearance and display * @param options.showPercentage - Whether to show percentage after the bar * @param options.showValues - Whether to show current/max values * @param options.fillChar - Character for filled portion (default: '█') * @param options.emptyChar - Character for empty portion (default: '░') * @param options.leftBracket - Left bracket character (default: '[') * @param options.rightBracket - Right bracket character (default: ']') * @param options.colors - Color configuration for different thresholds * @param options.colors.low - Color for low percentage values * @param options.colors.medium - Color for medium percentage values * @param options.colors.high - Color for high percentage values * @param options.colors.critical - Color for critical percentage values * @returns Formatted progress bar string with optional percentage/values */ export function createProgressBar( value: number, max: number, width: number, options: { showPercentage?: boolean; showValues?: boolean; fillChar?: string; emptyChar?: string; leftBracket?: string; rightBracket?: string; colors?: { low?: string; medium?: string; high?: string; critical?: string; }; } = {}, ): string { const { showPercentage = true, showValues = false, fillChar = '█', emptyChar = '░', leftBracket = '[', rightBracket = ']', colors = {}, } = options; const percentage = max > 0 ? Math.min(100, (value / max) * 100) : 0; const fillWidth = Math.round((percentage / 100) * width); const emptyWidth = width - fillWidth; // Determine color based on percentage let color = ''; if (colors.critical != null && percentage >= 90) { color = colors.critical; } else if (colors.high != null && percentage >= 80) { color = colors.high; } else if (colors.medium != null && percentage >= 50) { color = colors.medium; } else if (colors.low != null) { color = colors.low; } // Build progress bar let bar = leftBracket; if (color !== '') { bar += color; } bar += fillChar.repeat(fillWidth); bar += emptyChar.repeat(emptyWidth); if (color !== '') { bar += ANSI_RESET; // Reset color } bar += rightBracket; // Add percentage or values if (showPercentage) { bar += ` ${percentage.toFixed(1)}%`; } if (showValues) { bar += ` (${value}/${max})`; } return bar; } /** * Centers text within a specified width using spaces for padding * * Uses string-width to handle Unicode characters and ANSI escape codes properly. * If text is longer than width, returns original text without truncation. * * Example: centerText("Hello", 10) -> " Hello " * * @param text - Text to center (may contain ANSI color codes) * @param width - Total character width including padding * @returns Text with spaces added for centering */ export function centerText(text: string, width: number): string { const textLength = stringWidth(text); if (textLength >= width) { return text; } const leftPadding = Math.floor((width - textLength) / 2); const rightPadding = width - textLength - leftPadding; return ' '.repeat(leftPadding) + text + ' '.repeat(rightPadding); } // Using below sequences causes issues in some terminals such as NeoVim. // - ansiEscapes.cursorSavePosition (\u001B[s = Save Cursor) // - ansiEscapes.cursorRestorePosition (\u001B[u = Unsave Cursor) // // Instead, we use: // - \u001B7 = Save Cursor & Attrs // - \u001B8 = Restore Cursor & Attrs // // see: https://www2.ccs.neu.edu/research/gpc/VonaUtils/vona/terminal/vtansi.htm const SAVE_CURSOR = '\u001B7'; const RESTORE_CURSOR = '\u001B8'; /** * Draws an emoji with consistent 2-character width regardless of terminal behavior * @param emoji The emoji to draw * @returns A string containing ANSI escape sequences and the emoji */ export function drawEmoji(emoji: string): string { return `${SAVE_CURSOR}${emoji}${RESTORE_CURSOR}${ansiEscapes.cursorForward(stringWidth(emoji))}`; } if (import.meta.vitest != null) { describe('drawEmoji', () => { it('should always return a string with width as same as original', () => { // 2-width emojis expect(stringWidth(drawEmoji('⏱️'))).toBe(2); expect(stringWidth(drawEmoji('🔥'))).toBe(2); expect(stringWidth(drawEmoji('📈'))).toBe(2); expect(stringWidth(drawEmoji('⚙️'))).toBe(2); expect(stringWidth(drawEmoji('❌'))).toBe(2); expect(stringWidth(drawEmoji('⚠️'))).toBe(2); expect(stringWidth(drawEmoji('⚡'))).toBe(2); // 1-width emojis expect(stringWidth(drawEmoji('✓'))).toBe(1); expect(stringWidth(drawEmoji('↻'))).toBe(1); }); }); } ================================================ FILE: packages/terminal/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "jsx": "react-jsx", // Environment setup & latest features "lib": ["ESNext"], "moduleDetection": "force", "module": "Preserve", // Bundler mode "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["vitest/globals", "vitest/importMeta"], "allowImportingTsExtensions": true, "allowJs": true, // Best practices "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noEmit": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true }, "include": ["src/**/*.ts", "vitest.config.ts"], "exclude": ["dist"] } ================================================ FILE: packages/terminal/vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { watch: false, globals: true, includeSource: ['src/**/*.ts'], }, define: { 'import.meta.vitest': 'undefined', }, }); ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - apps/* - docs - packages/* catalogMode: strict catalogs: build: '@oxc-project/runtime': ^0.82.3 tsdown: ^0.16.6 unplugin-macros: ^0.18.0 unplugin-unused: ^0.5.2 docs: '@ryoppippi/vite-plugin-cloudflare-redirect': ^1.1.2 typedoc: ^0.28.10 typedoc-plugin-markdown: ^4.8.1 typedoc-vitepress-theme: ^1.1.2 vitepress: ^1.6.4 vitepress-plugin-group-icons: ^1.6.3 vitepress-plugin-llms: ^1.7.3 wrangler: ^4.32.0 lint: '@ryoppippi/eslint-config': ^0.4.0 eslint: ^9.33.0 eslint-plugin-format: ^1.0.2 oxfmt: ^0.23.0 publint: ^0.3.12 llm-docs: '@gunshi/docs': ^0.27.5 '@praha/byethrow-docs': ^0.9.0 release: bumpp: ^10.2.3 changelogithub: ^13.16.1 clean-pkg-json: ^1.3.0 lint-staged: ^16.1.5 pkg-pr-new: ^0.0.60 runtime: '@antfu/utils': ^9.2.1 '@hono/mcp': ^0.1.5 '@hono/node-server': ^1.19.7 '@modelcontextprotocol/sdk': ^1.24.3 '@praha/byethrow': ^0.6.3 '@ryoppippi/limo': jsr:^0.2.2 '@std/async': jsr:^1.0.14 ansi-escapes: ^7.0.0 cli-table3: ^0.6.5 consola: ^3.4.2 es-toolkit: ^1.39.10 fast-sort: ^3.4.1 get-stdin: ^9.0.0 gunshi: ^0.26.3 hono: ^4.9.2 nano-spawn: ^1.0.3 p-limit: ^7.1.0 path-type: ^6.0.0 picocolors: ^1.1.1 pretty-ms: ^9.2.0 string-width: ^7.2.0 tinyglobby: ^0.2.14 type-fest: ^4.41.0 valibot: ^1.1.0 xdg-basedir: ^5.1.0 zod: ^4.1.13 testing: fs-fixture: ^2.8.1 vitest: ^4.0.15 types: '@types/bun': ^1.3.5 '@types/node': ^24.10.1 '@types/react': ^19.1.13 '@typescript/native-preview': ^7.0.0-dev.20251225.1 enablePrePostScripts: true minimumReleaseAge: 2880 # Security settings for supply chain attack prevention strictDepBuilds: true blockExoticSubdeps: true trustPolicy: no-downgrade # Explicitly allow build scripts for packages that require them # (replaces onlyBuiltDependencies) allowBuilds: esbuild: true sharp: true sqlite3: true workerd: true shellEmulator: true enableGlobalVirtualStore: true ================================================ FILE: typos.toml ================================================ [default] locale = 'en-us' extend-ignore-re = [ "(?s)(#|//)\\s*spellchecker:off.*?\\n\\s*(#|//)\\s*spellchecker:on", "(?s)", "(?Rm)^.*#\\s*spellchecker:disable-line$", "(?m)^.*\\n.*$", ] [default.extend-words] color = "color" [files] ignore-hidden = false extend-exclude = [".git", "node_modules", "pnpm-lock.yaml", "pnpm-workspace.yaml"]