[
  {
    "path": ".claude/commands/reduce-similarities.md",
    "content": "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.\n"
  },
  {
    "path": ".claude/skills/byethrow/SKILL.md",
    "content": "---\nname: byethrow\ndescription: 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.\nallowed-tools: Read, Grep, Glob\n---\n\n## About byethrow\n\n`@praha/byethrow` is a lightweight, tree-shakable Result type library for handling fallible operations in JavaScript and TypeScript.\nIt provides a simple, consistent API for managing errors and results without throwing exceptions.\n\nFor detailed API references and usage examples, refer to the documentation in `node_modules/@praha/byethrow-docs/docs/**/*.md`.\n\n### Documentation CLI\n\nThe byethrow documentation CLI provides commands to browse, search, and navigate documentation directly from your terminal.\n\n#### `list` command\n\nList all available documentation organized by sections.\n\n```bash\n# List all documentation\nnpx @praha/byethrow-docs list\n\n# List documentation with filter query\nnpx @praha/byethrow-docs list --query \"your query\"\n```\n\n**Options:**\n\n- `--query <string>`: Filter documentation by keywords (optional)\n\n#### `search` command\n\nSearch documentation and get matching results with highlighted snippets.\n\n```bash\n# Search documentation\nnpx @praha/byethrow-docs search \"your query\"\n\n# Limit number of results (default: 5)\nnpx @praha/byethrow-docs search \"your query\" --limit 10\n```\n\n**Arguments:**\n\n- `query`: Search query string (required)\n\n**Options:**\n\n- `--limit <number>`: Maximum number of results to return (default: 5)\n\n#### `toc` command\n\nDisplay table of contents from a documentation file.\n\n```bash\n# Display table of contents from a markdown file\nnpx @praha/byethrow-docs toc path/to/document.md\n```\n\n**Arguments:**\n\n- `path`: Path to the documentation file (required)\n"
  },
  {
    "path": ".claude/skills/use-gunshi-cli/SKILL.md",
    "content": "---\nname: use-gunshi-cli\ndescription: Use the Gunshi library to create command-line interfaces in JavaScript/TypeScript.\nglobs: '*.ts, *.tsx, *.js, *.jsx, package.json'\nalwaysApply: false\n---\n\nuse gunshi library for creating cli instead of other libraries including cac, yargs, commander, etc.\nGunshi is a modern javascript command-line library\n\nFor more information, read the gunshi API docs in `node_modules/@gunshi/docs/**.md`.\n"
  },
  {
    "path": ".envrc",
    "content": "watch_file pnpm-lock.yaml\nwatch_file pnpm-workspace.yaml\nuse flake\n"
  },
  {
    "path": ".githooks/pre-commit",
    "content": "#!/bin/sh\n\n# Run lint-staged\nnpx --no-install lint-staged\n"
  },
  {
    "path": ".github/FUNDING.yaml",
    "content": "github: ryoppippi\n"
  },
  {
    "path": ".github/actions/setup-nix/action.yaml",
    "content": "name: Setup Nix\ndescription: Install Nix and configure Cachix\ninputs:\n  cachix-auth-token:\n    description: Cachix authentication token\n    required: false\nruns:\n  using: composite\n  steps:\n    - name: Install Nix\n      uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1\n      with:\n        github_access_token: ${{ github.token }}\n\n    - name: Setup Cachix (numtide)\n      uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16\n      with:\n        name: numtide\n        authToken: ''\n\n    - name: Setup Cachix (ryoppippi)\n      if: inputs.cachix-auth-token != ''\n      uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16\n      with:\n        name: ryoppippi\n        authToken: ${{ inputs.cachix-auth-token }}\n\n    - name: Load Nix development environment\n      shell: bash\n      run: nix develop --command true\n"
  },
  {
    "path": ".github/renovate.json",
    "content": "{\n\t\"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n\t\"nix\": {\n\t\t\"enabled\": true\n\t},\n\t\"extends\": [\"github>ryoppippi/renovate-config:no-group\"]\n}\n"
  },
  {
    "path": ".github/workflows/check-pr-title.yaml",
    "content": "name: Check PR title\n\non:\n  pull_request:\n    types:\n      - opened\n      - reopened\n      - edited\n      - synchronize\n\npermissions:\n  pull-requests: read\n\njobs:\n  main:\n    name: Validate PR title\n    runs-on: ubuntu-slim\n    steps:\n      - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          ignoreLabels: |\n            autorelease: pending\n            dependencies\n"
  },
  {
    "path": ".github/workflows/ci.yaml",
    "content": "name: CI\n\non:\n  push:\n  pull_request:\n\njobs:\n  lint-check:\n    runs-on: ubuntu-24.04-arm\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - uses: ./.github/actions/setup-nix\n      - run: nix develop --command pnpm lint\n      - run: nix develop --command pnpm typecheck\n\n  test:\n    runs-on: ubuntu-24.04-arm\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - uses: ./.github/actions/setup-nix\n      - name: Create default Claude directories for tests\n        run: |\n          mkdir -p $HOME/.claude/projects\n          mkdir -p $HOME/.config/claude/projects\n      - run: nix develop --command pnpm run test\n\n  npm-publish-dry-run-and-upload-pkg-pr-now:\n    runs-on: ubuntu-24.04-arm\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - uses: ./.github/actions/setup-nix\n      - run: nix develop --command pnpm pkg-pr-new publish --pnpm './apps/*'\n\n  spell-check:\n    runs-on: ubuntu-24.04-arm\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - uses: ./.github/actions/setup-nix\n      - run: nix develop --command typos --config ./typos.toml\n\n  schema-check:\n    runs-on: ubuntu-24.04-arm\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - uses: ./.github/actions/setup-nix\n      - name: Generate schema files\n        run: nix develop --command pnpm run generate:schema\n        working-directory: apps/ccusage\n      - name: Check if schema files are up-to-date\n        run: |\n          if git diff --exit-code apps/ccusage/config-schema.json docs/public/config-schema.json; then\n            echo \"✅ Schema files are up-to-date\"\n          else\n            echo \"❌ Schema files are not up-to-date. Please run 'pnpm run generate:schema' and commit the changes.\"\n            echo \"\"\n            echo \"Changed files:\"\n            git diff --name-only apps/ccusage/config-schema.json docs/public/config-schema.json\n            echo \"\"\n            echo \"Diff:\"\n            git diff apps/ccusage/config-schema.json docs/public/config-schema.json\n            exit 1\n          fi\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "name: npm publish\n\non:\n  push:\n    tags:\n      - '*'\n\njobs:\n  npm:\n    runs-on: ubuntu-24.04-arm\n    timeout-minutes: 10\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-depth: 0\n      - uses: ./.github/actions/setup-nix\n      - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0\n        with:\n          registry-url: 'https://registry.npmjs.org'\n          node-version: lts/*\n      - run: nix develop --command pnpm --filter='./apps/**' publish --provenance --no-git-checks --access public\n\n  release:\n    needs:\n      - npm\n    runs-on: ubuntu-24.04-arm\n    permissions:\n      contents: write\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-depth: 0\n      - uses: ./.github/actions/setup-nix\n      - run: nix develop --command pnpm changelogithub\n        env:\n          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}\n"
  },
  {
    "path": ".gitignore",
    "content": "# dependencies (bun install)\nnode_modules\n\n# output\nout\ndist\n*.tgz\n\n# code coverage\ncoverage\n*.lcov\n\n# logs\nlogs\n_.log\nreport.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json\n\n# dotenv environment variable files\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# caches\n.eslintcache\n.cache\n*.tsbuildinfo\n\n# IntelliJ based IDEs\n.idea\n\n# Finder (MacOS) folder config\n.DS_Store\n\n.eslintcache\n\n# nix\n.direnv\n!.envrc\n"
  },
  {
    "path": ".mcp.json",
    "content": "{\n\t\"mcpServers\": {\n\t\t\"context7\": {\n\t\t\t\"type\": \"http\",\n\t\t\t\"url\": \"https://mcp.context7.com/mcp\"\n\t\t},\n\t\t\"grep\": {\n\t\t\t\"type\": \"http\",\n\t\t\t\"url\": \"https://mcp.grep.app\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": ".oxfmtrc.jsonc",
    "content": "{\n\t\"$schema\": \"https://unpkg.com/oxfmt/configuration_schema.json\",\n\t\"useTabs\": true,\n\t\"singleQuote\": true,\n\t\"files\": {\n\t\t\"ignore\": [\"**/node_modules/**\", \"**/dist/**\", \"**/.git/**\", \"**/pnpm-lock.yaml\"],\n\t},\n}\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Monorepo Structure\n\nThis is a monorepo containing multiple packages. For package-specific guidance, refer to the individual CLAUDE.md files:\n\n- **Main CLI Package**: @apps/ccusage/CLAUDE.md - Core ccusage CLI tool and library\n- **Codex CLI Package**: @apps/codex/CLAUDE.md - OpenAI Codex usage tracking CLI\n- **OpenCode CLI Package**: @apps/opencode/CLAUDE.md - OpenCode usage tracking CLI\n- **MCP Server Package**: @apps/mcp/CLAUDE.md - MCP server implementation for ccusage data\n- **Documentation**: @docs/CLAUDE.md - VitePress-based documentation website\n\nEach package has its own development commands, dependencies, and specific guidelines. Always check the relevant package's CLAUDE.md when working within that package directory.\n\n### Apps Are Bundled\n\nAll 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.\n\n## Development Commands\n\n**Testing and Quality:**\n\n- `pnpm run test` - Run all tests (using vitest via pnpm, watch mode disabled)\n- Lint code using ESLint MCP server (available via Claude Code tools)\n- `pnpm run format` - Format code with ESLint (writes changes)\n- `pnpm typecheck` - Type check with TypeScript\n\n**Build and Release:**\n\n- `pnpm run build` - Build distribution files with tsdown\n- `pnpm run release` - Full release workflow (lint + typecheck + test + build + version bump)\n\n**Development Usage:**\n\n- `pnpm run start daily` - Show daily usage report\n- `pnpm run start monthly` - Show monthly usage report\n- `pnpm run start session` - Show session-based usage report\n- `pnpm run start blocks` - Show 5-hour billing blocks usage report\n- `pnpm run start statusline` - Show compact status line (Beta)\n- `pnpm run start daily --json` - Show daily usage report in JSON format\n- `pnpm run start monthly --json` - Show monthly usage report in JSON format\n- `pnpm run start session --json` - Show session usage report in JSON format\n- `pnpm run start blocks --json` - Show blocks usage report in JSON format\n- `pnpm run start daily --mode <mode>` - Control cost calculation mode (auto/calculate/display)\n- `pnpm run start monthly --mode <mode>` - Control cost calculation mode (auto/calculate/display)\n- `pnpm run start session --mode <mode>` - Control cost calculation mode (auto/calculate/display)\n- `pnpm run start blocks --mode <mode>` - Control cost calculation mode (auto/calculate/display)\n- `pnpm run start blocks --active` - Show only active block with projections\n- `pnpm run start blocks --recent` - Show blocks from last 3 days (including active)\n- `pnpm run start blocks --token-limit <limit>` - Token limit for quota warnings (number or \"max\")\n- `node ./src/index.ts` - Direct execution for development\n\n**MCP Server Usage:** (now provided by the `@ccusage/mcp` package)\n\n- `pnpm dlx @ccusage/mcp@latest -- --help` - Show available options\n- `pnpm dlx @ccusage/mcp@latest -- --type http --port 8080` - Start HTTP transport\n\n**Cost Calculation Modes:**\n\n- `auto` (default) - Use pre-calculated costUSD when available, otherwise calculate from tokens\n- `calculate` - Always calculate costs from token counts using model pricing, ignore costUSD\n- `display` - Always use pre-calculated costUSD values, show 0 for missing costs\n\n**Environment Variables:**\n\n- `LOG_LEVEL` - Control logging verbosity (0=silent, 1=warn, 2=log, 3=info, 4=debug, 5=trace)\n  - Example: `LOG_LEVEL=0 pnpm run start daily` for silent output\n  - Useful for debugging or suppressing non-critical output\n\n**Multiple Claude Data Directories:**\n\nThis tool supports multiple Claude data directories to handle different Claude Code installations:\n\n- **Default Behavior**: Automatically searches both `~/.config/claude/projects/` (new default) and `~/.claude/projects/` (old default)\n- **Environment Variable**: Set `CLAUDE_CONFIG_DIR` to specify custom path(s)\n  - Single path: `export CLAUDE_CONFIG_DIR=\"/path/to/claude\"`\n  - Multiple paths: `export CLAUDE_CONFIG_DIR=\"/path/to/claude1,/path/to/claude2\"`\n- **Data Aggregation**: Usage data from all valid directories is automatically combined\n- **Backward Compatibility**: Existing configurations continue to work without changes\n\nThis addresses the breaking change in Claude Code where logs moved from `~/.claude` to `~/.config/claude`.\n\n## Architecture Overview\n\nThis 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:\n\n**Core Data Flow:**\n\n1. **Data Loading** (`data-loader.ts`) - Parses JSONL files from multiple Claude data directories, including pre-calculated costs\n2. **Token Aggregation** (`calculate-cost.ts`) - Utility functions for aggregating token counts and costs\n3. **Command Execution** (`commands/`) - CLI subcommands that orchestrate data loading and presentation\n4. **CLI Entry** (`index.ts`) - Gunshi-based CLI setup with subcommand routing\n\n**Output Formats:**\n\n- Table format (default): Pretty-printed tables with colors for terminal display\n- JSON format (`--json`): Structured JSON output for programmatic consumption\n\n**Key Data Structures:**\n\n- Raw usage data is parsed from JSONL with timestamp, token counts, and pre-calculated costs\n- Data is aggregated into daily summaries, monthly summaries, session summaries, or 5-hour billing blocks\n- **Important Note on Naming**: The term \"session\" in this codebase has two different meanings:\n  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)\n  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)\n- File structure: `projects/{project}/{sessionId}.jsonl` where:\n  - `{project}` is the project directory name (used for grouping)\n  - `{sessionId}.jsonl` is the JSONL file named with the actual session ID from Claude Code\n  - Each JSONL file contains all usage entries for a single Claude Code session\n  - The sessionId in the filename matches the `sessionId` field inside the JSONL entries\n- 5-hour blocks group usage data by Claude's billing cycles with active block tracking\n\n**External Dependencies:**\n\n- Uses local timezone for date formatting\n- CLI built with `gunshi` framework, tables with `cli-table3`\n- **LiteLLM Integration**: Cost calculations depend on LiteLLM's pricing database for model pricing data\n\n**MCP Integration:**\n\n- **Built-in MCP Server**: Exposes usage data through MCP protocol with tools:\n  - `daily` - Daily usage reports\n  - `session` - Session-based usage reports\n  - `monthly` - Monthly usage reports\n  - `blocks` - 5-hour billing blocks usage reports\n- **External MCP Servers Available:**\n  - **ESLint MCP**: Lint TypeScript/JavaScript files directly through Claude Code tools\n  - **Context7 MCP**: Look up documentation for libraries and frameworks\n- **Claude Code Skills Available:**\n  - **use-gunshi-cli**: Guide for using gunshi CLI framework (via @gunshi/docs)\n  - **byethrow**: Guide for using @praha/byethrow Result type (via @praha/byethrow-docs)\n\n## Git Commit and PR Conventions\n\n**Commit Message Format:**\n\nFollow the Conventional Commits specification with package/area prefixes:\n\n```\n<type>(<scope>): <subject>\n```\n\n**Scope Naming Rules:**\n\n- **Apps**: Use the app directory name\n  - `feat(ccusage):` - Changes to apps/ccusage\n  - `fix(mcp):` - Fixes in apps/mcp\n  - `feat(codex):` - Features for apps/codex (if exists)\n\n- **Packages**: Use the package directory name\n  - `feat(terminal):` - Changes to packages/terminal\n  - `fix(ui):` - Fixes in packages/ui\n  - `refactor(core):` - Refactoring packages/core\n\n- **Documentation**: Use `docs` scope\n  - `docs:` or `docs(guide):` - Documentation updates\n  - `docs(api):` - API documentation changes\n\n- **Root-level changes**: No scope (preferred) or use `root`\n  - `chore:` - Root config updates\n  - `ci:` - CI/CD changes\n  - `feat:` - Root-level features\n  - `docs:` - Root documentation updates\n  - `build:` or `build(root):` - Root build system changes\n\n**Type Prefixes:**\n\n- `feat:` - New feature\n- `fix:` - Bug fix\n- `docs:` - Documentation only changes\n- `style:` - Code style changes (formatting, missing semi-colons, etc)\n- `refactor:` - Code change that neither fixes a bug nor adds a feature\n- `perf:` - Performance improvements\n- `test:` - Adding missing tests or correcting existing tests\n- `chore:` - Changes to the build process or auxiliary tools\n- `ci:` - CI/CD configuration changes\n- `revert:` - Reverting a previous commit\n\n**Examples:**\n\n```\nfeat(ccusage): add support for Claude 4.1 models\nfix(mcp): resolve connection timeout issues\ndocs(guide): update installation instructions\nrefactor(ccusage): extract cost calculation to separate module\ntest(mcp): add integration tests for HTTP transport\nchore: update dependencies\n```\n\n**PR Title Convention:**\n\nPR titles should follow the same format as commit messages. When a PR contains multiple commits, the title should describe the main change:\n\n```\nfeat(ccusage): implement session-based usage reports\nfix(mcp): handle edge cases in data aggregation\ndocs: comprehensive API documentation update\n```\n\n## Code Style Notes\n\n- Uses ESLint for linting and formatting with tab indentation and double quotes\n- TypeScript with strict mode and bundler module resolution\n- No console.log allowed except where explicitly disabled with eslint-disable\n- Error handling: silently skips malformed JSONL lines during parsing\n- File paths always use Node.js path utilities for cross-platform compatibility\n- **Import conventions**: Use `.ts` extensions for local file imports (e.g., `import { foo } from './utils.ts'`)\n\n**Error Handling:**\n\n- **Prefer @praha/byethrow Result type** over traditional try-catch for functional error handling\n  - Documentation: Available via byethrow skill (use `/byethrow` or check `.claude/skills/byethrow/`)\n- Use `Result.try()` for wrapping operations that may throw (JSON parsing, etc.)\n- Use `Result.isFailure()` for checking errors (more readable than `!Result.isSuccess()`)\n- Use early return pattern (`if (Result.isFailure(result)) continue;`) instead of ternary operators\n- For async operations: create wrapper function with `Result.try()` then call it\n- Keep traditional try-catch only for: file I/O with complex error handling, legacy code that's hard to refactor\n- Always use `Result.isFailure()` and `Result.isSuccess()` type guards for better code clarity\n\n**Naming Conventions:**\n\n- Variables: start with lowercase (camelCase) - e.g., `usageDataSchema`, `modelBreakdownSchema`\n- Types: start with uppercase (PascalCase) - e.g., `UsageData`, `ModelBreakdown`\n- Constants: can use UPPER_SNAKE_CASE - e.g., `DEFAULT_CLAUDE_CODE_PATH`\n- Internal files: use underscore prefix - e.g., `_types.ts`, `_utils.ts`, `_consts.ts`\n\n**Export Rules:**\n\n- **IMPORTANT**: Only export constants, functions, and types that are actually used by other modules\n- Internal/private constants that are only used within the same file should NOT be exported\n- Always check if a constant is used elsewhere before making it `export const` vs just `const`\n- This follows the principle of minimizing the public API surface area\n- Dependencies should always be added as `devDependencies` unless explicitly requested otherwise\n\n**Post-Code Change Workflow:**\n\nAfter making any code changes, ALWAYS run these commands in parallel:\n\n- `pnpm run format` - Auto-fix and format code with ESLint (includes linting)\n- `pnpm typecheck` - Type check with TypeScript\n- `pnpm run test` - Run all tests\n\nThis ensures code quality and catches issues immediately after changes.\n\n## Documentation Guidelines\n\n**Screenshot Usage:**\n\n- **Placement**: Always place screenshots immediately after the main heading (H1) in documentation pages\n- **Purpose**: Provide immediate visual context to users before textual explanations\n- **Guides with Screenshots**:\n  - `/docs/guide/index.md` (What is ccusage) - Main usage screenshot\n  - `/docs/guide/daily-reports.md` - Daily report output screenshot\n  - `/docs/guide/live-monitoring.md` - Live monitoring dashboard screenshot\n  - `/docs/guide/mcp-server.md` - Claude Desktop integration screenshot\n- **Image Path**: Use relative paths like `/screenshot.png` for images stored in `/docs/public/`\n- **Alt Text**: Always include descriptive alt text for accessibility\n\n## Claude Models and Testing\n\n**Supported Claude 4 Models (as of 2025):**\n\n- `claude-sonnet-4-20250514` - Latest Claude 4 Sonnet model\n- `claude-opus-4-20250514` - Latest Claude 4 Opus model\n\n**Model Naming Convention:**\n\n- Pattern: `claude-{model-type}-{generation}-{date}`\n- Example: `claude-sonnet-4-20250514` (NOT `claude-4-sonnet-20250514`)\n- The generation number comes AFTER the model type\n\n**Testing Guidelines:**\n\n- **In-Source Testing Pattern**: This project uses in-source testing with `if (import.meta.vitest != null)` blocks\n- Tests are written directly in the same files as the source code, not in separate test files\n- Vitest globals (`describe`, `it`, `expect`) are available automatically without imports\n- **IMPORTANT**: DO NOT use `await import()` dynamic imports anywhere in the codebase - this causes tree-shaking issues and should be avoided entirely\n- **ESPECIALLY**: Never use dynamic imports in vitest test blocks - this is particularly problematic for test execution\n- **Vitest globals are enabled**: Use `describe`, `it`, `expect` directly without any imports since globals are configured\n- Mock data is created using `fs-fixture` with `createFixture()` for Claude data directory simulation\n- All test files must use current Claude 4 models, not outdated Claude 3 models\n- Test coverage should include both Sonnet and Opus models for comprehensive validation\n- Model names in tests must exactly match LiteLLM's pricing database entries\n- When adding new model tests, verify the model exists in LiteLLM before implementation\n- Tests depend on real pricing data from LiteLLM - failures may indicate model availability issues\n\n**LiteLLM Integration Notes:**\n\n- Cost calculations require exact model name matches with LiteLLM's database\n- Test failures often indicate model names don't exist in LiteLLM's pricing data\n- Future model updates require checking LiteLLM compatibility first\n- The application cannot calculate costs for models not supported by LiteLLM\n\n# Tips for Claude Code\n\n- Context7 MCP server available for library documentation lookup\n- use-gunshi-cli skill available for gunshi CLI framework documentation\n- byethrow skill available for @praha/byethrow Result type documentation\n- do not use console.log. use logger.ts instead\n- **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.\n\n# important-instruction-reminders\n\nDo what has been asked; nothing more, nothing less.\nNEVER create files unless they're absolutely necessary for achieving your goal.\nALWAYS prefer editing an existing file to creating a new one.\nNEVER proactively create documentation files (\\*.md) or README files. Only create documentation files if explicitly requested by the User.\nDependencies should always be added as devDependencies unless explicitly requested otherwise.\n"
  },
  {
    "path": "apps/amp/CLAUDE.md",
    "content": "# Amp CLI Notes\n\n## Log Sources\n\n- 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`).\n- Each thread is stored as a JSON file (not JSONL) named `T-{uuid}.json`.\n- Token usage is extracted from the `usageLedger.events[]` array in each thread file.\n- Cache token information (creation/read) is extracted from `messages[].usage` for detailed breakdown.\n\n## Token Fields\n\n- `inputTokens`: total input tokens sent to the model.\n- `outputTokens`: output tokens (completion text).\n- `cacheCreationInputTokens`: tokens used for cache creation (from message usage).\n- `cacheReadInputTokens`: tokens read from cache (from message usage).\n- `totalTokens`: sum of input and output tokens.\n\n## Credits\n\n- Amp uses a credits-based billing system in addition to standard token counts.\n- Each usage event includes a `credits` field representing the billing cost in Amp's credit system.\n- Credits are displayed alongside USD cost estimates in reports.\n\n## Cost Calculation\n\n- Pricing is pulled from LiteLLM's public JSON (`model_prices_and_context_window.json`).\n- Amp primarily uses Anthropic Claude models (Haiku, Sonnet, Opus variants).\n- Cost formula per model:\n  - Input: `inputTokens / 1_000_000 * input_cost_per_mtoken`\n  - Cached input read: `cacheReadInputTokens / 1_000_000 * cached_input_cost_per_mtoken`\n  - Cache creation: `cacheCreationInputTokens / 1_000_000 * cache_creation_cost_per_mtoken`\n  - Output: `outputTokens / 1_000_000 * output_cost_per_mtoken`\n\n## CLI Usage\n\n- Treat Amp as a sibling to `apps/ccusage`, `apps/codex`, and `apps/opencode`.\n- Reuse shared packages (`@ccusage/terminal`, `@ccusage/internal`) wherever possible.\n- Amp is packaged as a bundled CLI. Keep every runtime dependency in `devDependencies`.\n- Entry point uses Gunshi framework with subcommands: `daily`, `monthly`, `session`.\n- Data discovery relies on `AMP_DATA_DIR` environment variable.\n- Default path: `~/.local/share/amp`.\n\n## Available Commands\n\n- `ccusage-amp daily` - Show daily usage report\n- `ccusage-amp monthly` - Show monthly usage report\n- `ccusage-amp session` - Show usage by thread (session)\n- Add `--json` flag for JSON output format\n- Add `--compact` flag for compact table mode\n\n## Testing Notes\n\n- Tests rely on `fs-fixture` with `using` to ensure cleanup.\n- All vitest blocks live alongside implementation files via `if (import.meta.vitest != null)`.\n- Vitest globals are enabled - use `describe`, `it`, `expect` directly without imports.\n- **CRITICAL**: NEVER use `await import()` dynamic imports anywhere, especially in test blocks.\n\n## Data Structure\n\nAmp thread files have the following structure:\n\n```json\n{\n\t\"id\": \"T-{uuid}\",\n\t\"created\": 1700000000000,\n\t\"title\": \"Thread Title\",\n\t\"messages\": [\n\t\t{\n\t\t\t\"role\": \"assistant\",\n\t\t\t\"messageId\": 1,\n\t\t\t\"usage\": {\n\t\t\t\t\"model\": \"claude-haiku-4-5-20251001\",\n\t\t\t\t\"inputTokens\": 100,\n\t\t\t\t\"outputTokens\": 50,\n\t\t\t\t\"cacheCreationInputTokens\": 500,\n\t\t\t\t\"cacheReadInputTokens\": 200,\n\t\t\t\t\"credits\": 1.5\n\t\t\t}\n\t\t}\n\t],\n\t\"usageLedger\": {\n\t\t\"events\": [\n\t\t\t{\n\t\t\t\t\"id\": \"event-uuid\",\n\t\t\t\t\"timestamp\": \"2025-11-23T10:00:00.000Z\",\n\t\t\t\t\"model\": \"claude-haiku-4-5-20251001\",\n\t\t\t\t\"credits\": 1.5,\n\t\t\t\t\"tokens\": {\n\t\t\t\t\t\"input\": 100,\n\t\t\t\t\t\"output\": 50\n\t\t\t\t},\n\t\t\t\t\"operationType\": \"inference\",\n\t\t\t\t\"fromMessageId\": 0,\n\t\t\t\t\"toMessageId\": 1\n\t\t\t}\n\t\t]\n\t}\n}\n```\n\n## Environment Variables\n\n- `AMP_DATA_DIR` - Custom Amp data directory path (defaults to `~/.local/share/amp`)\n- `LOG_LEVEL` - Control logging verbosity (0=silent, 1=warn, 2=log, 3=info, 4=debug, 5=trace)\n"
  },
  {
    "path": "apps/amp/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\n/** @type {import('eslint').Linter.FlatConfig[]} */\nconst config = ryoppippi(\n\t{\n\t\ttype: 'app',\n\t\tstylistic: false,\n\t},\n\t{\n\t\trules: {\n\t\t\t'test/no-importing-vitest-globals': 'error',\n\t\t},\n\t},\n);\n\nexport default config;\n"
  },
  {
    "path": "apps/amp/package.json",
    "content": "{\n\t\"name\": \"@ccusage/amp\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"description\": \"Usage analysis tool for Amp CLI sessions\",\n\t\"author\": \"ryoppippi\",\n\t\"license\": \"MIT\",\n\t\"funding\": \"https://github.com/ryoppippi/ccusage?sponsor=1\",\n\t\"homepage\": \"https://github.com/ryoppippi/ccusage#readme\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/ryoppippi/ccusage.git\",\n\t\t\"directory\": \"apps/amp\"\n\t},\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/ryoppippi/ccusage/issues\"\n\t},\n\t\"main\": \"./dist/index.js\",\n\t\"module\": \"./dist/index.js\",\n\t\"bin\": {\n\t\t\"ccusage-amp\": \"./src/index.ts\"\n\t},\n\t\"files\": [\n\t\t\"dist\"\n\t],\n\t\"publishConfig\": {\n\t\t\"bin\": {\n\t\t\t\"ccusage-amp\": \"./dist/index.js\"\n\t\t}\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=20.19.4\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"tsdown\",\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"prepack\": \"pnpm run build && clean-pkg-json\",\n\t\t\"prerelease\": \"pnpm run lint && pnpm run typecheck && pnpm run build\",\n\t\t\"start\": \"bun ./src/index.ts\",\n\t\t\"test\": \"TZ=UTC vitest\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@ccusage/internal\": \"workspace:*\",\n\t\t\"@ccusage/terminal\": \"workspace:*\",\n\t\t\"@praha/byethrow\": \"catalog:runtime\",\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"@typescript/native-preview\": \"catalog:types\",\n\t\t\"clean-pkg-json\": \"catalog:release\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"fast-sort\": \"catalog:runtime\",\n\t\t\"fs-fixture\": \"catalog:testing\",\n\t\t\"gunshi\": \"catalog:runtime\",\n\t\t\"path-type\": \"catalog:runtime\",\n\t\t\"picocolors\": \"catalog:runtime\",\n\t\t\"tinyglobby\": \"catalog:runtime\",\n\t\t\"tsdown\": \"catalog:build\",\n\t\t\"unplugin-macros\": \"catalog:build\",\n\t\t\"unplugin-unused\": \"catalog:build\",\n\t\t\"valibot\": \"catalog:runtime\",\n\t\t\"vitest\": \"catalog:testing\"\n\t}\n}\n"
  },
  {
    "path": "apps/amp/src/_consts.ts",
    "content": "import { homedir } from 'node:os';\nimport path from 'node:path';\n\n/**\n * Environment variable name for custom Amp data directory\n */\nexport const AMP_DATA_DIR_ENV = 'AMP_DATA_DIR';\n\n/**\n * Default Amp data directory path (~/.local/share/amp)\n */\nconst DEFAULT_AMP_PATH = '.local/share/amp';\n\n/**\n * User home directory\n */\nconst USER_HOME_DIR = homedir();\n\n/**\n * Default Amp data directory (absolute path)\n */\nexport const DEFAULT_AMP_DIR = path.join(USER_HOME_DIR, DEFAULT_AMP_PATH);\n\n/**\n * Amp threads subdirectory name\n */\nexport const AMP_THREADS_DIR_NAME = 'threads';\n\n/**\n * Glob pattern for Amp thread files\n */\nexport const AMP_THREAD_GLOB = '**/*.json';\n\n/**\n * Million constant for pricing calculations\n */\nexport const MILLION = 1_000_000;\n"
  },
  {
    "path": "apps/amp/src/_macro.ts",
    "content": "import type { LiteLLMModelPricing } from '@ccusage/internal/pricing';\nimport {\n\tcreatePricingDataset,\n\tfetchLiteLLMPricingDataset,\n\tfilterPricingDataset,\n} from '@ccusage/internal/pricing-fetch-utils';\n\nconst AMP_MODEL_PREFIXES = ['claude-', 'anthropic/'];\n\nfunction isAmpModel(modelName: string, _pricing: LiteLLMModelPricing): boolean {\n\treturn AMP_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix));\n}\n\nexport async function prefetchAmpPricing(): Promise<Record<string, LiteLLMModelPricing>> {\n\ttry {\n\t\tconst dataset = await fetchLiteLLMPricingDataset();\n\t\treturn filterPricingDataset(dataset, isAmpModel);\n\t} catch (error) {\n\t\tconsole.warn('Failed to prefetch Amp pricing data, proceeding with empty cache.', error);\n\t\treturn createPricingDataset();\n\t}\n}\n"
  },
  {
    "path": "apps/amp/src/_types.ts",
    "content": "/**\n * Token usage delta for a single event\n */\nexport type TokenUsageDelta = {\n\tinputTokens: number;\n\tcacheCreationInputTokens: number;\n\tcacheReadInputTokens: number;\n\toutputTokens: number;\n\ttotalTokens: number;\n};\n\n/**\n * Token usage event loaded from Amp thread files\n */\nexport type TokenUsageEvent = TokenUsageDelta & {\n\ttimestamp: string;\n\tthreadId: string;\n\tmodel: string;\n\tcredits: number;\n\toperationType: string;\n};\n\n/**\n * Model usage summary with token counts\n */\nexport type ModelUsage = TokenUsageDelta & {\n\tcredits: number;\n};\n\n/**\n * Daily usage summary\n */\nexport type DailyUsageSummary = {\n\tdate: string;\n\tfirstTimestamp: string;\n\tcostUSD: number;\n\tcredits: number;\n\tmodels: Map<string, ModelUsage>;\n} & TokenUsageDelta;\n\n/**\n * Monthly usage summary\n */\nexport type MonthlyUsageSummary = {\n\tmonth: string;\n\tfirstTimestamp: string;\n\tcostUSD: number;\n\tcredits: number;\n\tmodels: Map<string, ModelUsage>;\n} & TokenUsageDelta;\n\n/**\n * Session (thread) usage summary\n */\nexport type SessionUsageSummary = {\n\tthreadId: string;\n\ttitle: string;\n\tfirstTimestamp: string;\n\tlastTimestamp: string;\n\tcostUSD: number;\n\tcredits: number;\n\tmodels: Map<string, ModelUsage>;\n} & TokenUsageDelta;\n\n/**\n * Model pricing information\n */\nexport type ModelPricing = {\n\tinputCostPerMToken: number;\n\tcachedInputCostPerMToken: number;\n\tcacheCreationCostPerMToken: number;\n\toutputCostPerMToken: number;\n};\n\n/**\n * Pricing source interface\n */\nexport type PricingSource = {\n\tgetPricing: (model: string) => Promise<ModelPricing>;\n};\n\n/**\n * Daily report row for JSON output\n */\nexport type DailyReportRow = {\n\tdate: string;\n\tinputTokens: number;\n\tcacheCreationInputTokens: number;\n\tcacheReadInputTokens: number;\n\toutputTokens: number;\n\ttotalTokens: number;\n\tcostUSD: number;\n\tcredits: number;\n\tmodels: Record<string, ModelUsage>;\n};\n\n/**\n * Monthly report row for JSON output\n */\nexport type MonthlyReportRow = {\n\tmonth: string;\n\tinputTokens: number;\n\tcacheCreationInputTokens: number;\n\tcacheReadInputTokens: number;\n\toutputTokens: number;\n\ttotalTokens: number;\n\tcostUSD: number;\n\tcredits: number;\n\tmodels: Record<string, ModelUsage>;\n};\n\n/**\n * Session report row for JSON output\n */\nexport type SessionReportRow = {\n\tthreadId: string;\n\ttitle: string;\n\tlastActivity: string;\n\tinputTokens: number;\n\tcacheCreationInputTokens: number;\n\tcacheReadInputTokens: number;\n\toutputTokens: number;\n\ttotalTokens: number;\n\tcostUSD: number;\n\tcredits: number;\n\tmodels: Record<string, ModelUsage>;\n};\n"
  },
  {
    "path": "apps/amp/src/commands/daily.ts",
    "content": "import type { TokenUsageEvent } from '../_types.ts';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { loadAmpUsageEvents } from '../data-loader.ts';\nimport { AmpPricingSource } from '../pricing.ts';\n\nconst TABLE_COLUMN_COUNT = 9;\n\nfunction groupByDate(events: TokenUsageEvent[]): Map<string, TokenUsageEvent[]> {\n\tconst grouped = new Map<string, TokenUsageEvent[]>();\n\tfor (const event of events) {\n\t\tconst date = event.timestamp.split('T')[0]!;\n\t\tconst existing = grouped.get(date);\n\t\tif (existing != null) {\n\t\t\texisting.push(event);\n\t\t} else {\n\t\t\tgrouped.set(date, [event]);\n\t\t}\n\t}\n\treturn grouped;\n}\n\nexport const dailyCommand = define({\n\tname: 'daily',\n\tdescription: 'Show Amp token usage grouped by day',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'j',\n\t\t\tdescription: 'Output in JSON format',\n\t\t},\n\t\tcompact: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Force compact table mode',\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\n\t\tconst { events } = await loadAmpUsageEvents();\n\n\t\tif (events.length === 0) {\n\t\t\tconst output = jsonOutput\n\t\t\t\t? JSON.stringify({ daily: [], totals: null })\n\t\t\t\t: 'No Amp usage data found.';\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(output);\n\t\t\treturn;\n\t\t}\n\n\t\tusing pricingSource = new AmpPricingSource({ offline: false });\n\n\t\tconst eventsByDate = groupByDate(events);\n\n\t\tconst dailyData: Array<{\n\t\t\tdate: string;\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\ttotalTokens: number;\n\t\t\tcredits: number;\n\t\t\ttotalCost: number;\n\t\t\tmodelsUsed: string[];\n\t\t}> = [];\n\n\t\tfor (const [date, dayEvents] of eventsByDate) {\n\t\t\tlet inputTokens = 0;\n\t\t\tlet outputTokens = 0;\n\t\t\tlet cacheCreationTokens = 0;\n\t\t\tlet cacheReadTokens = 0;\n\t\t\tlet credits = 0;\n\t\t\tlet totalCost = 0;\n\t\t\tconst modelsSet = new Set<string>();\n\n\t\t\tfor (const event of dayEvents) {\n\t\t\t\tinputTokens += event.inputTokens;\n\t\t\t\toutputTokens += event.outputTokens;\n\t\t\t\tcacheCreationTokens += event.cacheCreationInputTokens;\n\t\t\t\tcacheReadTokens += event.cacheReadInputTokens;\n\t\t\t\tcredits += event.credits;\n\n\t\t\t\tconst cost = await pricingSource.calculateCost(event.model, {\n\t\t\t\t\tinputTokens: event.inputTokens,\n\t\t\t\t\toutputTokens: event.outputTokens,\n\t\t\t\t\tcacheCreationInputTokens: event.cacheCreationInputTokens,\n\t\t\t\t\tcacheReadInputTokens: event.cacheReadInputTokens,\n\t\t\t\t});\n\t\t\t\ttotalCost += cost;\n\t\t\t\tmodelsSet.add(event.model);\n\t\t\t}\n\n\t\t\tconst totalTokens = inputTokens + outputTokens;\n\n\t\t\tdailyData.push({\n\t\t\t\tdate,\n\t\t\t\tinputTokens,\n\t\t\t\toutputTokens,\n\t\t\t\tcacheCreationTokens,\n\t\t\t\tcacheReadTokens,\n\t\t\t\ttotalTokens,\n\t\t\t\tcredits,\n\t\t\t\ttotalCost,\n\t\t\t\tmodelsUsed: Array.from(modelsSet),\n\t\t\t});\n\t\t}\n\n\t\tdailyData.sort((a, b) => a.date.localeCompare(b.date));\n\n\t\tconst totals = {\n\t\t\tinputTokens: dailyData.reduce((sum, d) => sum + d.inputTokens, 0),\n\t\t\toutputTokens: dailyData.reduce((sum, d) => sum + d.outputTokens, 0),\n\t\t\tcacheCreationTokens: dailyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0),\n\t\t\tcacheReadTokens: dailyData.reduce((sum, d) => sum + d.cacheReadTokens, 0),\n\t\t\ttotalTokens: dailyData.reduce((sum, d) => sum + d.totalTokens, 0),\n\t\t\tcredits: dailyData.reduce((sum, d) => sum + d.credits, 0),\n\t\t\ttotalCost: dailyData.reduce((sum, d) => sum + d.totalCost, 0),\n\t\t};\n\n\t\tif (jsonOutput) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tdaily: dailyData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log('\\n📊 Amp Token Usage Report - Daily\\n');\n\n\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\thead: [\n\t\t\t\t'Date',\n\t\t\t\t'Models',\n\t\t\t\t'Input',\n\t\t\t\t'Output',\n\t\t\t\t'Cache Create',\n\t\t\t\t'Cache Read',\n\t\t\t\t'Total Tokens',\n\t\t\t\t'Credits',\n\t\t\t\t'Cost (USD)',\n\t\t\t],\n\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\tcompactHead: ['Date', 'Models', 'Input', 'Output', 'Credits', 'Cost (USD)'],\n\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right', 'right'],\n\t\t\tcompactThreshold: 100,\n\t\t\tforceCompact: Boolean(ctx.values.compact),\n\t\t\tstyle: { head: ['cyan'] },\n\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t});\n\n\t\tfor (const data of dailyData) {\n\t\t\ttable.push([\n\t\t\t\tdata.date,\n\t\t\t\tformatModelsDisplayMultiline(data.modelsUsed),\n\t\t\t\tformatNumber(data.inputTokens),\n\t\t\t\tformatNumber(data.outputTokens),\n\t\t\t\tformatNumber(data.cacheCreationTokens),\n\t\t\t\tformatNumber(data.cacheReadTokens),\n\t\t\t\tformatNumber(data.totalTokens),\n\t\t\t\tdata.credits.toFixed(2),\n\t\t\t\tformatCurrency(data.totalCost),\n\t\t\t]);\n\t\t}\n\n\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\ttable.push([\n\t\t\tpc.yellow('Total'),\n\t\t\t'',\n\t\t\tpc.yellow(formatNumber(totals.inputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.outputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheCreationTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheReadTokens)),\n\t\t\tpc.yellow(formatNumber(totals.totalTokens)),\n\t\t\tpc.yellow(totals.credits.toFixed(2)),\n\t\t\tpc.yellow(formatCurrency(totals.totalCost)),\n\t\t]);\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(table.toString());\n\n\t\tif (table.isCompactMode()) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('\\nRunning in Compact Mode');\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('Expand terminal width to see cache metrics and total tokens');\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/amp/src/commands/index.ts",
    "content": "export { dailyCommand } from './daily.ts';\nexport { monthlyCommand } from './monthly.ts';\nexport { sessionCommand } from './session.ts';\n"
  },
  {
    "path": "apps/amp/src/commands/monthly.ts",
    "content": "import type { TokenUsageEvent } from '../_types.ts';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { loadAmpUsageEvents } from '../data-loader.ts';\nimport { AmpPricingSource } from '../pricing.ts';\n\nconst TABLE_COLUMN_COUNT = 9;\n\nfunction groupByMonth(events: TokenUsageEvent[]): Map<string, TokenUsageEvent[]> {\n\tconst grouped = new Map<string, TokenUsageEvent[]>();\n\tfor (const event of events) {\n\t\tconst month = event.timestamp.slice(0, 7); // YYYY-MM\n\t\tconst existing = grouped.get(month);\n\t\tif (existing != null) {\n\t\t\texisting.push(event);\n\t\t} else {\n\t\t\tgrouped.set(month, [event]);\n\t\t}\n\t}\n\treturn grouped;\n}\n\nexport const monthlyCommand = define({\n\tname: 'monthly',\n\tdescription: 'Show Amp token usage grouped by month',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'j',\n\t\t\tdescription: 'Output in JSON format',\n\t\t},\n\t\tcompact: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Force compact table mode',\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\n\t\tconst { events } = await loadAmpUsageEvents();\n\n\t\tif (events.length === 0) {\n\t\t\tconst output = jsonOutput\n\t\t\t\t? JSON.stringify({ monthly: [], totals: null })\n\t\t\t\t: 'No Amp usage data found.';\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(output);\n\t\t\treturn;\n\t\t}\n\n\t\tusing pricingSource = new AmpPricingSource({ offline: false });\n\n\t\tconst eventsByMonth = groupByMonth(events);\n\n\t\tconst monthlyData: Array<{\n\t\t\tmonth: string;\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\ttotalTokens: number;\n\t\t\tcredits: number;\n\t\t\ttotalCost: number;\n\t\t\tmodelsUsed: string[];\n\t\t}> = [];\n\n\t\tfor (const [month, monthEvents] of eventsByMonth) {\n\t\t\tlet inputTokens = 0;\n\t\t\tlet outputTokens = 0;\n\t\t\tlet cacheCreationTokens = 0;\n\t\t\tlet cacheReadTokens = 0;\n\t\t\tlet credits = 0;\n\t\t\tlet totalCost = 0;\n\t\t\tconst modelsSet = new Set<string>();\n\n\t\t\tfor (const event of monthEvents) {\n\t\t\t\tinputTokens += event.inputTokens;\n\t\t\t\toutputTokens += event.outputTokens;\n\t\t\t\tcacheCreationTokens += event.cacheCreationInputTokens;\n\t\t\t\tcacheReadTokens += event.cacheReadInputTokens;\n\t\t\t\tcredits += event.credits;\n\n\t\t\t\tconst cost = await pricingSource.calculateCost(event.model, {\n\t\t\t\t\tinputTokens: event.inputTokens,\n\t\t\t\t\toutputTokens: event.outputTokens,\n\t\t\t\t\tcacheCreationInputTokens: event.cacheCreationInputTokens,\n\t\t\t\t\tcacheReadInputTokens: event.cacheReadInputTokens,\n\t\t\t\t});\n\t\t\t\ttotalCost += cost;\n\t\t\t\tmodelsSet.add(event.model);\n\t\t\t}\n\n\t\t\tconst totalTokens = inputTokens + outputTokens;\n\n\t\t\tmonthlyData.push({\n\t\t\t\tmonth,\n\t\t\t\tinputTokens,\n\t\t\t\toutputTokens,\n\t\t\t\tcacheCreationTokens,\n\t\t\t\tcacheReadTokens,\n\t\t\t\ttotalTokens,\n\t\t\t\tcredits,\n\t\t\t\ttotalCost,\n\t\t\t\tmodelsUsed: Array.from(modelsSet),\n\t\t\t});\n\t\t}\n\n\t\tmonthlyData.sort((a, b) => a.month.localeCompare(b.month));\n\n\t\tconst totals = {\n\t\t\tinputTokens: monthlyData.reduce((sum, d) => sum + d.inputTokens, 0),\n\t\t\toutputTokens: monthlyData.reduce((sum, d) => sum + d.outputTokens, 0),\n\t\t\tcacheCreationTokens: monthlyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0),\n\t\t\tcacheReadTokens: monthlyData.reduce((sum, d) => sum + d.cacheReadTokens, 0),\n\t\t\ttotalTokens: monthlyData.reduce((sum, d) => sum + d.totalTokens, 0),\n\t\t\tcredits: monthlyData.reduce((sum, d) => sum + d.credits, 0),\n\t\t\ttotalCost: monthlyData.reduce((sum, d) => sum + d.totalCost, 0),\n\t\t};\n\n\t\tif (jsonOutput) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tmonthly: monthlyData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log('\\n📊 Amp Token Usage Report - Monthly\\n');\n\n\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\thead: [\n\t\t\t\t'Month',\n\t\t\t\t'Models',\n\t\t\t\t'Input',\n\t\t\t\t'Output',\n\t\t\t\t'Cache Create',\n\t\t\t\t'Cache Read',\n\t\t\t\t'Total Tokens',\n\t\t\t\t'Credits',\n\t\t\t\t'Cost (USD)',\n\t\t\t],\n\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\tcompactHead: ['Month', 'Models', 'Input', 'Output', 'Credits', 'Cost (USD)'],\n\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right', 'right'],\n\t\t\tcompactThreshold: 100,\n\t\t\tforceCompact: Boolean(ctx.values.compact),\n\t\t\tstyle: { head: ['cyan'] },\n\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t});\n\n\t\tfor (const data of monthlyData) {\n\t\t\ttable.push([\n\t\t\t\tdata.month,\n\t\t\t\tformatModelsDisplayMultiline(data.modelsUsed),\n\t\t\t\tformatNumber(data.inputTokens),\n\t\t\t\tformatNumber(data.outputTokens),\n\t\t\t\tformatNumber(data.cacheCreationTokens),\n\t\t\t\tformatNumber(data.cacheReadTokens),\n\t\t\t\tformatNumber(data.totalTokens),\n\t\t\t\tdata.credits.toFixed(2),\n\t\t\t\tformatCurrency(data.totalCost),\n\t\t\t]);\n\t\t}\n\n\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\ttable.push([\n\t\t\tpc.yellow('Total'),\n\t\t\t'',\n\t\t\tpc.yellow(formatNumber(totals.inputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.outputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheCreationTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheReadTokens)),\n\t\t\tpc.yellow(formatNumber(totals.totalTokens)),\n\t\t\tpc.yellow(totals.credits.toFixed(2)),\n\t\t\tpc.yellow(formatCurrency(totals.totalCost)),\n\t\t]);\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(table.toString());\n\n\t\tif (table.isCompactMode()) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('\\nRunning in Compact Mode');\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('Expand terminal width to see cache metrics and total tokens');\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/amp/src/commands/session.ts",
    "content": "import type { TokenUsageEvent } from '../_types.ts';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { loadAmpUsageEvents } from '../data-loader.ts';\nimport { AmpPricingSource } from '../pricing.ts';\n\nconst TABLE_COLUMN_COUNT = 9;\n\nfunction groupByThread(events: TokenUsageEvent[]): Map<string, TokenUsageEvent[]> {\n\tconst grouped = new Map<string, TokenUsageEvent[]>();\n\tfor (const event of events) {\n\t\tconst existing = grouped.get(event.threadId);\n\t\tif (existing != null) {\n\t\t\texisting.push(event);\n\t\t} else {\n\t\t\tgrouped.set(event.threadId, [event]);\n\t\t}\n\t}\n\treturn grouped;\n}\n\nexport const sessionCommand = define({\n\tname: 'session',\n\tdescription: 'Show Amp token usage grouped by thread (session)',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'j',\n\t\t\tdescription: 'Output in JSON format',\n\t\t},\n\t\tcompact: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Force compact table mode',\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\n\t\tconst { events, threads } = await loadAmpUsageEvents();\n\n\t\tif (events.length === 0) {\n\t\t\tconst output = jsonOutput\n\t\t\t\t? JSON.stringify({ sessions: [], totals: null })\n\t\t\t\t: 'No Amp usage data found.';\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(output);\n\t\t\treturn;\n\t\t}\n\n\t\tusing pricingSource = new AmpPricingSource({ offline: false });\n\n\t\tconst eventsByThread = groupByThread(events);\n\n\t\tconst sessionData: Array<{\n\t\t\tthreadId: string;\n\t\t\ttitle: string;\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\ttotalTokens: number;\n\t\t\tcredits: number;\n\t\t\ttotalCost: number;\n\t\t\tmodelsUsed: string[];\n\t\t\tlastActivity: string;\n\t\t}> = [];\n\n\t\tfor (const [threadId, threadEvents] of eventsByThread) {\n\t\t\tlet inputTokens = 0;\n\t\t\tlet outputTokens = 0;\n\t\t\tlet cacheCreationTokens = 0;\n\t\t\tlet cacheReadTokens = 0;\n\t\t\tlet credits = 0;\n\t\t\tlet totalCost = 0;\n\t\t\tconst modelsSet = new Set<string>();\n\t\t\tlet lastActivity = threadEvents[0]!.timestamp;\n\n\t\t\tfor (const event of threadEvents) {\n\t\t\t\tinputTokens += event.inputTokens;\n\t\t\t\toutputTokens += event.outputTokens;\n\t\t\t\tcacheCreationTokens += event.cacheCreationInputTokens;\n\t\t\t\tcacheReadTokens += event.cacheReadInputTokens;\n\t\t\t\tcredits += event.credits;\n\n\t\t\t\tconst cost = await pricingSource.calculateCost(event.model, {\n\t\t\t\t\tinputTokens: event.inputTokens,\n\t\t\t\t\toutputTokens: event.outputTokens,\n\t\t\t\t\tcacheCreationInputTokens: event.cacheCreationInputTokens,\n\t\t\t\t\tcacheReadInputTokens: event.cacheReadInputTokens,\n\t\t\t\t});\n\t\t\t\ttotalCost += cost;\n\t\t\t\tmodelsSet.add(event.model);\n\n\t\t\t\tif (event.timestamp > lastActivity) {\n\t\t\t\t\tlastActivity = event.timestamp;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst totalTokens = inputTokens + outputTokens;\n\t\t\tconst threadInfo = threads.get(threadId);\n\n\t\t\tsessionData.push({\n\t\t\t\tthreadId,\n\t\t\t\ttitle: threadInfo?.title ?? 'Untitled',\n\t\t\t\tinputTokens,\n\t\t\t\toutputTokens,\n\t\t\t\tcacheCreationTokens,\n\t\t\t\tcacheReadTokens,\n\t\t\t\ttotalTokens,\n\t\t\t\tcredits,\n\t\t\t\ttotalCost,\n\t\t\t\tmodelsUsed: Array.from(modelsSet),\n\t\t\t\tlastActivity,\n\t\t\t});\n\t\t}\n\n\t\tsessionData.sort((a, b) => a.lastActivity.localeCompare(b.lastActivity));\n\n\t\tconst totals = {\n\t\t\tinputTokens: sessionData.reduce((sum, s) => sum + s.inputTokens, 0),\n\t\t\toutputTokens: sessionData.reduce((sum, s) => sum + s.outputTokens, 0),\n\t\t\tcacheCreationTokens: sessionData.reduce((sum, s) => sum + s.cacheCreationTokens, 0),\n\t\t\tcacheReadTokens: sessionData.reduce((sum, s) => sum + s.cacheReadTokens, 0),\n\t\t\ttotalTokens: sessionData.reduce((sum, s) => sum + s.totalTokens, 0),\n\t\t\tcredits: sessionData.reduce((sum, s) => sum + s.credits, 0),\n\t\t\ttotalCost: sessionData.reduce((sum, s) => sum + s.totalCost, 0),\n\t\t};\n\n\t\tif (jsonOutput) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tsessions: sessionData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log('\\n📊 Amp Token Usage Report - Sessions (Threads)\\n');\n\n\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\thead: [\n\t\t\t\t'Thread',\n\t\t\t\t'Models',\n\t\t\t\t'Input',\n\t\t\t\t'Output',\n\t\t\t\t'Cache Create',\n\t\t\t\t'Cache Read',\n\t\t\t\t'Total Tokens',\n\t\t\t\t'Credits',\n\t\t\t\t'Cost (USD)',\n\t\t\t],\n\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\tcompactHead: ['Thread', 'Models', 'Input', 'Output', 'Credits', 'Cost (USD)'],\n\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right', 'right'],\n\t\t\tcompactThreshold: 100,\n\t\t\tforceCompact: Boolean(ctx.values.compact),\n\t\t\tstyle: { head: ['cyan'] },\n\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t});\n\n\t\tfor (const data of sessionData) {\n\t\t\t// Truncate title for display\n\t\t\tconst displayTitle = data.title.length > 30 ? `${data.title.slice(0, 27)}...` : data.title;\n\n\t\t\ttable.push([\n\t\t\t\tdisplayTitle,\n\t\t\t\tformatModelsDisplayMultiline(data.modelsUsed),\n\t\t\t\tformatNumber(data.inputTokens),\n\t\t\t\tformatNumber(data.outputTokens),\n\t\t\t\tformatNumber(data.cacheCreationTokens),\n\t\t\t\tformatNumber(data.cacheReadTokens),\n\t\t\t\tformatNumber(data.totalTokens),\n\t\t\t\tdata.credits.toFixed(2),\n\t\t\t\tformatCurrency(data.totalCost),\n\t\t\t]);\n\t\t}\n\n\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\ttable.push([\n\t\t\tpc.yellow('Total'),\n\t\t\t'',\n\t\t\tpc.yellow(formatNumber(totals.inputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.outputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheCreationTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheReadTokens)),\n\t\t\tpc.yellow(formatNumber(totals.totalTokens)),\n\t\t\tpc.yellow(totals.credits.toFixed(2)),\n\t\t\tpc.yellow(formatCurrency(totals.totalCost)),\n\t\t]);\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(table.toString());\n\n\t\tif (table.isCompactMode()) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('\\nRunning in Compact Mode');\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('Expand terminal width to see cache metrics and total tokens');\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/amp/src/data-loader.ts",
    "content": "/**\n * @fileoverview Data loading utilities for Amp CLI usage analysis\n *\n * This module provides functions for loading and parsing Amp usage data\n * from JSON thread files stored in Amp data directories.\n * Amp stores usage data in ~/.local/share/amp/threads/\n *\n * @module data-loader\n */\n\nimport type { TokenUsageEvent } from './_types.ts';\nimport { readFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport process from 'node:process';\nimport { Result } from '@praha/byethrow';\nimport { createFixture } from 'fs-fixture';\nimport { isDirectorySync } from 'path-type';\nimport { glob } from 'tinyglobby';\nimport * as v from 'valibot';\nimport {\n\tAMP_DATA_DIR_ENV,\n\tAMP_THREAD_GLOB,\n\tAMP_THREADS_DIR_NAME,\n\tDEFAULT_AMP_DIR,\n} from './_consts.ts';\nimport { logger } from './logger.ts';\n\n/**\n * Amp usageLedger event schema\n */\nconst usageLedgerEventSchema = v.object({\n\tid: v.string(),\n\ttimestamp: v.string(),\n\tmodel: v.string(),\n\tcredits: v.number(),\n\ttokens: v.object({\n\t\tinput: v.optional(v.number()),\n\t\toutput: v.optional(v.number()),\n\t}),\n\toperationType: v.optional(v.string()),\n\tfromMessageId: v.optional(v.number()),\n\ttoMessageId: v.optional(v.number()),\n});\n\n/**\n * Amp message usage schema (for cache tokens)\n */\nconst messageUsageSchema = v.object({\n\tmodel: v.optional(v.string()),\n\tinputTokens: v.optional(v.number()),\n\toutputTokens: v.optional(v.number()),\n\tcacheCreationInputTokens: v.optional(v.number()),\n\tcacheReadInputTokens: v.optional(v.number()),\n\ttotalInputTokens: v.optional(v.number()),\n\tcredits: v.optional(v.number()),\n});\n\n/**\n * Amp message schema\n */\nconst messageSchema = v.object({\n\trole: v.string(),\n\tmessageId: v.number(),\n\tusage: v.optional(messageUsageSchema),\n});\n\n/**\n * Amp thread file schema\n */\nconst threadSchema = v.object({\n\tid: v.string(),\n\tcreated: v.optional(v.number()),\n\ttitle: v.optional(v.string()),\n\tmessages: v.optional(v.array(messageSchema)),\n\tusageLedger: v.optional(\n\t\tv.object({\n\t\t\tevents: v.optional(v.array(usageLedgerEventSchema)),\n\t\t}),\n\t),\n});\n\ntype ParsedThread = v.InferOutput<typeof threadSchema>;\ntype ParsedUsageLedgerEvent = v.InferOutput<typeof usageLedgerEventSchema>;\ntype ParsedMessage = v.InferOutput<typeof messageSchema>;\n\n/**\n * Get Amp data directory\n * @returns Path to Amp data directory, or null if not found\n */\nexport function getAmpPath(): string | null {\n\t// Check environment variable first\n\tconst envPath = process.env[AMP_DATA_DIR_ENV];\n\tif (envPath != null && envPath.trim() !== '') {\n\t\tconst normalizedPath = path.resolve(envPath);\n\t\tif (isDirectorySync(normalizedPath)) {\n\t\t\treturn normalizedPath;\n\t\t}\n\t}\n\n\t// Use default path\n\tif (isDirectorySync(DEFAULT_AMP_DIR)) {\n\t\treturn DEFAULT_AMP_DIR;\n\t}\n\n\treturn null;\n}\n\n/**\n * Find cache token information from messages for a specific messageId range\n */\nfunction findCacheTokensForEvent(\n\tmessages: ParsedMessage[] | undefined,\n\tfromMessageId: number | undefined,\n\ttoMessageId: number | undefined,\n): { cacheCreationInputTokens: number; cacheReadInputTokens: number } {\n\tif (messages == null || toMessageId == null) {\n\t\treturn { cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };\n\t}\n\n\t// Find the assistant message that corresponds to this event\n\tconst message = messages.find((m) => m.role === 'assistant' && m.messageId === toMessageId);\n\n\tif (message?.usage == null) {\n\t\treturn { cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };\n\t}\n\n\treturn {\n\t\tcacheCreationInputTokens: message.usage.cacheCreationInputTokens ?? 0,\n\t\tcacheReadInputTokens: message.usage.cacheReadInputTokens ?? 0,\n\t};\n}\n\n/**\n * Convert usageLedger event to TokenUsageEvent\n */\nfunction convertLedgerEventToUsageEvent(\n\tthreadId: string,\n\tevent: ParsedUsageLedgerEvent,\n\tmessages: ParsedMessage[] | undefined,\n): TokenUsageEvent {\n\tconst inputTokens = event.tokens.input ?? 0;\n\tconst outputTokens = event.tokens.output ?? 0;\n\n\tconst { cacheCreationInputTokens, cacheReadInputTokens } = findCacheTokensForEvent(\n\t\tmessages,\n\t\tevent.fromMessageId,\n\t\tevent.toMessageId,\n\t);\n\n\treturn {\n\t\ttimestamp: event.timestamp,\n\t\tthreadId,\n\t\tmodel: event.model,\n\t\tcredits: event.credits,\n\t\toperationType: event.operationType ?? 'unknown',\n\t\tinputTokens,\n\t\toutputTokens,\n\t\tcacheCreationInputTokens,\n\t\tcacheReadInputTokens,\n\t\ttotalTokens: inputTokens + outputTokens,\n\t};\n}\n\n/**\n * Load and parse a single Amp thread file\n */\nasync function loadThreadFile(filePath: string): Promise<ParsedThread | null> {\n\tconst readResult = await Result.try({\n\t\ttry: readFile(filePath, 'utf-8'),\n\t\tcatch: (error) => error,\n\t});\n\n\tif (Result.isFailure(readResult)) {\n\t\tlogger.debug('Failed to read Amp thread file', { filePath, error: readResult.error });\n\t\treturn null;\n\t}\n\n\tconst parseResult = Result.try({\n\t\ttry: () => JSON.parse(readResult.value) as unknown,\n\t\tcatch: (error) => error,\n\t})();\n\n\tif (Result.isFailure(parseResult)) {\n\t\tlogger.debug('Failed to parse Amp thread JSON', { filePath, error: parseResult.error });\n\t\treturn null;\n\t}\n\n\tconst validationResult = v.safeParse(threadSchema, parseResult.value);\n\tif (!validationResult.success) {\n\t\tlogger.debug('Failed to validate Amp thread schema', {\n\t\t\tfilePath,\n\t\t\tissues: validationResult.issues,\n\t\t});\n\t\treturn null;\n\t}\n\n\treturn validationResult.output;\n}\n\nexport type LoadOptions = {\n\tthreadDirs?: string[];\n};\n\nexport type LoadResult = {\n\tevents: TokenUsageEvent[];\n\tthreads: Map<string, { title: string; created: number | undefined }>;\n\tmissingDirectories: string[];\n};\n\n/**\n * Load all Amp usage events from thread files\n */\nexport async function loadAmpUsageEvents(options: LoadOptions = {}): Promise<LoadResult> {\n\tconst ampPath = getAmpPath();\n\tconst providedDirs =\n\t\toptions.threadDirs != null && options.threadDirs.length > 0\n\t\t\t? options.threadDirs.map((dir) => path.resolve(dir))\n\t\t\t: undefined;\n\n\tconst defaultThreadsDir = ampPath != null ? path.join(ampPath, AMP_THREADS_DIR_NAME) : null;\n\n\tconst threadDirs = providedDirs ?? (defaultThreadsDir != null ? [defaultThreadsDir] : []);\n\n\tconst events: TokenUsageEvent[] = [];\n\tconst threads = new Map<string, { title: string; created: number | undefined }>();\n\tconst missingDirectories: string[] = [];\n\n\tfor (const dir of threadDirs) {\n\t\tif (!isDirectorySync(dir)) {\n\t\t\tmissingDirectories.push(dir);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst files = await glob(AMP_THREAD_GLOB, {\n\t\t\tcwd: dir,\n\t\t\tabsolute: true,\n\t\t});\n\n\t\tfor (const file of files) {\n\t\t\tconst thread = await loadThreadFile(file);\n\t\t\tif (thread == null) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst threadId = thread.id;\n\t\t\tthreads.set(threadId, {\n\t\t\t\ttitle: thread.title ?? 'Untitled',\n\t\t\t\tcreated: thread.created,\n\t\t\t});\n\n\t\t\tconst ledgerEvents = thread.usageLedger?.events ?? [];\n\t\t\tfor (const ledgerEvent of ledgerEvents) {\n\t\t\t\tconst event = convertLedgerEventToUsageEvent(threadId, ledgerEvent, thread.messages);\n\t\t\t\tevents.push(event);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Sort events by timestamp\n\tevents.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());\n\n\treturn { events, threads, missingDirectories };\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('loadAmpUsageEvents', () => {\n\t\tit('parses Amp thread files and extracts usage events', async () => {\n\t\t\tconst threadData = {\n\t\t\t\tv: 195,\n\t\t\t\tid: 'T-test-thread-123',\n\t\t\t\tcreated: 1700000000000,\n\t\t\t\ttitle: 'Test Thread',\n\t\t\t\tmessages: [\n\t\t\t\t\t{\n\t\t\t\t\t\trole: 'user',\n\t\t\t\t\t\tmessageId: 0,\n\t\t\t\t\t\tcontent: [{ type: 'text', text: 'hi' }],\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\trole: 'assistant',\n\t\t\t\t\t\tmessageId: 1,\n\t\t\t\t\t\tcontent: [{ type: 'text', text: 'Hello!' }],\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tmodel: 'claude-haiku-4-5-20251001',\n\t\t\t\t\t\t\tinputTokens: 100,\n\t\t\t\t\t\t\toutputTokens: 50,\n\t\t\t\t\t\t\tcacheCreationInputTokens: 500,\n\t\t\t\t\t\t\tcacheReadInputTokens: 200,\n\t\t\t\t\t\t\ttotalInputTokens: 800,\n\t\t\t\t\t\t\tcredits: 1.5,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tusageLedger: {\n\t\t\t\t\tevents: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tid: 'event-1',\n\t\t\t\t\t\t\ttimestamp: '2025-11-23T10:00:00.000Z',\n\t\t\t\t\t\t\tmodel: 'claude-haiku-4-5-20251001',\n\t\t\t\t\t\t\tcredits: 1.5,\n\t\t\t\t\t\t\ttokens: {\n\t\t\t\t\t\t\t\tinput: 100,\n\t\t\t\t\t\t\t\toutput: 50,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\toperationType: 'inference',\n\t\t\t\t\t\t\tfromMessageId: 0,\n\t\t\t\t\t\t\ttoMessageId: 1,\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tthreads: {\n\t\t\t\t\t'T-test-thread-123.json': JSON.stringify(threadData),\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst { events, threads, missingDirectories } = await loadAmpUsageEvents({\n\t\t\t\tthreadDirs: [fixture.getPath('threads')],\n\t\t\t});\n\n\t\t\texpect(missingDirectories).toEqual([]);\n\t\t\texpect(events).toHaveLength(1);\n\n\t\t\tconst event = events[0]!;\n\t\t\texpect(event.threadId).toBe('T-test-thread-123');\n\t\t\texpect(event.model).toBe('claude-haiku-4-5-20251001');\n\t\t\texpect(event.inputTokens).toBe(100);\n\t\t\texpect(event.outputTokens).toBe(50);\n\t\t\texpect(event.cacheCreationInputTokens).toBe(500);\n\t\t\texpect(event.cacheReadInputTokens).toBe(200);\n\t\t\texpect(event.credits).toBe(1.5);\n\n\t\t\texpect(threads.get('T-test-thread-123')).toEqual({\n\t\t\t\ttitle: 'Test Thread',\n\t\t\t\tcreated: 1700000000000,\n\t\t\t});\n\t\t});\n\n\t\tit('handles missing directories gracefully', async () => {\n\t\t\tconst { events, missingDirectories } = await loadAmpUsageEvents({\n\t\t\t\tthreadDirs: ['/nonexistent/path'],\n\t\t\t});\n\n\t\t\texpect(events).toEqual([]);\n\t\t\texpect(missingDirectories).toContain(path.resolve('/nonexistent/path'));\n\t\t});\n\n\t\tit('handles malformed JSON gracefully', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tthreads: {\n\t\t\t\t\t'invalid.json': 'not valid json',\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst { events } = await loadAmpUsageEvents({\n\t\t\t\tthreadDirs: [fixture.getPath('threads')],\n\t\t\t});\n\n\t\t\texpect(events).toEqual([]);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/amp/src/index.ts",
    "content": "#!/usr/bin/env node\n\nimport { run } from './run.ts';\n\n// eslint-disable-next-line antfu/no-top-level-await\nawait run();\n"
  },
  {
    "path": "apps/amp/src/logger.ts",
    "content": "import { createLogger } from '@ccusage/internal/logger';\n\nexport const logger = createLogger('@ccusage/amp');\n"
  },
  {
    "path": "apps/amp/src/pricing.ts",
    "content": "import type { LiteLLMModelPricing } from '@ccusage/internal/pricing';\nimport type { ModelPricing, PricingSource } from './_types.ts';\nimport { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';\nimport { Result } from '@praha/byethrow';\nimport { MILLION } from './_consts.ts';\nimport { prefetchAmpPricing } from './_macro.ts' with { type: 'macro' };\nimport { logger } from './logger.ts';\n\nconst AMP_PROVIDER_PREFIXES = ['anthropic/'];\nconst ZERO_MODEL_PRICING = {\n\tinputCostPerMToken: 0,\n\tcachedInputCostPerMToken: 0,\n\tcacheCreationCostPerMToken: 0,\n\toutputCostPerMToken: 0,\n} as const satisfies ModelPricing;\n\nfunction toPerMillion(value: number | undefined, fallback?: number): number {\n\tconst perToken = value ?? fallback ?? 0;\n\treturn perToken * MILLION;\n}\n\nexport type AmpPricingSourceOptions = {\n\toffline?: boolean;\n\tofflineLoader?: () => Promise<Record<string, LiteLLMModelPricing>>;\n};\n\nconst PREFETCHED_AMP_PRICING = prefetchAmpPricing();\n\nexport class AmpPricingSource implements PricingSource, Disposable {\n\tprivate readonly fetcher: LiteLLMPricingFetcher;\n\n\tconstructor(options: AmpPricingSourceOptions = {}) {\n\t\tthis.fetcher = new LiteLLMPricingFetcher({\n\t\t\toffline: options.offline ?? false,\n\t\t\tofflineLoader: options.offlineLoader ?? (async () => PREFETCHED_AMP_PRICING),\n\t\t\tlogger,\n\t\t\tproviderPrefixes: AMP_PROVIDER_PREFIXES,\n\t\t});\n\t}\n\n\t[Symbol.dispose](): void {\n\t\tthis.fetcher[Symbol.dispose]();\n\t}\n\n\tasync getPricing(model: string): Promise<ModelPricing> {\n\t\tconst directLookup = await this.fetcher.getModelPricing(model);\n\t\tif (Result.isFailure(directLookup)) {\n\t\t\tthrow directLookup.error;\n\t\t}\n\n\t\tconst pricing = directLookup.value;\n\t\tif (pricing == null) {\n\t\t\tlogger.warn(`Pricing not found for model ${model}; defaulting to zero-cost pricing.`);\n\t\t\treturn ZERO_MODEL_PRICING;\n\t\t}\n\n\t\treturn {\n\t\t\tinputCostPerMToken: toPerMillion(pricing.input_cost_per_token),\n\t\t\tcachedInputCostPerMToken: toPerMillion(\n\t\t\t\tpricing.cache_read_input_token_cost,\n\t\t\t\tpricing.input_cost_per_token,\n\t\t\t),\n\t\t\tcacheCreationCostPerMToken: toPerMillion(\n\t\t\t\tpricing.cache_creation_input_token_cost,\n\t\t\t\tpricing.input_cost_per_token,\n\t\t\t),\n\t\t\toutputCostPerMToken: toPerMillion(pricing.output_cost_per_token),\n\t\t};\n\t}\n\n\tasync calculateCost(\n\t\tmodel: string,\n\t\ttokens: {\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationInputTokens?: number;\n\t\t\tcacheReadInputTokens?: number;\n\t\t},\n\t): Promise<number> {\n\t\tconst result = await this.fetcher.calculateCostFromTokens(\n\t\t\t{\n\t\t\t\tinput_tokens: tokens.inputTokens,\n\t\t\t\toutput_tokens: tokens.outputTokens,\n\t\t\t\tcache_creation_input_tokens: tokens.cacheCreationInputTokens,\n\t\t\t\tcache_read_input_tokens: tokens.cacheReadInputTokens,\n\t\t\t},\n\t\t\tmodel,\n\t\t);\n\n\t\tif (Result.isFailure(result)) {\n\t\t\tlogger.warn(`Failed to calculate cost for model ${model}:`, result.error);\n\t\t\treturn 0;\n\t\t}\n\n\t\treturn result.value;\n\t}\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('AmpPricingSource', () => {\n\t\tit('converts LiteLLM pricing to per-million costs', async () => {\n\t\t\tusing source = new AmpPricingSource({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'claude-haiku-4-5-20251001': {\n\t\t\t\t\t\tinput_cost_per_token: 1e-6,\n\t\t\t\t\t\toutput_cost_per_token: 5e-6,\n\t\t\t\t\t\tcache_read_input_token_cost: 1e-7,\n\t\t\t\t\t\tcache_creation_input_token_cost: 1.25e-6,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst pricing = await source.getPricing('claude-haiku-4-5-20251001');\n\t\t\texpect(pricing.inputCostPerMToken).toBeCloseTo(1);\n\t\t\texpect(pricing.outputCostPerMToken).toBeCloseTo(5);\n\t\t\texpect(pricing.cachedInputCostPerMToken).toBeCloseTo(0.1);\n\t\t\texpect(pricing.cacheCreationCostPerMToken).toBeCloseTo(1.25);\n\t\t});\n\n\t\tit('calculates cost from tokens', async () => {\n\t\t\tusing source = new AmpPricingSource({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'claude-haiku-4-5-20251001': {\n\t\t\t\t\t\tinput_cost_per_token: 1e-6,\n\t\t\t\t\t\toutput_cost_per_token: 5e-6,\n\t\t\t\t\t\tcache_read_input_token_cost: 1e-7,\n\t\t\t\t\t\tcache_creation_input_token_cost: 1.25e-6,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst cost = await source.calculateCost('claude-haiku-4-5-20251001', {\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t\tcacheReadInputTokens: 200,\n\t\t\t\tcacheCreationInputTokens: 100,\n\t\t\t});\n\n\t\t\tconst expected = 1000 * 1e-6 + 500 * 5e-6 + 200 * 1e-7 + 100 * 1.25e-6;\n\t\t\texpect(cost).toBeCloseTo(expected);\n\t\t});\n\n\t\tit('falls back to zero pricing for unknown models', async () => {\n\t\t\tusing source = new AmpPricingSource({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({}),\n\t\t\t});\n\n\t\t\tconst pricing = await source.getPricing('anthropic/unknown');\n\t\t\texpect(pricing).toEqual(ZERO_MODEL_PRICING);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/amp/src/run.ts",
    "content": "import process from 'node:process';\nimport { cli } from 'gunshi';\nimport { description, name, version } from '../package.json';\nimport { dailyCommand, monthlyCommand, sessionCommand } from './commands/index.ts';\n\nconst subCommands = new Map([\n\t['daily', dailyCommand],\n\t['monthly', monthlyCommand],\n\t['session', sessionCommand],\n]);\n\nconst mainCommand = dailyCommand;\n\nexport async function run(): Promise<void> {\n\t// When invoked through npx, the binary name might be passed as the first argument\n\t// Filter it out if it matches the expected binary name\n\tlet args = process.argv.slice(2);\n\tif (args[0] === 'ccusage-amp') {\n\t\targs = args.slice(1);\n\t}\n\n\tawait cli(args, mainCommand, {\n\t\tname,\n\t\tversion,\n\t\tdescription,\n\t\tsubCommands,\n\t\trenderHeader: null,\n\t});\n}\n"
  },
  {
    "path": "apps/amp/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"moduleDetection\": \"force\",\n\t\t\"module\": \"Preserve\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"types\": [\"vitest/globals\", \"vitest/importMeta\"],\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"allowJs\": false,\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noPropertyAccessFromIndexSignature\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t\"noUnusedLocals\": false,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noEmit\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"exclude\": [\"dist\"]\n}\n"
  },
  {
    "path": "apps/amp/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n\tentry: ['src/index.ts'],\n\tformat: ['esm'],\n\tclean: true,\n\tdts: false,\n\tshims: true,\n\tplatform: 'node',\n\ttarget: 'node20',\n\tfixedExtension: false,\n});\n"
  },
  {
    "path": "apps/amp/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\tglobals: true,\n\t\tincludeSource: ['src/**/*.ts'],\n\t\tcoverage: {\n\t\t\tprovider: 'v8',\n\t\t\treporter: ['text', 'json', 'html'],\n\t\t},\n\t},\n\tdefine: {\n\t\t'import.meta.vitest': 'undefined',\n\t},\n});\n"
  },
  {
    "path": "apps/ccusage/CLAUDE.md",
    "content": "# CLAUDE.md - ccusage Package\n\nThis is the main ccusage CLI package that provides usage analysis for Claude Code.\n\n## Package Overview\n\n**Name**: `ccusage`\n**Description**: Usage analysis tool for Claude Code\n**Type**: CLI tool and library with TypeScript exports\n\n## Development Commands\n\n**Testing and Quality:**\n\n- `pnpm run test` - Run all tests (using vitest via pnpm, watch mode disabled)\n- `pnpm run lint` - Lint code using ESLint\n- `pnpm run format` - Format and auto-fix code with ESLint\n- `pnpm typecheck` - Type check with TypeScript\n\n**Build and Release:**\n\n- `pnpm run build` - Build distribution files with tsdown (includes schema generation)\n- `pnpm run generate:schema` - Generate JSON schema for configuration\n- `pnpm run prerelease` - Full release workflow (lint + typecheck + build)\n\n**Development Usage:**\n\n- `pnpm run start daily` - Show daily usage report\n- `pnpm run start monthly` - Show monthly usage report\n- `pnpm run start session` - Show session-based usage report\n- `pnpm run start blocks` - Show 5-hour billing blocks usage report\n- `pnpm run start statusline` - Show compact status line (Beta)\n- Add `--json` flag for JSON output format\n- Add `--mode <mode>` for cost calculation control (auto/calculate/display)\n- Add `--active` flag for blocks to show only active block with projections\n- Add `--recent` flag for blocks to show last 3 days including active\n\n**CLI Testing:**\n\n- `pnpm run test:statusline` - Test statusline with default test data\n- `pnpm run test:statusline:all` - Test statusline with all model variants\n- `pnpm run test:statusline:sonnet4` - Test with Sonnet 4 data\n- `pnpm run test:statusline:opus4` - Test with Opus 4 data\n- `pnpm run test:statusline:sonnet41` - Test with Sonnet 4.1 data\n\n## Architecture\n\nThis package contains the core ccusage functionality:\n\n**Key Modules:**\n\n- `src/index.ts` - CLI entry point with Gunshi-based command routing\n- `src/data-loader.ts` - Parses JSONL files from Claude data directories\n- `src/calculate-cost.ts` - Token aggregation and cost calculation utilities\n- `src/commands/` - CLI subcommands (daily, monthly, session, blocks, statusline)\n- `src/logger.ts` - Logging utilities (use instead of console.log)\n\n**Data Flow:**\n\n1. Loads JSONL files from `~/.claude/projects/` and `~/.config/claude/projects/`\n2. Aggregates usage data by time periods or sessions\n3. Calculates costs using LiteLLM pricing database\n4. Outputs formatted tables or JSON\n\n## Testing Guidelines\n\n- **In-Source Testing**: Tests are written in the same files using `if (import.meta.vitest != null)` blocks\n- **Vitest Globals Enabled**: Use `describe`, `it`, `expect` directly without imports\n- **Model Testing**: Use current Claude 4 models (sonnet-4, opus-4) in tests\n- **Mock Data**: Uses `fs-fixture` with `createFixture()` for Claude data simulation\n- **CRITICAL**: NEVER use `await import()` dynamic imports anywhere, especially in test blocks\n\n## Code Style\n\n- **Error Handling**: Prefer `@praha/byethrow Result` type over try-catch for functional error handling\n- **Imports**: Use `.ts` extensions for local imports (e.g., `import { foo } from './utils.ts'`)\n- **Exports**: Only export what's actually used by other modules\n- **Dependencies**: Add as `devDependencies` unless explicitly requested otherwise\n- **No console.log**: Use `logger.ts` instead\n\n**Post-Change Workflow:**\nAlways run these commands in parallel after code changes:\n\n- `pnpm run format` - Auto-fix and format\n- `pnpm typecheck` - Type checking\n- `pnpm run test` - Run tests\n\n## Environment Variables\n\n- `LOG_LEVEL` - Control logging verbosity (0=silent, 1=warn, 2=log, 3=info, 4=debug, 5=trace)\n- `CLAUDE_CONFIG_DIR` - Custom Claude data directory paths (supports multiple comma-separated paths)\n\n## Dependencies\n\nBecause `ccusage` is distributed as a bundled CLI, keep all runtime libraries in `devDependencies` so the bundler captures them.\n\n**Key Runtime Dependencies:**\n\n- `gunshi` - CLI framework\n- `cli-table3` - Table formatting\n- `valibot` - Schema validation\n- `@praha/byethrow` - Functional error handling\n\n**Key Dev Dependencies:**\n\n- `vitest` - Testing framework\n- `tsdown` - TypeScript build tool\n- `eslint` - Linting and formatting\n- `fs-fixture` - Test fixture creation\n\n## Package Exports\n\nThe package provides multiple exports for library usage:\n\n- `.` - Main CLI entry point\n- `./calculate-cost` - Cost calculation utilities\n- `./data-loader` - Data loading functions\n- `./debug` - Debug utilities\n- `./logger` - Logging utilities\n- `./pricing-fetcher` - LiteLLM pricing integration\n"
  },
  {
    "path": "apps/ccusage/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 ryoppippi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "apps/ccusage/README.md",
    "content": "<div align=\"center\">\n    <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.svg\" alt=\"ccusage logo\" width=\"256\" height=\"256\">\n    <h1>ccusage</h1>\n</div>\n\n<p align=\"center\">\n    <a href=\"https://socket.dev/api/npm/package/ccusage\"><img src=\"https://socket.dev/api/badge/npm/package/ccusage\" alt=\"Socket Badge\" /></a>\n    <a href=\"https://npmjs.com/package/ccusage\"><img src=\"https://img.shields.io/npm/v/ccusage?color=yellow\" alt=\"npm version\" /></a>\n    <a href=\"https://tanstack.com/stats/npm?packageGroups=%5B%7B%22packages%22:%5B%7B%22name%22:%22ccusage%22%7D%5D%7D%5D&range=30-days&transform=none&binType=daily&showDataMode=all&height=400\"><img src=\"https://img.shields.io/npm/dt/ccusage\" alt=\"NPM Downloads\" /></a>\n    <a href=\"https://packagephobia.com/result?p=ccusage\"><img src=\"https://packagephobia.com/badge?p=ccusage\" alt=\"install size\" /></a>\n    <a href=\"https://deepwiki.com/ryoppippi/ccusage\"><img src=\"https://img.shields.io/badge/DeepWiki-ryoppippi%2Fccusage-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==\" alt=\"DeepWiki\"></a>\n    <!-- DeepWiki badge generated by https://deepwiki.ryoppippi.com/ -->\n    <a href=\"https://github.com/hesreallyhim/awesome-claude-code\"><img src=\"https://awesome.re/mentioned-badge.svg\" alt=\"Mentioned in Awesome Claude Code\" /></a>\n</p>\n\n<div align=\"center\">\n    <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/screenshot.png\">\n</div>\n\n> Analyze your Claude Code token usage and costs from local JSONL files — incredibly fast and informative!\n\n## ccusage Family\n\n### 📊 [ccusage](https://www.npmjs.com/package/ccusage) - Claude Code Usage Analyzer\n\nThe main CLI tool for analyzing Claude Code usage from local JSONL files. Track daily, monthly, and session-based usage with beautiful tables.\n\n### 🤖 [@ccusage/codex](https://www.npmjs.com/package/@ccusage/codex) - OpenAI Codex Usage Analyzer\n\nCompanion 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.\n\n### 🚀 [@ccusage/opencode](https://www.npmjs.com/package/@ccusage/opencode) - OpenCode Usage Analyzer\n\nCompanion 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.\n\n### 🥧 [@ccusage/pi](https://www.npmjs.com/package/@ccusage/pi) - Pi-agent Usage Analyzer\n\nCompanion 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.\n\n### ⚡ [@ccusage/amp](https://www.npmjs.com/package/@ccusage/amp) - Amp Usage Analyzer\n\nCompanion 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.\n\n### 🔌 [@ccusage/mcp](https://www.npmjs.com/package/@ccusage/mcp) - MCP Server Integration\n\nModel 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.\n\n## Installation\n\n### Quick Start (Recommended)\n\nThanks 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:\n\n```bash\n# Recommended - always include @latest to ensure you get the newest version\nnpx ccusage@latest\nbunx ccusage\n\n# Alternative package runners\npnpm dlx ccusage\npnpx ccusage\n\n# Using deno (with security flags)\ndeno run -E -R=$HOME/.claude/projects/ -S=homedir -N='raw.githubusercontent.com:443' npm:ccusage@latest\n```\n\n> 💡 **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.\n\n### Related Tools\n\n```bash\nnpx @ccusage/codex@latest       # OpenAI Codex usage tracking\nnpx @ccusage/opencode@latest    # OpenCode usage tracking\nnpx @ccusage/pi@latest          # Pi-agent usage tracking\nnpx @ccusage/amp@latest         # Amp usage tracking\nnpx @ccusage/mcp@latest         # MCP Server\n```\n\n## Usage\n\n```bash\n# Basic usage\nnpx ccusage          # Show daily report (default)\nnpx ccusage daily    # Daily token usage and costs\nnpx ccusage monthly  # Monthly aggregated report\nnpx ccusage session  # Usage by conversation session\nnpx ccusage blocks   # 5-hour billing windows\nnpx ccusage statusline  # Compact status line for hooks (Beta)\n\n# Filters and options\nnpx ccusage daily --since 20250525 --until 20250530\nnpx ccusage daily --json  # JSON output\nnpx ccusage daily --breakdown  # Per-model cost breakdown\nnpx ccusage daily --timezone UTC  # Use UTC timezone\nnpx ccusage daily --locale ja-JP  # Use Japanese locale for date/time formatting\n\n# Project analysis\nnpx ccusage daily --instances  # Group by project/instance\nnpx ccusage daily --project myproject  # Filter to specific project\nnpx ccusage daily --instances --project myproject --json  # Combined usage\n\n# Compact mode for screenshots/sharing\nnpx ccusage --compact  # Force compact table mode\nnpx ccusage monthly --compact  # Compact monthly report\n```\n\n## Features\n\n- 📊 **Daily Report**: View token usage and costs aggregated by date\n- 📅 **Monthly Report**: View token usage and costs aggregated by month\n- 💬 **Session Report**: View usage grouped by conversation sessions\n- ⏰ **5-Hour Blocks Report**: Track usage within Claude's billing windows with active block monitoring\n- 🚀 **Statusline Integration**: Compact usage display for Claude Code status bar hooks (Beta)\n- 🤖 **Model Tracking**: See which Claude models you're using (Opus, Sonnet, etc.)\n- 📊 **Model Breakdown**: View per-model cost breakdown with `--breakdown` flag\n- 📅 **Date Filtering**: Filter reports by date range using `--since` and `--until`\n- 📁 **Custom Path**: Support for custom Claude data directory locations\n- 🎨 **Beautiful Output**: Colorful table-formatted display with automatic responsive layout\n- 📱 **Smart Tables**: Automatic compact mode for narrow terminals (< 100 characters) with essential columns\n- 📸 **Compact Mode**: Use `--compact` flag to force compact table layout, perfect for screenshots and sharing\n- 📋 **Enhanced Model Display**: Model names shown as bulleted lists for better readability\n- 📄 **JSON Output**: Export data in structured JSON format with `--json`\n- 💰 **Cost Tracking**: Shows costs in USD for each day/month/session\n- 🔄 **Cache Token Support**: Tracks and displays cache creation and cache read tokens separately\n- 🌐 **Offline Mode**: Use pre-cached pricing data without network connectivity with `--offline` (Claude models only)\n- 🔌 **MCP Integration**: Built-in Model Context Protocol server for integration with other tools\n- 🏗️ **Multi-Instance Support**: Group usage by project with `--instances` flag and filter by specific projects\n- 🌍 **Timezone Support**: Configure timezone for date grouping with `--timezone` option\n- 🌐 **Locale Support**: Customize date/time formatting with `--locale` option (e.g., en-US, ja-JP, de-DE)\n- ⚙️ **Configuration Files**: Set defaults with JSON configuration files, complete with IDE autocomplete and validation\n- 🚀 **Ultra-Small Bundle**: Unlike other CLI tools, we pay extreme attention to bundle size - incredibly small even without minification!\n\n## Documentation\n\nFull documentation is available at **[ccusage.com](https://ccusage.com/)**\n\n## Development Setup\n\n### Using Nix (Recommended for Contributors)\n\nFor contributors and developers working on ccusage, we provide a Nix flake-based development environment:\n\n```bash\n# Clone the repository\ngit clone https://github.com/ryoppippi/ccusage.git\ncd ccusage\n\n# Allow direnv (automatically loads Nix environment)\ndirenv allow\n\n# Or manually enter the development shell\nnix develop\n```\n\nThis 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.\n\n## Sponsors\n\n### Featured Sponsor\n\nCheck out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)\n\n<p align=\"center\">\n    <a href=\"https://www.youtube.com/watch?v=Ak6qpQ5qdgk\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/ccusage_thumbnail.png\" alt=\"ccusage: The Claude Code cost scorecard that went viral\" width=\"600\">\n    </a>\n</p>\n\n<p align=\"center\">\n    <a href=\"https://github.com/sponsors/ryoppippi\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/sponsors@main/sponsors.svg\">\n    </a>\n</p>\n\n## Star History\n\n<a href=\"https://www.star-history.com/#ryoppippi/ccusage&Date\">\n    <picture>\n        <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=ryoppippi/ccusage&type=Date&theme=dark\" />\n        <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=ryoppippi/ccusage&type=Date\" />\n        <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=ryoppippi/ccusage&type=Date\" />\n    </picture>\n</a>\n\n## License\n\n[MIT](LICENSE) © [@ryoppippi](https://github.com/ryoppippi)\n"
  },
  {
    "path": "apps/ccusage/config-schema.json",
    "content": "{\n\t\"$ref\": \"#/definitions/ccusage-config\",\n\t\"definitions\": {\n\t\t\"ccusage-config\": {\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": {\n\t\t\t\t\"$schema\": {\n\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\"description\": \"JSON Schema URL for validation and autocomplete\",\n\t\t\t\t\t\"markdownDescription\": \"JSON Schema URL for validation and autocomplete\"\n\t\t\t\t},\n\t\t\t\t\"defaults\": {\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\"since\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"description\": \"Filter from date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Filter from date (YYYYMMDD format)\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"until\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"description\": \"Filter until date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Filter until date (YYYYMMDD format)\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"json\": {\n\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\"description\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"enum\": [\"auto\", \"calculate\", \"display\"],\n\t\t\t\t\t\t\t\"description\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\"default\": \"auto\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"debug\": {\n\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\"description\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"debugSamples\": {\n\t\t\t\t\t\t\t\"type\": \"number\",\n\t\t\t\t\t\t\t\"description\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\"default\": 5\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"order\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"enum\": [\"desc\", \"asc\"],\n\t\t\t\t\t\t\t\"description\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\"default\": \"asc\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"breakdown\": {\n\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\"description\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"offline\": {\n\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\"description\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"color\": {\n\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\"description\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"noColor\": {\n\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\"description\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"timezone\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"description\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"locale\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"description\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\"default\": \"en-CA\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"jq\": {\n\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\"description\": \"Process JSON output with jq command (requires jq binary, implies --json)\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Process JSON output with jq command (requires jq binary, implies --json)\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"compact\": {\n\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\"description\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\"markdownDescription\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"additionalProperties\": false,\n\t\t\t\t\t\"description\": \"Default values for all commands\",\n\t\t\t\t\t\"markdownDescription\": \"Default values for all commands\"\n\t\t\t\t},\n\t\t\t\t\"commands\": {\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\"daily\": {\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\t\"since\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter from date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter from date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"until\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter until date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter until date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"json\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"auto\", \"calculate\", \"display\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"auto\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debug\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debugSamples\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"number\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"default\": 5\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"order\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"desc\", \"asc\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"asc\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"breakdown\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"offline\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"color\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"noColor\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"timezone\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"locale\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"en-CA\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"jq\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Process JSON output with jq command (requires jq binary, implies --json)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Process JSON output with jq command (requires jq binary, implies --json)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"compact\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"instances\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show usage breakdown by project/instance\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show usage breakdown by project/instance\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"project\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter to specific project name\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter to specific project name\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"projectAliases\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Comma-separated project aliases (e.g., 'ccusage=Usage Tracker,myproject=My Project')\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Comma-separated project aliases (e.g., 'ccusage=Usage Tracker,myproject=My Project')\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"additionalProperties\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"monthly\": {\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\t\"since\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter from date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter from date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"until\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter until date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter until date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"json\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"auto\", \"calculate\", \"display\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"auto\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debug\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debugSamples\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"number\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"default\": 5\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"order\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"desc\", \"asc\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"asc\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"breakdown\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"offline\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"color\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"noColor\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"timezone\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"locale\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"en-CA\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"jq\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Process JSON output with jq command (requires jq binary, implies --json)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Process JSON output with jq command (requires jq binary, implies --json)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"compact\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"additionalProperties\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"weekly\": {\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\t\"since\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter from date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter from date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"until\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter until date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter until date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"json\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"auto\", \"calculate\", \"display\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"auto\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debug\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debugSamples\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"number\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"default\": 5\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"order\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"desc\", \"asc\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"asc\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"breakdown\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"offline\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"color\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"noColor\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"timezone\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"locale\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"en-CA\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"jq\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Process JSON output with jq command (requires jq binary, implies --json)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Process JSON output with jq command (requires jq binary, implies --json)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"compact\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"startOfWeek\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\n\t\t\t\t\t\t\t\t\t\t\"sunday\",\n\t\t\t\t\t\t\t\t\t\t\"monday\",\n\t\t\t\t\t\t\t\t\t\t\"tuesday\",\n\t\t\t\t\t\t\t\t\t\t\"wednesday\",\n\t\t\t\t\t\t\t\t\t\t\"thursday\",\n\t\t\t\t\t\t\t\t\t\t\"friday\",\n\t\t\t\t\t\t\t\t\t\t\"saturday\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"description\": \"Day to start the week on\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Day to start the week on\",\n\t\t\t\t\t\t\t\t\t\"default\": \"sunday\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"additionalProperties\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"session\": {\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\t\"since\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter from date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter from date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"until\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter until date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter until date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"json\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"auto\", \"calculate\", \"display\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"auto\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debug\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debugSamples\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"number\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"default\": 5\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"breakdown\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"offline\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"color\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"noColor\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"timezone\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"locale\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"en-CA\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"jq\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Process JSON output with jq command (requires jq binary, implies --json)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Process JSON output with jq command (requires jq binary, implies --json)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"compact\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"id\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Load usage data for a specific session ID\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Load usage data for a specific session ID\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"additionalProperties\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"blocks\": {\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\t\"since\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter from date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter from date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"until\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Filter until date (YYYYMMDD format)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Filter until date (YYYYMMDD format)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"json\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Output in JSON format\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"mode\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"auto\", \"calculate\", \"display\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"auto\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debug\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debugSamples\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"number\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Number of sample discrepancies to show in debug output (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"default\": 5\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"order\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"desc\", \"asc\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Sort order: desc (newest first) or asc (oldest first)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"asc\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"breakdown\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show per-model cost breakdown\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"offline\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"color\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"noColor\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Disable colored output (default: auto). NO_COLOR=1 has the same effect.\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"timezone\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"locale\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"en-CA\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"jq\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Process JSON output with jq command (requires jq binary, implies --json)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Process JSON output with jq command (requires jq binary, implies --json)\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"compact\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Force compact mode for narrow displays (better for screenshots)\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"active\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show only active block with projections\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show only active block with projections\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"recent\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show blocks from last 3 days (including active)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show blocks from last 3 days (including active)\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"tokenLimit\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Token limit for quota warnings (e.g., 500000 or \\\"max\\\")\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Token limit for quota warnings (e.g., 500000 or \\\"max\\\")\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"sessionLength\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"number\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Session block duration in hours (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Session block duration in hours (default: 5)\",\n\t\t\t\t\t\t\t\t\t\"default\": 5\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"additionalProperties\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"statusline\": {\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": {\n\t\t\t\t\t\t\t\t\"offline\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Use cached pricing data for Claude models instead of fetching from API\",\n\t\t\t\t\t\t\t\t\t\"default\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"visualBurnRate\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"off\", \"emoji\", \"text\", \"emoji-text\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Controls the visualization of the burn rate status\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Controls the visualization of the burn rate status\",\n\t\t\t\t\t\t\t\t\t\"default\": \"off\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"costSource\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"enum\": [\"auto\", \"ccusage\", \"cc\", \"both\"],\n\t\t\t\t\t\t\t\t\t\"description\": \"Session cost source: auto (prefer CC then ccusage), ccusage (always calculate), cc (always use Claude Code cost), both (show both costs)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Session cost source: auto (prefer CC then ccusage), ccusage (always calculate), cc (always use Claude Code cost), both (show both costs)\",\n\t\t\t\t\t\t\t\t\t\"default\": \"auto\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"cache\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Enable cache for status line output (default: true)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Enable cache for status line output (default: true)\",\n\t\t\t\t\t\t\t\t\t\"default\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"refreshInterval\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"number\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Refresh interval in seconds for cache expiry (default: 1)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Refresh interval in seconds for cache expiry (default: 1)\",\n\t\t\t\t\t\t\t\t\t\"default\": 1\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"contextLowThreshold\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Context usage percentage below which status is shown in green (0-100)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Context usage percentage below which status is shown in green (0-100)\",\n\t\t\t\t\t\t\t\t\t\"default\": 50\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"contextMediumThreshold\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Context usage percentage below which status is shown in yellow (0-100)\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Context usage percentage below which status is shown in yellow (0-100)\",\n\t\t\t\t\t\t\t\t\t\"default\": 80\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"debug\": {\n\t\t\t\t\t\t\t\t\t\"type\": \"boolean\",\n\t\t\t\t\t\t\t\t\t\"description\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"markdownDescription\": \"Show pricing mismatch information for debugging\",\n\t\t\t\t\t\t\t\t\t\"default\": false\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"additionalProperties\": false\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"additionalProperties\": false,\n\t\t\t\t\t\"description\": \"Command-specific configuration overrides\",\n\t\t\t\t\t\"markdownDescription\": \"Command-specific configuration overrides\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"additionalProperties\": false\n\t\t}\n\t},\n\t\"$schema\": \"https://json-schema.org/draft-07/schema#\",\n\t\"title\": \"ccusage Configuration\",\n\t\"description\": \"Configuration file for ccusage - Claude Code usage analysis tool\",\n\t\"examples\": [\n\t\t{\n\t\t\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\t\t\"defaults\": {\n\t\t\t\t\"json\": false,\n\t\t\t\t\"mode\": \"auto\",\n\t\t\t\t\"timezone\": \"Asia/Tokyo\",\n\t\t\t\t\"locale\": \"ja-JP\"\n\t\t\t},\n\t\t\t\"commands\": {\n\t\t\t\t\"daily\": {\n\t\t\t\t\t\"instances\": true\n\t\t\t\t},\n\t\t\t\t\"blocks\": {\n\t\t\t\t\t\"tokenLimit\": \"500000\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "apps/ccusage/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\n/** @type {import('eslint').Linter.FlatConfig[]} */\nconst config = ryoppippi(\n\t{\n\t\ttype: 'lib',\n\t\tstylistic: false,\n\t},\n\t{\n\t\trules: {\n\t\t\t'test/no-importing-vitest-globals': 'error',\n\t\t},\n\t},\n);\n\nexport default config;\n"
  },
  {
    "path": "apps/ccusage/package.json",
    "content": "{\n\t\"name\": \"ccusage\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"description\": \"Usage analysis tool for Claude Code\",\n\t\"author\": \"ryoppippi\",\n\t\"license\": \"MIT\",\n\t\"funding\": \"https://github.com/ryoppippi/ccusage?sponsor=1\",\n\t\"homepage\": \"https://github.com/ryoppippi/ccusage#readme\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/ryoppippi/ccusage.git\",\n\t\t\"directory\": \"apps/ccusage\"\n\t},\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/ryoppippi/ccusage/issues\"\n\t},\n\t\"exports\": {\n\t\t\".\": \"./src/index.ts\",\n\t\t\"./calculate-cost\": \"./src/calculate-cost.ts\",\n\t\t\"./data-loader\": \"./src/data-loader.ts\",\n\t\t\"./debug\": \"./src/debug.ts\",\n\t\t\"./logger\": \"./src/logger.ts\",\n\t\t\"./package.json\": \"./package.json\"\n\t},\n\t\"main\": \"./dist/index.js\",\n\t\"module\": \"./dist/index.js\",\n\t\"types\": \"./dist/index.d.ts\",\n\t\"bin\": {\n\t\t\"ccusage\": \"./src/index.ts\"\n\t},\n\t\"files\": [\n\t\t\"config-schema.json\",\n\t\t\"dist\"\n\t],\n\t\"publishConfig\": {\n\t\t\"bin\": {\n\t\t\t\"ccusage\": \"./dist/index.js\"\n\t\t},\n\t\t\"exports\": {\n\t\t\t\".\": \"./dist/index.js\",\n\t\t\t\"./calculate-cost\": \"./dist/calculate-cost.js\",\n\t\t\t\"./data-loader\": \"./dist/data-loader.js\",\n\t\t\t\"./debug\": \"./dist/debug.js\",\n\t\t\t\"./logger\": \"./dist/logger.js\",\n\t\t\t\"./package.json\": \"./package.json\"\n\t\t}\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=20.19.4\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"pnpm run generate:schema && tsdown\",\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"generate:schema\": \"bun scripts/generate-json-schema.ts\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"prepack\": \"pnpm run build && clean-pkg-json\",\n\t\t\"prepare\": \"pnpm run generate:schema || true\",\n\t\t\"prerelease\": \"pnpm run lint && pnpm run typecheck && pnpm run build\",\n\t\t\"start\": \"bun ./src/index.ts\",\n\t\t\"test\": \"TZ=UTC vitest\",\n\t\t\"test:statusline\": \"cat test/statusline-test.json | node ./src/index.ts statusline\",\n\t\t\"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\",\n\t\t\"test:statusline:opus4\": \"cat test/statusline-test-opus4.json | node ./src/index.ts statusline --offline\",\n\t\t\"test:statusline:sonnet4\": \"cat test/statusline-test-sonnet4.json | node ./src/index.ts statusline --offline\",\n\t\t\"test:statusline:sonnet41\": \"cat test/statusline-test-sonnet41.json | node ./src/index.ts statusline --offline\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@antfu/utils\": \"catalog:runtime\",\n\t\t\"@ccusage/internal\": \"workspace:*\",\n\t\t\"@ccusage/terminal\": \"workspace:*\",\n\t\t\"@oxc-project/runtime\": \"catalog:build\",\n\t\t\"@praha/byethrow\": \"catalog:runtime\",\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"@ryoppippi/limo\": \"catalog:runtime\",\n\t\t\"@std/async\": \"catalog:runtime\",\n\t\t\"@types/bun\": \"catalog:types\",\n\t\t\"@typescript/native-preview\": \"catalog:types\",\n\t\t\"ansi-escapes\": \"catalog:runtime\",\n\t\t\"bumpp\": \"catalog:release\",\n\t\t\"clean-pkg-json\": \"catalog:release\",\n\t\t\"es-toolkit\": \"catalog:runtime\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"eslint-plugin-format\": \"catalog:lint\",\n\t\t\"fast-sort\": \"catalog:runtime\",\n\t\t\"fs-fixture\": \"catalog:testing\",\n\t\t\"get-stdin\": \"catalog:runtime\",\n\t\t\"gunshi\": \"catalog:runtime\",\n\t\t\"nano-spawn\": \"catalog:runtime\",\n\t\t\"p-limit\": \"catalog:runtime\",\n\t\t\"path-type\": \"catalog:runtime\",\n\t\t\"picocolors\": \"catalog:runtime\",\n\t\t\"pretty-ms\": \"catalog:runtime\",\n\t\t\"publint\": \"catalog:lint\",\n\t\t\"string-width\": \"catalog:runtime\",\n\t\t\"tinyglobby\": \"catalog:runtime\",\n\t\t\"tsdown\": \"catalog:build\",\n\t\t\"type-fest\": \"catalog:runtime\",\n\t\t\"unplugin-macros\": \"catalog:build\",\n\t\t\"unplugin-unused\": \"catalog:build\",\n\t\t\"valibot\": \"catalog:runtime\",\n\t\t\"vitest\": \"catalog:testing\",\n\t\t\"xdg-basedir\": \"catalog:runtime\"\n\t}\n}\n"
  },
  {
    "path": "apps/ccusage/scripts/generate-json-schema.ts",
    "content": "#!/usr/bin/env bun\n\n/**\n * @fileoverview Generate JSON Schema from args-tokens configuration schema\n *\n * This script generates a JSON Schema file from the args-tokens configuration schema\n * for ccusage configuration files. The generated schema enables:\n * - IDE autocomplete and validation\n * - Documentation of available options\n * - Schema validation for configuration files\n */\n\nimport process from 'node:process';\nimport { Result } from '@praha/byethrow';\nimport { $ } from 'bun';\nimport { sharedArgs } from '../src/_shared-args.ts';\n// Import command definitions to access their args\nimport { subCommandUnion } from '../src/commands/index.ts';\n\nimport { logger } from '../src/logger.ts';\n\n/**\n * The filename for the generated JSON Schema file.\n * Used for both root directory and docs/public directory output.\n */\nconst SCHEMA_FILENAME = 'config-schema.json';\n\n/**\n * Keys to exclude from the generated JSON Schema.\n * These are CLI-only options that shouldn't appear in configuration files.\n */\nconst EXCLUDE_KEYS = ['config'];\n\n/**\n * Command-specific keys to exclude from the generated JSON Schema.\n * These are CLI-only options that shouldn't appear in configuration files.\n */\nconst COMMAND_EXCLUDE_KEYS: Record<string, string[]> = {\n\tblocks: ['live', 'refreshInterval'],\n};\n\n/**\n * Convert args-tokens schema to JSON Schema format\n */\nfunction tokensSchemaToJsonSchema(schema: Record<string, any>): Record<string, any> {\n\tconst properties: Record<string, any> = {};\n\n\tfor (const [key, arg] of Object.entries(schema)) {\n\t\t// eslint-disable-next-line ts/no-unsafe-assignment\n\t\tconst argTyped = arg;\n\t\tconst property: Record<string, any> = {};\n\n\t\t// Handle type conversion\n\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\tswitch (argTyped.type) {\n\t\t\tcase 'boolean':\n\t\t\t\tproperty.type = 'boolean';\n\t\t\t\tbreak;\n\t\t\tcase 'number':\n\t\t\t\tproperty.type = 'number';\n\t\t\t\tbreak;\n\t\t\tcase 'string':\n\t\t\tcase 'custom':\n\t\t\t\tproperty.type = 'string';\n\t\t\t\tbreak;\n\t\t\tcase 'enum':\n\t\t\t\tproperty.type = 'string';\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\t\t\tif (argTyped.choices != null && Array.isArray(argTyped.choices)) {\n\t\t\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access\n\t\t\t\t\tproperty.enum = argTyped.choices;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tproperty.type = 'string';\n\t\t}\n\n\t\t// Add description\n\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\tif (argTyped.description != null) {\n\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access\n\t\t\tproperty.description = argTyped.description;\n\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access\n\t\t\tproperty.markdownDescription = argTyped.description;\n\t\t}\n\n\t\t// Add default value\n\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\tif ('default' in argTyped && argTyped.default !== undefined) {\n\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access\n\t\t\tproperty.default = argTyped.default;\n\t\t}\n\n\t\tproperties[key] = property;\n\t}\n\n\treturn {\n\t\ttype: 'object',\n\t\tproperties,\n\t\tadditionalProperties: false,\n\t};\n}\n\n/**\n * Create the complete configuration schema from all command definitions\n */\nfunction createConfigSchemaJson() {\n\t// Create schema for default/shared arguments (excluding CLI-only options)\n\tconst defaultsSchema = Object.fromEntries(\n\t\tObject.entries(sharedArgs).filter(([key]) => !EXCLUDE_KEYS.includes(key)),\n\t);\n\n\t// Create schemas for each command's specific arguments (excluding CLI-only options)\n\tconst commandSchemas: Record<string, any> = {};\n\tfor (const [commandName, command] of subCommandUnion) {\n\t\tconst commandExcludes = COMMAND_EXCLUDE_KEYS[commandName] ?? [];\n\t\tcommandSchemas[commandName] = Object.fromEntries(\n\t\t\tObject.entries(command.args as Record<string, any>).filter(\n\t\t\t\t([key]) => !EXCLUDE_KEYS.includes(key) && !commandExcludes.includes(key),\n\t\t\t),\n\t\t);\n\t}\n\n\t// Convert to JSON Schema format\n\n\tconst defaultsJsonSchema = tokensSchemaToJsonSchema(defaultsSchema);\n\tconst commandsJsonSchema = {\n\t\ttype: 'object',\n\t\tproperties: Object.fromEntries(\n\t\t\tObject.entries(commandSchemas).map(([name, schema]) => [\n\t\t\t\tname,\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-argument\n\t\t\t\ttokensSchemaToJsonSchema(schema),\n\t\t\t]),\n\t\t),\n\t\tadditionalProperties: false,\n\t\tdescription: 'Command-specific configuration overrides',\n\t\tmarkdownDescription: 'Command-specific configuration overrides',\n\t};\n\n\t// Main configuration schema\n\treturn {\n\t\t$ref: '#/definitions/ccusage-config',\n\t\tdefinitions: {\n\t\t\t'ccusage-config': {\n\t\t\t\ttype: 'object',\n\t\t\t\tproperties: {\n\t\t\t\t\t$schema: {\n\t\t\t\t\t\ttype: 'string',\n\t\t\t\t\t\tdescription: 'JSON Schema URL for validation and autocomplete',\n\t\t\t\t\t\tmarkdownDescription: 'JSON Schema URL for validation and autocomplete',\n\t\t\t\t\t},\n\t\t\t\t\tdefaults: {\n\t\t\t\t\t\t...defaultsJsonSchema,\n\t\t\t\t\t\tdescription: 'Default values for all commands',\n\t\t\t\t\t\tmarkdownDescription: 'Default values for all commands',\n\t\t\t\t\t},\n\t\t\t\t\tcommands: commandsJsonSchema,\n\t\t\t\t},\n\t\t\t\tadditionalProperties: false,\n\t\t\t},\n\t\t},\n\t\t$schema: 'https://json-schema.org/draft-07/schema#',\n\t\ttitle: 'ccusage Configuration',\n\t\tdescription: 'Configuration file for ccusage - Claude Code usage analysis tool',\n\t\texamples: [\n\t\t\t{\n\t\t\t\t$schema: 'https://ccusage.com/config-schema.json',\n\t\t\t\tdefaults: {\n\t\t\t\t\tjson: false,\n\t\t\t\t\tmode: 'auto',\n\t\t\t\t\ttimezone: 'Asia/Tokyo',\n\t\t\t\t\tlocale: 'ja-JP',\n\t\t\t\t},\n\t\t\t\tcommands: {\n\t\t\t\t\tdaily: {\n\t\t\t\t\t\tinstances: true,\n\t\t\t\t\t},\n\t\t\t\t\tblocks: {\n\t\t\t\t\t\ttokenLimit: '500000',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t};\n}\n\n/**\n * Generate JSON Schema and write to files\n */\nasync function runFormat(files: string[]) {\n\treturn Result.try({\n\t\ttry: $`pnpm exec oxfmt ${files}`,\n\t\tcatch: (error) => error,\n\t});\n}\n\nasync function writeFile(path: string, content: string) {\n\tconst attempt = Result.try({\n\t\ttry: async () => Bun.write(path, content),\n\t\tcatch: (error) => error,\n\t});\n\treturn attempt();\n}\n\nasync function readFile(path: string): Promise<Result.Result<string, any>> {\n\treturn Result.try({\n\t\ttry: async () => {\n\t\t\tconst file = Bun.file(path);\n\t\t\treturn file.text();\n\t\t},\n\t\tcatch: (error) => error,\n\t})();\n}\n\nasync function copySchemaToDocsPublic() {\n\tconst gitRoot = await $`git rev-parse --show-toplevel`.text().then((text) => text.trim());\n\tawait $`cp ${SCHEMA_FILENAME} ${gitRoot}/docs/public/${SCHEMA_FILENAME}`;\n}\n\nasync function generateJsonSchema() {\n\tlogger.info('Generating JSON Schema from args-tokens configuration schema...');\n\n\t// Create the JSON Schema\n\tconst schemaObject = Result.pipe(\n\t\tResult.try({\n\t\t\ttry: () => createConfigSchemaJson(),\n\t\t\tcatch: (error) => error,\n\t\t})(),\n\t\tResult.inspectError((error) => {\n\t\t\tlogger.error('Error creating JSON Schema:', error);\n\t\t\tprocess.exit(1);\n\t\t}),\n\t\tResult.unwrap(),\n\t);\n\n\t// Check if existing root schema is identical to avoid unnecessary writes\n\tconst existingRootSchema = await Result.pipe(\n\t\treadFile(SCHEMA_FILENAME),\n\t\tResult.map((content) => JSON.parse(content) as unknown),\n\t\tResult.unwrap(''),\n\t);\n\n\tconst isSchemaChanged = !Bun.deepEquals(existingRootSchema, schemaObject, true);\n\n\tif (!isSchemaChanged) {\n\t\tlogger.info('✓ Root schema is up to date, skipping generation');\n\n\t\t// Always copy to docs/public since it's gitignored\n\t\tawait copySchemaToDocsPublic();\n\n\t\tlogger.info('JSON Schema sync completed successfully!');\n\t\treturn;\n\t}\n\n\tconst schemaJson = JSON.stringify(schemaObject, null, '\\t');\n\n\tawait Result.pipe(\n\t\tResult.try({\n\t\t\ttry: writeFile(SCHEMA_FILENAME, schemaJson),\n\t\t\tsafe: true,\n\t\t}),\n\t\tResult.inspectError((error) => {\n\t\t\tlogger.error(`Failed to write ${SCHEMA_FILENAME}:`, error);\n\t\t\tprocess.exit(1);\n\t\t}),\n\t\tResult.inspect(() => logger.info(`✓ Generated ${SCHEMA_FILENAME}`)),\n\t);\n\n\t// Copy to docs/public using Bun shell\n\tawait copySchemaToDocsPublic();\n\n\t// Run format on the root schema file that was changed\n\tawait Result.pipe(\n\t\tResult.try({\n\t\t\ttry: runFormat([SCHEMA_FILENAME]),\n\t\t\tsafe: true,\n\t\t}),\n\t\tResult.inspectError((error) => {\n\t\t\tlogger.error('Failed to format generated files:', error);\n\t\t\tprocess.exit(1);\n\t\t}),\n\t\tResult.inspect(() => logger.info('✓ Formatted generated files')),\n\t);\n\n\tlogger.info('JSON Schema generation completed successfully!');\n}\n\n// Run the generator\nif (import.meta.main) {\n\tawait generateJsonSchema();\n}\nif (import.meta.vitest != null) {\n\tdescribe('tokensSchemaToJsonSchema', () => {\n\t\tit('should convert boolean args to JSON Schema', () => {\n\t\t\tconst schema = {\n\t\t\t\tdebug: {\n\t\t\t\t\ttype: 'boolean',\n\t\t\t\t\tdescription: 'Enable debug mode',\n\t\t\t\t\tdefault: false,\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst jsonSchema = tokensSchemaToJsonSchema(schema);\n\t\t\texpect((jsonSchema.properties as Record<string, any>).debug).toEqual({\n\t\t\t\ttype: 'boolean',\n\t\t\t\tdescription: 'Enable debug mode',\n\t\t\t\tmarkdownDescription: 'Enable debug mode',\n\t\t\t\tdefault: false,\n\t\t\t});\n\t\t});\n\n\t\tit('should convert enum args to JSON Schema', () => {\n\t\t\tconst schema = {\n\t\t\t\tmode: {\n\t\t\t\t\ttype: 'enum',\n\t\t\t\t\tdescription: 'Mode selection',\n\t\t\t\t\tchoices: ['auto', 'manual'],\n\t\t\t\t\tdefault: 'auto',\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst jsonSchema = tokensSchemaToJsonSchema(schema);\n\t\t\texpect((jsonSchema.properties as Record<string, any>).mode).toEqual({\n\t\t\t\ttype: 'string',\n\t\t\t\tenum: ['auto', 'manual'],\n\t\t\t\tdescription: 'Mode selection',\n\t\t\t\tmarkdownDescription: 'Mode selection',\n\t\t\t\tdefault: 'auto',\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('createConfigSchemaJson', () => {\n\t\tit('should generate valid JSON Schema', () => {\n\t\t\tconst jsonSchema = createConfigSchemaJson();\n\n\t\t\texpect(jsonSchema).toBeDefined();\n\t\t\texpect(jsonSchema.$ref).toBe('#/definitions/ccusage-config');\n\t\t\texpect(jsonSchema.definitions).toBeDefined();\n\t\t\texpect(jsonSchema.definitions['ccusage-config']).toBeDefined();\n\t\t\texpect(jsonSchema.definitions['ccusage-config'].type).toBe('object');\n\t\t});\n\n\t\tit('should include all expected properties', () => {\n\t\t\tconst jsonSchema = createConfigSchemaJson();\n\t\t\tconst mainSchema = jsonSchema.definitions['ccusage-config'];\n\n\t\t\texpect(mainSchema.properties).toHaveProperty('$schema');\n\t\t\texpect(mainSchema.properties).toHaveProperty('defaults');\n\t\t\texpect(mainSchema.properties).toHaveProperty('commands');\n\t\t});\n\n\t\tit('should include all command schemas', () => {\n\t\t\tconst jsonSchema = createConfigSchemaJson();\n\t\t\tconst commandsSchema = jsonSchema.definitions['ccusage-config'].properties.commands;\n\n\t\t\texpect(commandsSchema.properties).toHaveProperty('daily');\n\t\t\texpect(commandsSchema.properties).toHaveProperty('monthly');\n\t\t\texpect(commandsSchema.properties).toHaveProperty('weekly');\n\t\t\texpect(commandsSchema.properties).toHaveProperty('session');\n\t\t\texpect(commandsSchema.properties).toHaveProperty('blocks');\n\t\t\texpect(commandsSchema.properties).toHaveProperty('mcp');\n\t\t\texpect(commandsSchema.properties).toHaveProperty('statusline');\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_config-loader-tokens.ts",
    "content": "import { existsSync, readFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport process from 'node:process';\nimport { toArray } from '@antfu/utils';\nimport { Result } from '@praha/byethrow';\nimport { createFixture } from 'fs-fixture';\nimport { CONFIG_FILE_NAME } from './_consts.ts';\nimport { getClaudePaths } from './data-loader.ts';\nimport { logger } from './logger.ts';\n\n/**\n * Minimal command context interface for config merging\n * Contains only the properties we need from Gunshi's CommandContext\n */\nexport type ConfigMergeContext<T extends Record<string, unknown>> = {\n\t/** Command values from CLI */\n\tvalues: T;\n\t/** Command tokens from CLI */\n\ttokens: unknown[];\n\t/** Command name being executed */\n\tname?: string;\n};\n\n/**\n * Extract explicitly provided arguments from gunshi tokens\n * @param tokens - Command tokens from ctx.tokens\n * @returns Object with keys as argument names and values as boolean (true if explicitly provided)\n */\nfunction extractExplicitArgs(tokens: unknown[]): Record<string, boolean> {\n\tconst explicit: Record<string, boolean> = {};\n\n\tfor (const token of tokens) {\n\t\tif (typeof token === 'object' && token !== null) {\n\t\t\tconst t = token as { kind?: string; name?: string };\n\t\t\tif (t.kind === 'option' && typeof t.name === 'string') {\n\t\t\t\texplicit[t.name] = true;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn explicit;\n}\n\n// Type for configuration data (simple structure without Valibot)\nexport type ConfigData = {\n\t$schema?: string;\n\tdefaults?: Record<string, any>;\n\tcommands?: Record<string, Record<string, any>>;\n\tsource?: string;\n};\n\n/**\n * Get configuration file search paths in priority order (highest to lowest)\n * 1. Local .ccusage/ccusage.json\n * 2. User config directories from getClaudePaths() + ccusage.json\n */\nfunction getConfigSearchPaths(): string[] {\n\tconst claudeConfigDirs = [join(process.cwd(), '.ccusage'), ...toArray(getClaudePaths())];\n\treturn claudeConfigDirs.map((dir) => join(dir, CONFIG_FILE_NAME));\n}\n\n/**\n * Basic JSON validation - just check if it can be parsed and has expected structure\n */\nfunction validateConfigJson(data: unknown): data is ConfigData {\n\tif (typeof data !== 'object' || data === null) {\n\t\treturn false;\n\t}\n\n\tconst config = data as Record<string, unknown>;\n\n\t// Optional schema property\n\tif (config.$schema != null && typeof config.$schema !== 'string') {\n\t\treturn false;\n\t}\n\n\t// Optional defaults property\n\tif (\n\t\tconfig.defaults != null &&\n\t\t(typeof config.defaults !== 'object' || config.defaults === null)\n\t) {\n\t\treturn false;\n\t}\n\n\t// Optional commands property\n\tif (\n\t\tconfig.commands != null &&\n\t\t(typeof config.commands !== 'object' || config.commands === null)\n\t) {\n\t\treturn false;\n\t}\n\n\treturn true;\n}\n\n/**\n * Internal function to load and parse a configuration file\n * @param filePath - Path to the configuration file\n * @param debug - Whether to enable debug logging\n * @returns ConfigData if successful, undefined if failed\n */\nfunction loadConfigFile(filePath: string, debug = false): ConfigData | undefined {\n\tif (!existsSync(filePath)) {\n\t\tif (debug) {\n\t\t\tlogger.info(`  • Checking: ${filePath} (not found)`);\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tconst parseConfigFileResult = Result.pipe(\n\t\tResult.try({\n\t\t\ttry: () => {\n\t\t\t\tconst content = readFileSync(filePath, 'utf-8');\n\t\t\t\tconst data = JSON.parse(content) as unknown;\n\t\t\t\tif (!validateConfigJson(data)) {\n\t\t\t\t\tthrow new Error('Invalid configuration structure');\n\t\t\t\t}\n\t\t\t\t// Add source path to the config for debug display\n\t\t\t\tdata.source = filePath;\n\t\t\t\treturn data;\n\t\t\t},\n\t\t\tcatch: (error) => error,\n\t\t})(),\n\t\tResult.inspect(() => {\n\t\t\tlogger.debug(`Parsed configuration file: ${filePath}`);\n\t\t\tif (debug) {\n\t\t\t\tlogger.info(`  • Checking: ${filePath} (found ✓)`);\n\t\t\t}\n\t\t}),\n\t\tResult.inspectError((error) => {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\t\t\tlogger.warn(`Error parsing configuration file at ${filePath}: ${errorMessage}`);\n\t\t\tif (debug) {\n\t\t\t\tlogger.info(`  • Checking: ${filePath} (error: ${errorMessage})`);\n\t\t\t}\n\t\t}),\n\t\tResult.unwrap(undefined),\n\t);\n\n\treturn parseConfigFileResult;\n}\n\n/**\n * Loads configuration from the specified path or auto-discovery\n * @param configPath - Optional path to specific config file\n * @param debug - Whether to enable debug logging\n * @returns Parsed configuration data or undefined if no config found\n */\nexport function loadConfig(configPath?: string, debug = false): ConfigData | undefined {\n\tif (debug) {\n\t\tlogger.info('Debug mode enabled - showing config loading details\\n');\n\t}\n\n\t// If specific config path is provided, use it exclusively\n\tif (configPath != null) {\n\t\tif (debug) {\n\t\t\tlogger.info('Using specified config file:');\n\t\t\tlogger.info(`  • Path: ${configPath}`);\n\t\t}\n\t\tconst config = loadConfigFile(configPath, debug);\n\t\tif (config == null) {\n\t\t\tlogger.warn(`Configuration file not found or invalid: ${configPath}`);\n\t\t} else if (debug) {\n\t\t\tlogger.info('');\n\t\t\tlogger.info(`Loaded config from: ${configPath}`);\n\t\t\tlogger.info(`  • Schema: ${config.$schema ?? 'none'}`);\n\t\t\tlogger.info(\n\t\t\t\t`  • Has defaults: ${config.defaults != null ? 'yes' : 'no'}${config.defaults != null ? ` (${Object.keys(config.defaults).length} options)` : ''}`,\n\t\t\t);\n\t\t\tlogger.info(\n\t\t\t\t`  • Has command configs: ${config.commands != null ? 'yes' : 'no'}${config.commands != null ? ` (${Object.keys(config.commands).join(', ')})` : ''}`,\n\t\t\t);\n\t\t}\n\t\treturn config;\n\t}\n\n\t// Auto-discovery from search paths (highest priority first)\n\tif (debug) {\n\t\tlogger.info('Searching for config files:');\n\t}\n\n\tfor (const searchPath of getConfigSearchPaths()) {\n\t\tconst config = loadConfigFile(searchPath, debug);\n\t\tif (config != null) {\n\t\t\tif (debug) {\n\t\t\t\tlogger.info('');\n\t\t\t\tlogger.info(`Loaded config from: ${searchPath}`);\n\t\t\t\tlogger.info(`  • Schema: ${config.$schema ?? 'none'}`);\n\t\t\t\tlogger.info(\n\t\t\t\t\t`  • Has defaults: ${config.defaults != null ? 'yes' : 'no'}${config.defaults != null ? ` (${Object.keys(config.defaults).length} options)` : ''}`,\n\t\t\t\t);\n\t\t\t\tlogger.info(\n\t\t\t\t\t`  • Has command configs: ${config.commands != null ? 'yes' : 'no'}${config.commands != null ? ` (${Object.keys(config.commands).join(', ')})` : ''}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn config;\n\t\t}\n\t\t// Continue searching other paths even if one config is invalid\n\t}\n\n\tlogger.debug('No valid configuration file found');\n\tif (debug) {\n\t\tlogger.info('');\n\t\tlogger.info('No valid configuration file found');\n\t}\n\treturn undefined;\n}\n\n/**\n * Merges configuration with CLI arguments\n * Priority order (highest to lowest):\n * 1. CLI arguments (ctx.values)\n * 2. Command-specific config\n * 3. Default config\n * 4. Gunshi defaults\n *\n * @param ctx - Command context with values, tokens, and name\n * @param config - Loaded configuration data\n * @param debug - Whether to enable debug logging\n * @returns Merged arguments object\n */\nexport function mergeConfigWithArgs<T extends Record<string, unknown>>(\n\tctx: ConfigMergeContext<T>,\n\tconfig?: ConfigData,\n\tdebug = false,\n): T {\n\tif (config == null) {\n\t\tif (debug) {\n\t\t\tlogger.info('');\n\t\t\tlogger.info(\n\t\t\t\t`No config file loaded, using CLI args only for '${ctx.name ?? 'unknown'}' command`,\n\t\t\t);\n\t\t}\n\t\treturn ctx.values;\n\t}\n\n\t// Start with an empty base\n\tconst merged = {} as T;\n\tconst commandName = ctx.name;\n\n\t// Track sources for debug output\n\tconst sources: Record<string, string> = {};\n\n\t// 1. Apply defaults from config (lowest priority)\n\tif (config.defaults != null) {\n\t\tfor (const [key, value] of Object.entries(config.defaults)) {\n\t\t\t(merged as Record<string, unknown>)[key] = value;\n\t\t\tsources[key] = 'defaults';\n\t\t}\n\t}\n\n\t// 2. Apply command-specific config\n\tif (commandName != null && config.commands?.[commandName] != null) {\n\t\tfor (const [key, value] of Object.entries(config.commands[commandName])) {\n\t\t\t(merged as Record<string, unknown>)[key] = value;\n\t\t\tsources[key] = 'command config';\n\t\t}\n\t}\n\n\t// 3. Apply CLI arguments (highest priority)\n\t// Only override with CLI args that are explicitly provided by the user\n\tconst explicit = extractExplicitArgs(ctx.tokens);\n\tfor (const [key, value] of Object.entries(ctx.values)) {\n\t\tif (value != null && explicit[key] === true) {\n\t\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\t\t(merged as any)[key] = value;\n\t\t\tsources[key] = 'CLI';\n\t\t}\n\t}\n\n\tlogger.debug(`Merged config for ${commandName ?? 'unknown'}:`, merged);\n\n\tif (debug) {\n\t\tlogger.info('');\n\t\tlogger.info(`Merging options for '${commandName ?? 'unknown'}' command:`);\n\n\t\t// Group options by source\n\t\tconst bySource: Record<string, string[]> = {\n\t\t\tdefaults: [],\n\t\t\t'command config': [],\n\t\t\tCLI: [],\n\t\t};\n\n\t\tfor (const [key, source] of Object.entries(sources)) {\n\t\t\tif (bySource[source] != null) {\n\t\t\t\tbySource[source].push(`${key}=${JSON.stringify((merged as Record<string, unknown>)[key])}`);\n\t\t\t}\n\t\t}\n\n\t\tif (bySource.defaults!.length > 0) {\n\t\t\tlogger.info(`  • From defaults: ${bySource.defaults!.join(', ')}`);\n\t\t}\n\t\tif (bySource['command config']!.length > 0) {\n\t\t\tlogger.info(`  • From command config: ${bySource['command config']!.join(', ')}`);\n\t\t}\n\t\tif (bySource.CLI!.length > 0) {\n\t\t\tlogger.info(`  • From CLI args: ${bySource.CLI!.join(', ')}`);\n\t\t}\n\n\t\t// Show final result with sources\n\t\tlogger.info('  • Final merged options: {');\n\t\tfor (const [key, value] of Object.entries(merged)) {\n\t\t\tconst source = sources[key] ?? 'unknown';\n\t\t\tlogger.info(`      ${key}: ${JSON.stringify(value)} (from ${source}),`);\n\t\t}\n\t\tlogger.info('    }');\n\t}\n\n\treturn merged;\n}\n\n/**\n * Validates a configuration file without loading it\n * @param configPath - Path to configuration file\n * @returns Validation result\n */\nexport function validateConfigFile(\n\tconfigPath: string,\n): { success: true; data: ConfigData } | { success: false; error: Error } {\n\tif (!existsSync(configPath)) {\n\t\treturn { success: false, error: new Error(`Configuration file does not exist: ${configPath}`) };\n\t}\n\n\tconst parseConfig = Result.try({\n\t\ttry: () => {\n\t\t\tconst content = readFileSync(configPath, 'utf-8');\n\t\t\tconst data = JSON.parse(content) as unknown;\n\t\t\tif (!validateConfigJson(data)) {\n\t\t\t\tthrow new Error('Invalid configuration structure');\n\t\t\t}\n\t\t\treturn data;\n\t\t},\n\t\tcatch: (error) => (error instanceof Error ? error : new Error(String(error))),\n\t});\n\n\tconst result = parseConfig();\n\tif (Result.isSuccess(result)) {\n\t\treturn { success: true, data: result.value };\n\t} else {\n\t\treturn { success: false, error: result.error };\n\t}\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('extractExplicitArgs', () => {\n\t\tit('should extract explicit arguments from tokens', () => {\n\t\t\tconst tokens = [\n\t\t\t\t{ kind: 'option', name: 'json' },\n\t\t\t\t{ kind: 'option', name: 'debug' },\n\t\t\t\t{ kind: 'positional', value: 'daily' }, // Should be ignored\n\t\t\t\t{ kind: 'option', name: 'mode' },\n\t\t\t];\n\n\t\t\tconst result = extractExplicitArgs(tokens);\n\t\t\texpect(result).toEqual({\n\t\t\t\tjson: true,\n\t\t\t\tdebug: true,\n\t\t\t\tmode: true,\n\t\t\t});\n\t\t});\n\n\t\tit('should handle empty tokens array', () => {\n\t\t\tconst result = extractExplicitArgs([]);\n\t\t\texpect(result).toEqual({});\n\t\t});\n\n\t\tit('should handle invalid token structures', () => {\n\t\t\tconst tokens = [\n\t\t\t\tnull,\n\t\t\t\tundefined,\n\t\t\t\t'string',\n\t\t\t\t123,\n\t\t\t\t{ kind: 'option' }, // Missing name\n\t\t\t\t{ name: 'test' }, // Missing kind\n\t\t\t\t{ kind: 'other', name: 'ignored' }, // Wrong kind\n\t\t\t];\n\n\t\t\tconst result = extractExplicitArgs(tokens);\n\t\t\texpect(result).toEqual({});\n\t\t});\n\n\t\tit('should handle mixed valid and invalid tokens', () => {\n\t\t\tconst tokens = [\n\t\t\t\t{ kind: 'option', name: 'valid' },\n\t\t\t\tnull,\n\t\t\t\t{ kind: 'positional', value: 'ignored' },\n\t\t\t\t{ kind: 'option', name: 'alsoValid' },\n\t\t\t];\n\n\t\t\tconst result = extractExplicitArgs(tokens);\n\t\t\texpect(result).toEqual({\n\t\t\t\tvalid: true,\n\t\t\t\talsoValid: true,\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('loadConfig', () => {\n\t\tbeforeEach(() => {\n\t\t\tvi.restoreAllMocks();\n\t\t});\n\n\t\tafterEach(() => {\n\t\t\tvi.restoreAllMocks();\n\t\t});\n\n\t\tit('should load valid configuration from .ccusage/ccusage.json', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.ccusage/ccusage.json': JSON.stringify({\n\t\t\t\t\tdefaults: { json: true },\n\t\t\t\t\tcommands: { daily: { instances: true } },\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\t// Mock process.cwd to return fixture path\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\n\t\t\tconst config = loadConfig();\n\t\t\texpect(config).toBeDefined();\n\t\t\texpect(config?.defaults?.json).toBe(true);\n\t\t\texpect(config?.commands?.daily?.instances).toBe(true);\n\t\t});\n\n\t\tit('should load configuration with specific path', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'custom-config.json': JSON.stringify({\n\t\t\t\t\tdefaults: { debug: true },\n\t\t\t\t\tcommands: { monthly: { breakdown: true } },\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst config = loadConfig(fixture.getPath('custom-config.json'));\n\t\t\texpect(config).toBeDefined();\n\t\t\texpect(config?.defaults?.debug).toBe(true);\n\t\t\texpect(config?.commands?.monthly?.breakdown).toBe(true);\n\t\t});\n\n\t\tit('should return undefined for non-existent config file', () => {\n\t\t\tconst config = loadConfig('/non/existent/path.json');\n\t\t\texpect(config).toBeUndefined();\n\t\t});\n\n\t\tit('should return undefined when no config files exist in search paths', () => {\n\t\t\t// Mock process.cwd to return a directory without config files\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue('/tmp/empty-dir');\n\n\t\t\tconst config = loadConfig();\n\t\t\texpect(config).toBeUndefined();\n\t\t});\n\n\t\tit('should handle invalid JSON gracefully', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.ccusage/ccusage.json': '{ invalid json }',\n\t\t\t});\n\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\n\t\t\tconst config = loadConfig();\n\t\t\texpect(config).toBeUndefined();\n\t\t});\n\n\t\tit('should prioritize local .ccusage config over Claude paths', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.ccusage/ccusage.json': JSON.stringify({\n\t\t\t\t\tdefaults: { json: true },\n\t\t\t\t\tcommands: { daily: { priority: 'local' } },\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\n\t\t\tconst config = loadConfig();\n\t\t\texpect(config).toBeDefined();\n\t\t\texpect(config?.defaults?.json).toBe(true);\n\t\t\texpect(config?.commands?.daily?.priority).toBe('local');\n\t\t});\n\n\t\tit('should test configuration priority order with multiple files', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.ccusage/ccusage.json': JSON.stringify({\n\t\t\t\t\tsource: 'local',\n\t\t\t\t\tdefaults: { mode: 'local-mode' },\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\t// Test 1: Local config should have highest priority\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\n\t\t\tconst config1 = loadConfig();\n\t\t\texpect(config1?.source).toBe(fixture.getPath('.ccusage/ccusage.json'));\n\t\t\texpect(config1?.defaults?.mode).toBe('local-mode');\n\n\t\t\t// Test 2: When local doesn't exist, search in Claude paths\n\t\t\tawait using fixture2 = await createFixture({\n\t\t\t\t'no-ccusage-dir': '',\n\t\t\t});\n\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture2.getPath());\n\n\t\t\tconst config2 = loadConfig();\n\t\t\t// Since we can't easily mock getClaudePaths, this test verifies the logic\n\t\t\t// In real implementation, first available config would be loaded\n\t\t\texpect(config2).toBeUndefined(); // No local .ccusage and no real Claude paths\n\t\t});\n\n\t\tit('should handle getClaudePaths() errors gracefully', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.ccusage/ccusage.json': JSON.stringify({\n\t\t\t\t\tdefaults: { json: true },\n\t\t\t\t\tsource: 'local-fallback',\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\t\t\t// getClaudePaths might throw if no Claude directories exist\n\n\t\t\tconst config = loadConfig();\n\t\t\texpect(config).toBeDefined();\n\t\t\texpect(config?.source).toBe(fixture.getPath('.ccusage/ccusage.json'));\n\t\t\texpect(config?.defaults?.json).toBe(true);\n\t\t});\n\n\t\tit('should handle empty configuration file', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.ccusage/ccusage.json': '{}',\n\t\t\t});\n\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\n\t\t\tconst config = loadConfig();\n\t\t\texpect(config).toBeDefined();\n\t\t\texpect(config?.defaults).toBeUndefined();\n\t\t\texpect(config?.commands).toBeUndefined();\n\t\t});\n\n\t\tit('should validate configuration structure', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.ccusage/ccusage.json': JSON.stringify({\n\t\t\t\t\tdefaults: 'invalid-type', // Should be object\n\t\t\t\t\tcommands: { daily: { instances: true } },\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\n\t\t\tconst config = loadConfig();\n\t\t\texpect(config).toBeUndefined();\n\t\t});\n\n\t\tit('should use validateConfigFile internally', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.ccusage/ccusage.json': JSON.stringify({\n\t\t\t\t\tdefaults: { json: true },\n\t\t\t\t\tcommands: { daily: { instances: true } },\n\t\t\t\t}),\n\t\t\t\t'invalid.json': '{ invalid json',\n\t\t\t\t'valid-minimal.json': '{}',\n\t\t\t});\n\n\t\t\t// Test validateConfigFile directly\n\t\t\tconst validResult = validateConfigFile(fixture.getPath('.ccusage/ccusage.json'));\n\t\t\texpect(validResult.success).toBe(true);\n\t\t\texpect((validResult as { success: true; data: ConfigData }).data.defaults?.json).toBe(true);\n\t\t\texpect(\n\t\t\t\t(validResult as { success: true; data: ConfigData }).data.commands?.daily?.instances,\n\t\t\t).toBe(true);\n\n\t\t\tconst invalidResult = validateConfigFile(fixture.getPath('invalid.json'));\n\t\t\texpect(invalidResult.success).toBe(false);\n\t\t\texpect((invalidResult as { success: false; error: Error }).error).toBeInstanceOf(Error);\n\n\t\t\tconst minimalResult = validateConfigFile(fixture.getPath('valid-minimal.json'));\n\t\t\texpect(minimalResult.success).toBe(true);\n\t\t\texpect((minimalResult as { success: true; data: ConfigData }).data).toEqual({});\n\n\t\t\tconst nonExistentResult = validateConfigFile(fixture.getPath('non-existent.json'));\n\t\t\texpect(nonExistentResult.success).toBe(false);\n\t\t\texpect((nonExistentResult as { success: false; error: Error }).error.message).toContain(\n\t\t\t\t'does not exist',\n\t\t\t);\n\t\t});\n\t});\n\n\tdescribe('mergeConfigWithArgs', () => {\n\t\tit('should merge config with CLI args correctly', () => {\n\t\t\tconst config: ConfigData = {\n\t\t\t\tdefaults: {\n\t\t\t\t\tjson: false,\n\t\t\t\t\tmode: 'auto',\n\t\t\t\t\tdebug: false,\n\t\t\t\t},\n\t\t\t\tcommands: {\n\t\t\t\t\tdaily: {\n\t\t\t\t\t\tinstances: true,\n\t\t\t\t\t\tproject: 'test-project',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst cliArgs = {\n\t\t\t\tjson: true, // Override config\n\t\t\t\tproject: undefined, // Should not override config\n\t\t\t\tbreakdown: true, // Not in config\n\t\t\t};\n\n\t\t\tconst merged = mergeConfigWithArgs(\n\t\t\t\t{\n\t\t\t\t\tvalues: cliArgs,\n\t\t\t\t\ttokens: [\n\t\t\t\t\t\t{ kind: 'option', name: 'json' },\n\t\t\t\t\t\t{ kind: 'option', name: 'breakdown' },\n\t\t\t\t\t],\n\t\t\t\t\tname: 'daily',\n\t\t\t\t},\n\t\t\t\tconfig,\n\t\t\t);\n\n\t\t\texpect(merged).toEqual({\n\t\t\t\tjson: true, // From CLI (overrides config)\n\t\t\t\tmode: 'auto', // From defaults\n\t\t\t\tdebug: false, // From defaults\n\t\t\t\tinstances: true, // From command config\n\t\t\t\tproject: 'test-project', // From command config (CLI was undefined)\n\t\t\t\tbreakdown: true, // From CLI (new option)\n\t\t\t});\n\t\t});\n\n\t\tit('should work without config', () => {\n\t\t\tconst cliArgs = { json: true, debug: false };\n\t\t\tconst merged = mergeConfigWithArgs({\n\t\t\t\tvalues: cliArgs,\n\t\t\t\ttokens: [\n\t\t\t\t\t{ kind: 'option', name: 'json' },\n\t\t\t\t\t{ kind: 'option', name: 'debug' },\n\t\t\t\t],\n\t\t\t\tname: 'daily',\n\t\t\t});\n\t\t\texpect(merged).toEqual(cliArgs);\n\t\t});\n\n\t\tit('should prioritize CLI args over config', () => {\n\t\t\tconst config: ConfigData = {\n\t\t\t\tdefaults: { json: false },\n\t\t\t\tcommands: { daily: { instances: false } },\n\t\t\t};\n\n\t\t\tconst cliArgs = { json: true, instances: true };\n\t\t\tconst merged = mergeConfigWithArgs(\n\t\t\t\t{\n\t\t\t\t\tvalues: cliArgs,\n\t\t\t\t\ttokens: [\n\t\t\t\t\t\t{ kind: 'option', name: 'json' },\n\t\t\t\t\t\t{ kind: 'option', name: 'instances' },\n\t\t\t\t\t],\n\t\t\t\t\tname: 'daily',\n\t\t\t\t},\n\t\t\t\tconfig,\n\t\t\t);\n\n\t\t\texpect(merged.json).toBe(true);\n\t\t\texpect(merged.instances).toBe(true);\n\t\t});\n\n\t\tit('should not override config with CLI args that were not explicitly provided', () => {\n\t\t\tconst config: ConfigData = {\n\t\t\t\tdefaults: {\n\t\t\t\t\tjson: false,\n\t\t\t\t\tmode: 'calculate',\n\t\t\t\t},\n\t\t\t\tcommands: {\n\t\t\t\t\tdaily: {\n\t\t\t\t\t\tinstances: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\n\t\t\t// CLI args has values but only 'json' was explicitly provided\n\t\t\tconst cliArgs = {\n\t\t\t\tjson: true,\n\t\t\t\tmode: 'auto', // This has a value but wasn't explicitly provided\n\t\t\t\tinstances: false, // This also has a value but wasn't explicitly provided\n\t\t\t};\n\n\t\t\tconst merged = mergeConfigWithArgs(\n\t\t\t\t{\n\t\t\t\t\tvalues: cliArgs,\n\t\t\t\t\ttokens: [\n\t\t\t\t\t\t{ kind: 'option', name: 'json' }, // Only json was explicitly provided\n\t\t\t\t\t],\n\t\t\t\t\tname: 'daily',\n\t\t\t\t},\n\t\t\t\tconfig,\n\t\t\t);\n\n\t\t\texpect(merged).toEqual({\n\t\t\t\tjson: true, // From CLI (explicitly provided)\n\t\t\t\tmode: 'calculate', // From config (CLI value ignored because not explicit)\n\t\t\t\tinstances: true, // From command config (CLI value ignored because not explicit)\n\t\t\t});\n\t\t});\n\n\t\tit('should handle CLI args with null values correctly', () => {\n\t\t\tconst config: ConfigData = {\n\t\t\t\tdefaults: {\n\t\t\t\t\tproject: 'default-project',\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst cliArgs = {\n\t\t\t\tproject: null, // Explicitly set to null\n\t\t\t};\n\n\t\t\tconst merged = mergeConfigWithArgs(\n\t\t\t\t{ values: cliArgs, tokens: [{ kind: 'option', name: 'project' }], name: 'daily' },\n\t\t\t\tconfig,\n\t\t\t);\n\n\t\t\t// null value in CLI args should not override config even if explicit\n\t\t\texpect(merged).toEqual({\n\t\t\t\tproject: 'default-project', // Config value retained because CLI value is null\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('validateConfigFile', () => {\n\t\tit('should validate valid config file', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'valid.json': JSON.stringify({\n\t\t\t\t\tdefaults: { json: true },\n\t\t\t\t}),\n\t\t\t\t'invalid.json': '{ invalid json',\n\t\t\t});\n\n\t\t\tconst result = validateConfigFile(fixture.getPath('valid.json'));\n\t\t\texpect(result.success).toBe(true);\n\t\t});\n\n\t\tit('should reject invalid JSON', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'valid.json': JSON.stringify({\n\t\t\t\t\tdefaults: { json: true },\n\t\t\t\t}),\n\t\t\t\t'invalid.json': '{ invalid json',\n\t\t\t});\n\n\t\t\tconst result = validateConfigFile(fixture.getPath('invalid.json'));\n\t\t\texpect(result.success).toBe(false);\n\t\t});\n\n\t\tit('should reject non-existent file', () => {\n\t\t\tconst result = validateConfigFile('/non/existent/file.json');\n\t\t\texpect(result.success).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('debug functionality', () => {\n\t\tlet loggerInfoSpy: any;\n\n\t\tbeforeEach(() => {\n\t\t\tvi.restoreAllMocks();\n\t\t\tloggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});\n\t\t});\n\n\t\tafterEach(() => {\n\t\t\tvi.restoreAllMocks();\n\t\t});\n\n\t\tdescribe('loadConfig with debug', () => {\n\t\t\tit('should log debug info when loading config with debug=true', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'.ccusage/ccusage.json': JSON.stringify({\n\t\t\t\t\t\t$schema: 'https://ccusage.com/config-schema.json',\n\t\t\t\t\t\tdefaults: { json: true, mode: 'auto' },\n\t\t\t\t\t\tcommands: { daily: { instances: true } },\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\n\t\t\t\tconst config = loadConfig(undefined, true);\n\n\t\t\t\texpect(config).toBeDefined();\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t'Debug mode enabled - showing config loading details\\n',\n\t\t\t\t);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('Searching for config files:');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t`  • Checking: ${fixture.getPath('.ccusage/ccusage.json')} (found ✓)`,\n\t\t\t\t);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t`Loaded config from: ${fixture.getPath('.ccusage/ccusage.json')}`,\n\t\t\t\t);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t'  • Schema: https://ccusage.com/config-schema.json',\n\t\t\t\t);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('  • Has defaults: yes (2 options)');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('  • Has command configs: yes (daily)');\n\t\t\t});\n\n\t\t\tit('should log search paths when no config found with debug=true', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'no-config-here': '',\n\t\t\t\t});\n\n\t\t\t\tvi.spyOn(process, 'cwd').mockReturnValue(fixture.getPath());\n\n\t\t\t\tconst config = loadConfig(undefined, true);\n\n\t\t\t\texpect(config).toBeUndefined();\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t'Debug mode enabled - showing config loading details\\n',\n\t\t\t\t);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('Searching for config files:');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('No valid configuration file found');\n\t\t\t});\n\n\t\t\tit('should log specific config file path when provided', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'custom-config.json': JSON.stringify({\n\t\t\t\t\t\tdefaults: { debug: true },\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst configPath = fixture.getPath('custom-config.json');\n\t\t\t\tconst config = loadConfig(configPath, true);\n\n\t\t\t\texpect(config).toBeDefined();\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t'Debug mode enabled - showing config loading details\\n',\n\t\t\t\t);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('Using specified config file:');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(`  • Path: ${configPath}`);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(`Loaded config from: ${configPath}`);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('  • Schema: none');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('  • Has defaults: yes (1 options)');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('  • Has command configs: no');\n\t\t\t});\n\t\t});\n\n\t\tdescribe('mergeConfigWithArgs with debug', () => {\n\t\t\tit('should log merge details with debug=true', () => {\n\t\t\t\tconst config: ConfigData = {\n\t\t\t\t\tdefaults: {\n\t\t\t\t\t\tmode: 'auto',\n\t\t\t\t\t\toffline: false,\n\t\t\t\t\t},\n\t\t\t\t\tcommands: {\n\t\t\t\t\t\tdaily: {\n\t\t\t\t\t\t\tinstances: true,\n\t\t\t\t\t\t\tproject: 'test-project',\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tconst cliArgs = {\n\t\t\t\t\tdebug: true,\n\t\t\t\t\tsince: '20250101',\n\t\t\t\t};\n\n\t\t\t\tconst merged = mergeConfigWithArgs(\n\t\t\t\t\t{\n\t\t\t\t\t\tvalues: cliArgs,\n\t\t\t\t\t\ttokens: [\n\t\t\t\t\t\t\t{ kind: 'option', name: 'debug' },\n\t\t\t\t\t\t\t{ kind: 'option', name: 'since' },\n\t\t\t\t\t\t],\n\t\t\t\t\t\tname: 'daily',\n\t\t\t\t\t},\n\t\t\t\t\tconfig,\n\t\t\t\t\ttrue,\n\t\t\t\t);\n\n\t\t\t\texpect(merged).toEqual({\n\t\t\t\t\tmode: 'auto',\n\t\t\t\t\toffline: false,\n\t\t\t\t\tinstances: true,\n\t\t\t\t\tproject: 'test-project',\n\t\t\t\t\tdebug: true,\n\t\t\t\t\tsince: '20250101',\n\t\t\t\t});\n\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(`Merging options for 'daily' command:`);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('  • From defaults: mode=\"auto\", offline=false');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t'  • From command config: instances=true, project=\"test-project\"',\n\t\t\t\t);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t'  • From CLI args: debug=true, since=\"20250101\"',\n\t\t\t\t);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('  • Final merged options: {');\n\t\t\t});\n\n\t\t\tit('should log no config message with debug=true when config is null', () => {\n\t\t\t\tconst cliArgs = { json: true, debug: false };\n\n\t\t\t\tconst merged = mergeConfigWithArgs(\n\t\t\t\t\t{\n\t\t\t\t\t\tvalues: cliArgs,\n\t\t\t\t\t\ttokens: [\n\t\t\t\t\t\t\t{ kind: 'option', name: 'json' },\n\t\t\t\t\t\t\t{ kind: 'option', name: 'debug' },\n\t\t\t\t\t\t],\n\t\t\t\t\t\tname: 'daily',\n\t\t\t\t\t},\n\t\t\t\t\tundefined,\n\t\t\t\t\ttrue,\n\t\t\t\t);\n\n\t\t\t\texpect(merged).toEqual(cliArgs);\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith('');\n\t\t\t\texpect(loggerInfoSpy).toHaveBeenCalledWith(\n\t\t\t\t\t`No config file loaded, using CLI args only for 'daily' command`,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_consts.ts",
    "content": "import { homedir } from 'node:os';\nimport path from 'node:path';\nimport { xdgConfig } from 'xdg-basedir';\n\n/**\n * Default number of recent days to include when filtering blocks\n * Used in both session blocks and commands for consistent behavior\n */\nexport const DEFAULT_RECENT_DAYS = 3;\n\n/**\n * Threshold percentage for showing usage warnings in blocks command (80%)\n * When usage exceeds this percentage of limits, warnings are displayed\n */\nexport const BLOCKS_WARNING_THRESHOLD = 0.8;\n\n/**\n * Terminal width threshold for switching to compact display mode in blocks command\n * Below this width, tables use more compact formatting\n */\nexport const BLOCKS_COMPACT_WIDTH_THRESHOLD = 120;\n\n/**\n * Default terminal width when stdout.columns is not available in blocks command\n * Used as fallback for responsive table formatting\n */\nexport const BLOCKS_DEFAULT_TERMINAL_WIDTH = 120;\n\n/**\n * Threshold percentage for considering costs as matching (0.1% tolerance)\n * Used in debug cost validation to allow for minor calculation differences\n */\nexport const DEBUG_MATCH_THRESHOLD_PERCENT = 0.1;\n\n/**\n * User's home directory path\n * Centralized access to OS home directory for consistent path building\n */\nexport const USER_HOME_DIR = homedir();\n\n/**\n * XDG config directory path\n * Uses XDG_CONFIG_HOME if set, otherwise falls back to ~/.config\n */\nconst XDG_CONFIG_DIR = xdgConfig ?? path.join(USER_HOME_DIR, '.config');\n\n/**\n * Default Claude data directory path (~/.claude)\n * Used as base path for loading usage data from JSONL files\n */\nexport const DEFAULT_CLAUDE_CODE_PATH = '.claude';\n\n/**\n * Default Claude data directory path using XDG config directory\n * Uses XDG_CONFIG_HOME if set, otherwise falls back to ~/.config/claude\n */\nexport const DEFAULT_CLAUDE_CONFIG_PATH = path.join(XDG_CONFIG_DIR, 'claude');\n\n/**\n * Environment variable for specifying multiple Claude data directories\n * Supports comma-separated paths for multiple locations\n */\nexport const CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR';\n\n/**\n * Claude projects directory name within the data directory\n * Contains subdirectories for each project with usage data\n */\nexport const CLAUDE_PROJECTS_DIR_NAME = 'projects';\n\n/**\n * JSONL file glob pattern for finding usage data files\n * Used to recursively find all JSONL files in project directories\n */\nexport const USAGE_DATA_GLOB_PATTERN = '**/*.jsonl';\n\n/**\n * Default port for MCP server HTTP transport\n * Used when no port is specified for MCP server communication\n */\nexport const MCP_DEFAULT_PORT = 8080;\n\n/**\n * Default refresh interval in seconds for statusline cache expiry\n */\nexport const DEFAULT_REFRESH_INTERVAL_SECONDS = 1;\n\n/**\n * Context usage percentage thresholds for color coding\n */\nexport const DEFAULT_CONTEXT_USAGE_THRESHOLDS = {\n\tLOW: 50, // Below 50% - green\n\tMEDIUM: 80, // 50-80% - yellow\n\t// Above 80% - red\n} as const;\n\n/**\n * Days of the week for weekly aggregation\n */\nexport const WEEK_DAYS = [\n\t'sunday',\n\t'monday',\n\t'tuesday',\n\t'wednesday',\n\t'thursday',\n\t'friday',\n\t'saturday',\n] as const;\n\n/**\n * Week day names type\n */\nexport type WeekDay = (typeof WEEK_DAYS)[number];\n\n/**\n * Day of week as number (0 = Sunday, 1 = Monday, ..., 6 = Saturday)\n */\nexport type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;\n\n/**\n * Default configuration file name for storing usage data\n * Used to load and save configuration settings\n */\nexport const CONFIG_FILE_NAME = 'ccusage.json';\n\n/**\n * Default locale for date formatting (en-CA provides YYYY-MM-DD ISO format)\n * Used consistently across the application for date parsing and display\n */\nexport const DEFAULT_LOCALE = 'en-CA';\n"
  },
  {
    "path": "apps/ccusage/src/_daily-grouping.ts",
    "content": "import type { DailyProjectOutput } from './_json-output-types.ts';\nimport type { loadDailyUsageData } from './data-loader.ts';\nimport { createDailyDate, createModelName } from './_types.ts';\nimport { getTotalTokens } from './calculate-cost.ts';\n\n/**\n * Type for daily data returned from loadDailyUsageData\n */\ntype DailyData = Awaited<ReturnType<typeof loadDailyUsageData>>;\n\n/**\n * Group daily usage data by project for JSON output\n */\nexport function groupByProject(dailyData: DailyData): Record<string, DailyProjectOutput[]> {\n\tconst projects: Record<string, DailyProjectOutput[]> = {};\n\n\tfor (const data of dailyData) {\n\t\tconst projectName = data.project ?? 'unknown';\n\n\t\tif (projects[projectName] == null) {\n\t\t\tprojects[projectName] = [];\n\t\t}\n\n\t\tprojects[projectName].push({\n\t\t\tdate: data.date,\n\t\t\tinputTokens: data.inputTokens,\n\t\t\toutputTokens: data.outputTokens,\n\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\ttotalTokens: getTotalTokens(data),\n\t\t\ttotalCost: data.totalCost,\n\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\tmodelBreakdowns: data.modelBreakdowns,\n\t\t});\n\t}\n\n\treturn projects;\n}\n\n/**\n * Group daily usage data by project for table display\n */\nexport function groupDataByProject(dailyData: DailyData): Record<string, DailyData> {\n\tconst projects: Record<string, DailyData> = {};\n\n\tfor (const data of dailyData) {\n\t\tconst projectName = data.project ?? 'unknown';\n\n\t\tif (projects[projectName] == null) {\n\t\t\tprojects[projectName] = [];\n\t\t}\n\n\t\tprojects[projectName].push(data);\n\t}\n\n\treturn projects;\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('groupByProject', () => {\n\t\tit('groups daily data by project for JSON output', () => {\n\t\t\tconst mockData = [\n\t\t\t\t{\n\t\t\t\t\tdate: createDailyDate('2024-01-01'),\n\t\t\t\t\tproject: 'project-a',\n\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\tcacheCreationTokens: 100,\n\t\t\t\t\tcacheReadTokens: 200,\n\t\t\t\t\ttotalCost: 0.01,\n\t\t\t\t\tmodelsUsed: [createModelName('claude-sonnet-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tdate: createDailyDate('2024-01-01'),\n\t\t\t\t\tproject: 'project-b',\n\t\t\t\t\tinputTokens: 2000,\n\t\t\t\t\toutputTokens: 1000,\n\t\t\t\t\tcacheCreationTokens: 200,\n\t\t\t\t\tcacheReadTokens: 300,\n\t\t\t\t\ttotalCost: 0.02,\n\t\t\t\t\tmodelsUsed: [createModelName('claude-opus-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst result = groupByProject(mockData);\n\n\t\t\texpect(Object.keys(result)).toHaveLength(2);\n\t\t\texpect(result['project-a']).toHaveLength(1);\n\t\t\texpect(result['project-b']).toHaveLength(1);\n\t\t\texpect(result['project-a']![0]!.totalTokens).toBe(1800);\n\t\t\texpect(result['project-b']![0]!.totalTokens).toBe(3500);\n\t\t});\n\n\t\tit('handles unknown project names', () => {\n\t\t\tconst mockData = [\n\t\t\t\t{\n\t\t\t\t\tdate: createDailyDate('2024-01-01'),\n\t\t\t\t\tproject: undefined,\n\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\ttotalCost: 0.01,\n\t\t\t\t\tmodelsUsed: [createModelName('claude-sonnet-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst result = groupByProject(mockData);\n\n\t\t\texpect(Object.keys(result)).toHaveLength(1);\n\t\t\texpect(result.unknown).toHaveLength(1);\n\t\t});\n\t});\n\n\tdescribe('groupDataByProject', () => {\n\t\tit('groups daily data by project for table display', () => {\n\t\t\tconst mockData = [\n\t\t\t\t{\n\t\t\t\t\tdate: createDailyDate('2024-01-01'),\n\t\t\t\t\tproject: 'project-a',\n\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\tcacheCreationTokens: 100,\n\t\t\t\t\tcacheReadTokens: 200,\n\t\t\t\t\ttotalCost: 0.01,\n\t\t\t\t\tmodelsUsed: [createModelName('claude-sonnet-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tdate: createDailyDate('2024-01-02'),\n\t\t\t\t\tproject: 'project-a',\n\t\t\t\t\tinputTokens: 800,\n\t\t\t\t\toutputTokens: 400,\n\t\t\t\t\tcacheCreationTokens: 50,\n\t\t\t\t\tcacheReadTokens: 150,\n\t\t\t\t\ttotalCost: 0.008,\n\t\t\t\t\tmodelsUsed: [createModelName('claude-sonnet-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst result = groupDataByProject(mockData);\n\n\t\t\texpect(Object.keys(result)).toHaveLength(1);\n\t\t\texpect(result['project-a']).toHaveLength(2);\n\t\t\texpect(result['project-a']).toEqual(mockData);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_date-utils.ts",
    "content": "/**\n * Date utility functions for handling date formatting, filtering, and manipulation\n * @module date-utils\n */\n\nimport type { DayOfWeek, WeekDay } from './_consts.ts';\nimport type { WeeklyDate } from './_types.ts';\nimport { sort } from 'fast-sort';\nimport { DEFAULT_LOCALE } from './_consts.ts';\nimport { createWeeklyDate } from './_types.ts';\nimport { unreachable } from './_utils.ts';\n\n// Re-export formatDateCompact from shared package\nexport { formatDateCompact } from '@ccusage/terminal/table';\n\n/**\n * Sort order for date-based sorting\n */\nexport type SortOrder = 'asc' | 'desc';\n\n/**\n * Creates a date formatter with the specified timezone and locale\n * @param timezone - Timezone to use (e.g., 'UTC', 'America/New_York')\n * @param locale - Locale to use for formatting (e.g., 'en-US', 'ja-JP')\n * @returns Intl.DateTimeFormat instance\n */\nfunction createDateFormatter(timezone: string | undefined, locale: string): Intl.DateTimeFormat {\n\treturn new Intl.DateTimeFormat(locale, {\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t\ttimeZone: timezone,\n\t});\n}\n\n/**\n * Formats a date string to YYYY-MM-DD format\n * @param dateStr - Input date string\n * @param timezone - Optional timezone to use for formatting\n * @param locale - Optional locale to use for formatting (defaults to DEFAULT_LOCALE for YYYY-MM-DD format)\n * @returns Formatted date string in YYYY-MM-DD format\n */\nexport function formatDate(dateStr: string, timezone?: string, locale?: string): string {\n\tconst date = new Date(dateStr);\n\t// Use DEFAULT_LOCALE as default for consistent YYYY-MM-DD format\n\tconst formatter = createDateFormatter(timezone, locale ?? DEFAULT_LOCALE);\n\treturn formatter.format(date);\n}\n\n/**\n * Generic function to sort items by date based on sort order\n * @param items - Array of items to sort\n * @param getDate - Function to extract date/timestamp from item\n * @param order - Sort order (asc or desc)\n * @returns Sorted array\n */\nexport function sortByDate<T>(\n\titems: T[],\n\tgetDate: (item: T) => string | Date,\n\torder: SortOrder = 'desc',\n): T[] {\n\tconst sorted = sort(items);\n\tswitch (order) {\n\t\tcase 'desc':\n\t\t\treturn sorted.desc((item) => new Date(getDate(item)).getTime());\n\t\tcase 'asc':\n\t\t\treturn sorted.asc((item) => new Date(getDate(item)).getTime());\n\t\tdefault:\n\t\t\tunreachable(order);\n\t}\n}\n\n/**\n * Filters items by date range\n * @param items - Array of items to filter\n * @param getDate - Function to extract date string from item\n * @param since - Start date in any format (will be converted to YYYYMMDD for comparison)\n * @param until - End date in any format (will be converted to YYYYMMDD for comparison)\n * @returns Filtered array\n */\nexport function filterByDateRange<T>(\n\titems: T[],\n\tgetDate: (item: T) => string,\n\tsince?: string,\n\tuntil?: string,\n): T[] {\n\tif (since == null && until == null) {\n\t\treturn items;\n\t}\n\n\treturn items.filter((item) => {\n\t\tconst dateStr = getDate(item).substring(0, 10).replace(/-/g, ''); // Convert to YYYYMMDD\n\t\tif (since != null && dateStr < since) {\n\t\t\treturn false;\n\t\t}\n\t\tif (until != null && dateStr > until) {\n\t\t\treturn false;\n\t\t}\n\t\treturn true;\n\t});\n}\n\n/**\n * Get the first day of the week for a given date\n * @param date - The date to get the week for\n * @param startDay - The day to start the week on (0 = Sunday, 1 = Monday, ..., 6 = Saturday)\n * @returns The date of the first day of the week for the given date\n */\nexport function getDateWeek(date: Date, startDay: DayOfWeek): WeeklyDate {\n\tconst d = new Date(date);\n\tconst day = d.getDay();\n\tconst shift = (day - startDay + 7) % 7;\n\td.setDate(d.getDate() - shift);\n\n\treturn createWeeklyDate(d.toISOString().substring(0, 10));\n}\n\n/**\n * Convert day name to number (0 = Sunday, 1 = Monday, ..., 6 = Saturday)\n * @param day - Day name\n * @returns Day number\n */\nexport function getDayNumber(day: WeekDay): DayOfWeek {\n\tconst dayMap = {\n\t\tsunday: 0,\n\t\tmonday: 1,\n\t\ttuesday: 2,\n\t\twednesday: 3,\n\t\tthursday: 4,\n\t\tfriday: 5,\n\t\tsaturday: 6,\n\t} as const satisfies Record<WeekDay, DayOfWeek>;\n\treturn dayMap[day];\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('formatDate', () => {\n\t\tit('should format date string to YYYY-MM-DD format', () => {\n\t\t\tconst result = formatDate('2024-08-04T12:00:00Z');\n\t\t\texpect(result).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n\t\t});\n\n\t\tit('should handle timezone parameter', () => {\n\t\t\tconst result = formatDate('2024-08-04T12:00:00Z', 'UTC');\n\t\t\texpect(result).toBe('2024-08-04');\n\t\t});\n\n\t\tit('should use default locale when locale is not provided', () => {\n\t\t\tconst result = formatDate('2024-08-04T12:00:00Z');\n\t\t\texpect(result).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n\t\t});\n\n\t\tit('should handle custom locale', () => {\n\t\t\tconst result = formatDate('2024-08-04T12:00:00Z', 'UTC', 'en-US');\n\t\t\texpect(result).toBe('08/04/2024');\n\t\t});\n\t});\n\n\t// formatDateCompact tests are in @ccusage/terminal/table.ts\n\n\tdescribe('sortByDate', () => {\n\t\tconst testData = [\n\t\t\t{ id: 1, date: '2024-01-01T10:00:00Z' },\n\t\t\t{ id: 2, date: '2024-01-03T10:00:00Z' },\n\t\t\t{ id: 3, date: '2024-01-02T10:00:00Z' },\n\t\t];\n\n\t\tit('should sort by date in descending order by default', () => {\n\t\t\tconst result = sortByDate(testData, (item) => item.date);\n\t\t\texpect(result.map((item) => item.id)).toEqual([2, 3, 1]);\n\t\t});\n\n\t\tit('should sort by date in ascending order when specified', () => {\n\t\t\tconst result = sortByDate(testData, (item) => item.date, 'asc');\n\t\t\texpect(result.map((item) => item.id)).toEqual([1, 3, 2]);\n\t\t});\n\n\t\tit('should sort by date in descending order when explicitly specified', () => {\n\t\t\tconst result = sortByDate(testData, (item) => item.date, 'desc');\n\t\t\texpect(result.map((item) => item.id)).toEqual([2, 3, 1]);\n\t\t});\n\n\t\tit('should handle Date objects', () => {\n\t\t\tconst dateData = [\n\t\t\t\t{ id: 1, date: new Date('2024-01-01T10:00:00Z') },\n\t\t\t\t{ id: 2, date: new Date('2024-01-03T10:00:00Z') },\n\t\t\t\t{ id: 3, date: new Date('2024-01-02T10:00:00Z') },\n\t\t\t];\n\t\t\tconst result = sortByDate(dateData, (item) => item.date);\n\t\t\texpect(result.map((item) => item.id)).toEqual([2, 3, 1]);\n\t\t});\n\t});\n\n\tdescribe('filterByDateRange', () => {\n\t\tconst testData = [\n\t\t\t{ id: 1, date: '2024-01-01' },\n\t\t\t{ id: 2, date: '2024-01-02' },\n\t\t\t{ id: 3, date: '2024-01-03' },\n\t\t\t{ id: 4, date: '2024-01-04' },\n\t\t\t{ id: 5, date: '2024-01-05' },\n\t\t];\n\n\t\tit('should return all items when no date filters are provided', () => {\n\t\t\tconst result = filterByDateRange(testData, (item) => item.date);\n\t\t\texpect(result).toEqual(testData);\n\t\t});\n\n\t\tit('should filter by since date', () => {\n\t\t\tconst result = filterByDateRange(testData, (item) => item.date, '20240103');\n\t\t\texpect(result.map((item) => item.id)).toEqual([3, 4, 5]);\n\t\t});\n\n\t\tit('should filter by until date', () => {\n\t\t\tconst result = filterByDateRange(testData, (item) => item.date, undefined, '20240103');\n\t\t\texpect(result.map((item) => item.id)).toEqual([1, 2, 3]);\n\t\t});\n\n\t\tit('should filter by both since and until dates', () => {\n\t\t\tconst result = filterByDateRange(testData, (item) => item.date, '20240102', '20240104');\n\t\t\texpect(result.map((item) => item.id)).toEqual([2, 3, 4]);\n\t\t});\n\n\t\tit('should handle timestamp format dates', () => {\n\t\t\tconst timestampData = [\n\t\t\t\t{ id: 1, date: '2024-01-01T10:00:00Z' },\n\t\t\t\t{ id: 2, date: '2024-01-02T10:00:00Z' },\n\t\t\t\t{ id: 3, date: '2024-01-03T10:00:00Z' },\n\t\t\t];\n\t\t\tconst result = filterByDateRange(timestampData, (item) => item.date, '20240102');\n\t\t\texpect(result.map((item) => item.id)).toEqual([2, 3]);\n\t\t});\n\t});\n\n\tdescribe('getDateWeek', () => {\n\t\tit('should get the first day of week starting from Sunday', () => {\n\t\t\tconst date = new Date('2024-01-03T10:00:00Z'); // Wednesday\n\t\t\tconst result = getDateWeek(date, 0); // Sunday start\n\t\t\texpect(result).toBe(createWeeklyDate('2023-12-31')); // Previous Sunday\n\t\t});\n\n\t\tit('should get the first day of week starting from Monday', () => {\n\t\t\tconst date = new Date('2024-01-03T10:00:00Z'); // Wednesday\n\t\t\tconst result = getDateWeek(date, 1); // Monday start\n\t\t\texpect(result).toBe(createWeeklyDate('2024-01-01')); // Monday of same week\n\t\t});\n\n\t\tit('should handle when the date is already the start of the week', () => {\n\t\t\tconst date = new Date('2024-01-01T10:00:00Z'); // Monday\n\t\t\tconst result = getDateWeek(date, 1); // Monday start\n\t\t\texpect(result).toBe(createWeeklyDate('2024-01-01')); // Same Monday\n\t\t});\n\n\t\tit('should handle Sunday as start of week when date is Sunday', () => {\n\t\t\tconst date = new Date('2023-12-31T10:00:00Z'); // Sunday\n\t\t\tconst result = getDateWeek(date, 0); // Sunday start\n\t\t\texpect(result).toBe(createWeeklyDate('2023-12-31')); // Same Sunday\n\t\t});\n\t});\n\n\tdescribe('getDayNumber', () => {\n\t\tit('should convert day names to correct numbers', () => {\n\t\t\texpect(getDayNumber('sunday')).toBe(0);\n\t\t\texpect(getDayNumber('monday')).toBe(1);\n\t\t\texpect(getDayNumber('tuesday')).toBe(2);\n\t\t\texpect(getDayNumber('wednesday')).toBe(3);\n\t\t\texpect(getDayNumber('thursday')).toBe(4);\n\t\t\texpect(getDayNumber('friday')).toBe(5);\n\t\t\texpect(getDayNumber('saturday')).toBe(6);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_jq-processor.ts",
    "content": "import { Result } from '@praha/byethrow';\nimport spawn from 'nano-spawn';\n\n/**\n * Process JSON data with a jq command\n * @param jsonData - The JSON data to process\n * @param jqCommand - The jq command/filter to apply\n * @returns The processed output from jq\n */\nexport async function processWithJq(\n\tjsonData: unknown,\n\tjqCommand: string,\n): Result.ResultAsync<string, Error> {\n\t// Convert JSON data to string\n\tconst jsonString = JSON.stringify(jsonData);\n\n\t// Use Result.try with object form to wrap spawn call\n\tconst result = Result.try({\n\t\ttry: async () => {\n\t\t\tconst spawnResult = await spawn('jq', [jqCommand], {\n\t\t\t\tstdin: { string: jsonString },\n\t\t\t});\n\t\t\treturn spawnResult.output.trim();\n\t\t},\n\t\tcatch: (error: unknown) => {\n\t\t\tif (error instanceof Error) {\n\t\t\t\t// Check if jq is not installed\n\t\t\t\tif (error.message.includes('ENOENT') || error.message.includes('not found')) {\n\t\t\t\t\treturn new Error('jq command not found. Please install jq to use the --jq option.');\n\t\t\t\t}\n\t\t\t\t// Return other errors (e.g., invalid jq syntax)\n\t\t\t\treturn new Error(`jq processing failed: ${error.message}`);\n\t\t\t}\n\t\t\treturn new Error('Unknown error during jq processing');\n\t\t},\n\t});\n\n\treturn result();\n}\n\n// In-source tests\nif (import.meta.vitest != null) {\n\tdescribe('processWithJq', () => {\n\t\tit('should process JSON with simple filter', async () => {\n\t\t\tconst data = { name: 'test', value: 42 };\n\t\t\tconst result = await processWithJq(data, '.name');\n\t\t\tconst unwrapped = Result.unwrap(result);\n\t\t\texpect(unwrapped).toBe('\"test\"');\n\t\t});\n\n\t\tit('should process JSON with complex filter', async () => {\n\t\t\tconst data = {\n\t\t\t\titems: [\n\t\t\t\t\t{ id: 1, name: 'apple' },\n\t\t\t\t\t{ id: 2, name: 'banana' },\n\t\t\t\t],\n\t\t\t};\n\t\t\tconst result = await processWithJq(data, '.items | map(.name)');\n\t\t\tconst unwrapped = Result.unwrap(result);\n\t\t\tconst parsed = JSON.parse(unwrapped) as string[];\n\t\t\texpect(parsed).toEqual(['apple', 'banana']);\n\t\t});\n\n\t\tit('should handle raw output', async () => {\n\t\t\tconst data = { message: 'hello world' };\n\t\t\tconst result = await processWithJq(data, '.message | @text');\n\t\t\tconst unwrapped = Result.unwrap(result);\n\t\t\texpect(unwrapped).toBe('\"hello world\"');\n\t\t});\n\n\t\tit('should return error for invalid jq syntax', async () => {\n\t\t\tconst data = { test: 'value' };\n\t\t\tconst result = await processWithJq(data, 'invalid syntax {');\n\t\t\tconst error = Result.unwrapError(result);\n\t\t\texpect(error.message).toContain('jq processing failed');\n\t\t});\n\n\t\tit('should handle complex jq operations', async () => {\n\t\t\tconst data = {\n\t\t\t\tusers: [\n\t\t\t\t\t{ name: 'Alice', age: 30 },\n\t\t\t\t\t{ name: 'Bob', age: 25 },\n\t\t\t\t\t{ name: 'Charlie', age: 35 },\n\t\t\t\t],\n\t\t\t};\n\t\t\tconst result = await processWithJq(data, '.users | sort_by(.age) | .[0].name');\n\t\t\tconst unwrapped = Result.unwrap(result);\n\t\t\texpect(unwrapped).toBe('\"Bob\"');\n\t\t});\n\n\t\tit('should handle numeric output', async () => {\n\t\t\tconst data = { values: [1, 2, 3, 4, 5] };\n\t\t\tconst result = await processWithJq(data, '.values | add');\n\t\t\tconst unwrapped = Result.unwrap(result);\n\t\t\texpect(unwrapped).toBe('15');\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_json-output-types.ts",
    "content": "/**\n * @fileoverview JSON output interface types for daily command groupByProject function\n *\n * This module provides TypeScript interfaces for the JSON output structure\n * used by the daily command's groupByProject function, replacing the\n * unsafe Record<string, any[]> type with proper type definitions.\n *\n * @module json-output-types\n */\n\nimport type { DailyDate, ModelName } from './_types.ts';\nimport type { ModelBreakdown } from './data-loader.ts';\n\n/**\n * Interface for daily command JSON output structure (groupByProject)\n * Used in src/commands/daily.ts\n */\nexport type DailyProjectOutput = {\n\tdate: DailyDate;\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\ttotalTokens: number;\n\ttotalCost: number;\n\tmodelsUsed: ModelName[];\n\tmodelBreakdowns: ModelBreakdown[];\n};\n"
  },
  {
    "path": "apps/ccusage/src/_macro.ts",
    "content": "import type { LiteLLMModelPricing } from '@ccusage/internal/pricing';\nimport {\n\tcreatePricingDataset,\n\tfetchLiteLLMPricingDataset,\n\tfilterPricingDataset,\n} from '@ccusage/internal/pricing-fetch-utils';\n\nfunction isClaudeModel(modelName: string, _pricing: LiteLLMModelPricing): boolean {\n\treturn (\n\t\tmodelName.startsWith('claude-') ||\n\t\tmodelName.startsWith('anthropic.claude-') ||\n\t\tmodelName.startsWith('anthropic/claude-')\n\t);\n}\n\nexport async function prefetchClaudePricing(): Promise<Record<string, LiteLLMModelPricing>> {\n\ttry {\n\t\tconst dataset = await fetchLiteLLMPricingDataset();\n\t\treturn filterPricingDataset(dataset, isClaudeModel);\n\t} catch (error) {\n\t\tconsole.warn('Failed to prefetch Claude pricing data, proceeding with empty cache.', error);\n\t\treturn createPricingDataset();\n\t}\n}\n"
  },
  {
    "path": "apps/ccusage/src/_pricing-fetcher.ts",
    "content": "import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';\nimport { Result } from '@praha/byethrow';\nimport { prefetchClaudePricing } from './_macro.ts' with { type: 'macro' };\nimport { logger } from './logger.ts';\n\nconst CLAUDE_PROVIDER_PREFIXES = [\n\t'anthropic/',\n\t'claude-3-5-',\n\t'claude-3-',\n\t'claude-',\n\t'openrouter/openai/',\n];\n\nconst PREFETCHED_CLAUDE_PRICING = prefetchClaudePricing();\n\nexport class PricingFetcher extends LiteLLMPricingFetcher {\n\tconstructor(offline = false) {\n\t\tsuper({\n\t\t\toffline,\n\t\t\tofflineLoader: async () => PREFETCHED_CLAUDE_PRICING,\n\t\t\tlogger,\n\t\t\tproviderPrefixes: CLAUDE_PROVIDER_PREFIXES,\n\t\t});\n\t}\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('PricingFetcher', () => {\n\t\tit('loads offline pricing when offline flag is true', async () => {\n\t\t\tusing fetcher = new PricingFetcher(true);\n\t\t\tconst pricing = await Result.unwrap(fetcher.fetchModelPricing());\n\t\t\texpect(pricing.size).toBeGreaterThan(0);\n\t\t});\n\n\t\tit('calculates cost for Claude model tokens', async () => {\n\t\t\tusing fetcher = new PricingFetcher(true);\n\t\t\tconst pricing = await Result.unwrap(fetcher.getModelPricing('claude-sonnet-4-20250514'));\n\t\t\tconst cost = fetcher.calculateCostFromPricing(\n\t\t\t\t{\n\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\tcache_read_input_tokens: 300,\n\t\t\t\t},\n\t\t\t\tpricing!,\n\t\t\t);\n\n\t\t\texpect(cost).toBeGreaterThan(0);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_project-names.ts",
    "content": "/**\n * @fileoverview Project name formatting and alias utilities\n *\n * Provides utilities for formatting raw project directory names into user-friendly\n * display names with support for custom aliases and improved path parsing.\n *\n * @module project-names\n */\n\n/**\n * Extract meaningful project name from directory-style project paths\n * Uses improved heuristics to handle complex project structures\n *\n * @param projectName - Raw project name from directory path\n * @returns Cleaned and formatted project name\n *\n * @example\n * ```typescript\n * // Basic cleanup\n * parseProjectName('-Users-phaedrus-Development-ccusage')\n * // → 'ccusage'\n *\n * // Complex project with feature branch\n * parseProjectName('-Users-phaedrus-Development-adminifi-edugakko-api--feature-ticket-002-configure-dependabot')\n * // → 'configure-dependabot'\n *\n * // Handle unknown projects\n * parseProjectName('unknown')\n * // → 'Unknown Project'\n * ```\n */\nfunction parseProjectName(projectName: string): string {\n\tif (projectName === 'unknown' || projectName === '') {\n\t\treturn 'Unknown Project';\n\t}\n\n\t// Remove common directory prefixes\n\tlet cleaned = projectName;\n\n\t// Handle Windows-style paths: C:\\Users\\... or \\Users\\...\n\tif (cleaned.match(/^[A-Z]:\\\\Users\\\\|^\\\\Users\\\\/) != null) {\n\t\tconst segments = cleaned.split('\\\\');\n\t\tconst userIndex = segments.findIndex((seg) => seg === 'Users');\n\t\tif (userIndex !== -1 && userIndex + 3 < segments.length) {\n\t\t\t// Take everything after Users/username/Projects or similar\n\t\t\tcleaned = segments.slice(userIndex + 3).join('-');\n\t\t}\n\t}\n\n\t// Handle Unix-style paths: /Users/... or -Users-...\n\tif (cleaned.startsWith('-Users-') || cleaned.startsWith('/Users/')) {\n\t\tconst separator = cleaned.startsWith('-Users-') ? '-' : '/';\n\t\tconst segments = cleaned.split(separator).filter((s) => s.length > 0);\n\t\tconst userIndex = segments.findIndex((seg) => seg === 'Users');\n\n\t\tif (userIndex !== -1 && userIndex + 3 < segments.length) {\n\t\t\t// Take everything after Users/username/Development or similar\n\t\t\tcleaned = segments.slice(userIndex + 3).join('-');\n\t\t}\n\t}\n\n\t// If no path cleanup occurred, use original name\n\tif (cleaned === projectName) {\n\t\t// Just basic cleanup for non-path names\n\t\tcleaned = projectName.replace(/^[/\\\\-]+|[/\\\\-]+$/g, '');\n\t}\n\n\t// Handle UUID-like patterns\n\tif (cleaned.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i) != null) {\n\t\t// Extract last two segments of UUID for brevity\n\t\tconst parts = cleaned.split('-');\n\t\tif (parts.length >= 5) {\n\t\t\t// Take the last two segments, which may include file extension in the last segment\n\t\t\tcleaned = parts.slice(-2).join('-');\n\t\t}\n\t}\n\n\t// Improved project name extraction for complex names\n\tif (cleaned.includes('--')) {\n\t\t// Handle project--feature patterns like \"adminifi-edugakko-api--feature-ticket-002\"\n\t\tconst parts = cleaned.split('--');\n\t\tif (parts.length >= 2 && parts[0] != null) {\n\t\t\t// Take the main project part before the first --\n\t\t\tcleaned = parts[0];\n\t\t}\n\t}\n\n\t// For compound project names, try to extract the most meaningful part\n\tif (cleaned.includes('-') && cleaned.length > 20) {\n\t\tconst segments = cleaned.split('-');\n\n\t\t// Look for common meaningful patterns\n\t\tconst meaningfulSegments = segments.filter(\n\t\t\t(seg) =>\n\t\t\t\tseg.length > 2 &&\n\t\t\t\tseg.match(\n\t\t\t\t\t/^(?:dev|development|feat|feature|fix|bug|test|staging|prod|production|main|master|branch)$/i,\n\t\t\t\t) == null,\n\t\t);\n\n\t\t// If we have compound project names like \"adminifi-edugakko-api\"\n\t\t// Try to find the last 2-3 meaningful segments\n\t\tif (meaningfulSegments.length >= 2) {\n\t\t\t// Take last 2-3 segments to get \"edugakko-api\" from \"adminifi-edugakko-api\"\n\t\t\tconst lastSegments = meaningfulSegments.slice(-2);\n\t\t\tif (lastSegments.join('-').length >= 6) {\n\t\t\t\tcleaned = lastSegments.join('-');\n\t\t\t} else if (meaningfulSegments.length >= 3) {\n\t\t\t\tcleaned = meaningfulSegments.slice(-3).join('-');\n\t\t\t}\n\t\t}\n\t}\n\n\t// Final cleanup\n\tcleaned = cleaned.replace(/^[/\\\\-]+|[/\\\\-]+$/g, '');\n\n\treturn cleaned !== '' ? cleaned : projectName !== '' ? projectName : 'Unknown Project';\n}\n\n/**\n * Format project name for display with custom alias support\n *\n * @param projectName - Raw project name from directory path\n * @param aliases - Optional map of project names to their aliases\n * @returns User-friendly project name with alias support\n *\n * @example\n * ```typescript\n * // Without aliases\n * formatProjectName('-Users-phaedrus-Development-ccusage')\n * // → 'ccusage'\n *\n * // With alias\n * const aliases = new Map([['ccusage', 'Usage Tracker']]);\n * formatProjectName('-Users-phaedrus-Development-ccusage', aliases)\n * // → 'Usage Tracker'\n * ```\n */\nexport function formatProjectName(projectName: string, aliases?: Map<string, string>): string {\n\t// Check for custom alias first\n\tif (aliases != null && aliases.has(projectName)) {\n\t\treturn aliases.get(projectName)!;\n\t}\n\n\t// Parse the project name using improved logic\n\tconst parsed = parseProjectName(projectName);\n\n\t// Check if parsed name has an alias\n\tif (aliases != null && aliases.has(parsed)) {\n\t\treturn aliases.get(parsed)!;\n\t}\n\n\treturn parsed;\n}\n\nif (import.meta.vitest != null) {\n\tconst { describe, it, expect } = import.meta.vitest;\n\n\tdescribe('project name formatting', () => {\n\t\tdescribe('parseProjectName', () => {\n\t\t\tit('handles unknown project names', () => {\n\t\t\t\texpect(formatProjectName('unknown')).toBe('Unknown Project');\n\t\t\t\texpect(formatProjectName('')).toBe('Unknown Project');\n\t\t\t});\n\n\t\t\tit('extracts project names from Unix-style paths', () => {\n\t\t\t\texpect(formatProjectName('-Users-phaedrus-Development-ccusage')).toBe('ccusage');\n\t\t\t\texpect(formatProjectName('/Users/phaedrus/Development/ccusage')).toBe('ccusage');\n\t\t\t});\n\n\t\t\tit('handles complex project names with features', () => {\n\t\t\t\tconst complexName =\n\t\t\t\t\t'-Users-phaedrus-Development-adminifi-edugakko-api--feature-ticket-002-configure-dependabot';\n\t\t\t\tconst result = formatProjectName(complexName);\n\t\t\t\t// Current logic processes the name and extracts meaningful segments\n\t\t\t\texpect(result).toBe('configure-dependabot');\n\t\t\t});\n\n\t\t\tit('handles UUID-based project names', () => {\n\t\t\t\tconst uuidName = 'a2cd99ed-a586-4fe4-8f59-b0026409ec09.jsonl';\n\t\t\t\tconst result = formatProjectName(uuidName);\n\t\t\t\texpect(result).toBe('8f59-b0026409ec09.jsonl');\n\t\t\t});\n\n\t\t\tit('returns original name for simple names', () => {\n\t\t\t\texpect(formatProjectName('simple-project')).toBe('simple-project');\n\t\t\t\texpect(formatProjectName('project')).toBe('project');\n\t\t\t});\n\t\t});\n\n\t\tdescribe('custom aliases', () => {\n\t\t\tit('uses configured aliases', () => {\n\t\t\t\tconst aliases = new Map([\n\t\t\t\t\t['ccusage', 'Usage Tracker'],\n\t\t\t\t\t['test', 'Test Project'],\n\t\t\t\t]);\n\n\t\t\t\texpect(formatProjectName('ccusage', aliases)).toBe('Usage Tracker');\n\t\t\t\texpect(formatProjectName('test', aliases)).toBe('Test Project');\n\t\t\t\texpect(formatProjectName('other', aliases)).toBe('other');\n\t\t\t});\n\n\t\t\tit('applies aliases to parsed project names', () => {\n\t\t\t\tconst aliases = new Map([['ccusage', 'Usage Tracker']]);\n\n\t\t\t\texpect(formatProjectName('-Users-phaedrus-Development-ccusage', aliases)).toBe(\n\t\t\t\t\t'Usage Tracker',\n\t\t\t\t);\n\t\t\t});\n\n\t\t\tit('works without aliases', () => {\n\t\t\t\texpect(formatProjectName('test')).toBe('test');\n\t\t\t\texpect(formatProjectName('test', undefined)).toBe('test');\n\t\t\t\texpect(formatProjectName('test', new Map())).toBe('test');\n\t\t\t});\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_session-blocks.ts",
    "content": "import { uniq } from 'es-toolkit';\nimport { DEFAULT_RECENT_DAYS } from './_consts.ts';\nimport { getTotalTokens } from './_token-utils.ts';\n\n/**\n * Default session duration in hours (Claude's billing block duration)\n */\nexport const DEFAULT_SESSION_DURATION_HOURS = 5;\n\n/**\n * Floors a timestamp to the beginning of the hour in UTC\n * @param timestamp - The timestamp to floor\n * @returns New Date object floored to the UTC hour\n */\nfunction floorToHour(timestamp: Date): Date {\n\tconst floored = new Date(timestamp);\n\tfloored.setUTCMinutes(0, 0, 0);\n\treturn floored;\n}\n\n/**\n * Represents a single usage data entry loaded from JSONL files\n */\nexport type LoadedUsageEntry = {\n\ttimestamp: Date;\n\tusage: {\n\t\tinputTokens: number;\n\t\toutputTokens: number;\n\t\tcacheCreationInputTokens: number;\n\t\tcacheReadInputTokens: number;\n\t};\n\tcostUSD: number | null;\n\tmodel: string;\n\tversion?: string;\n\tusageLimitResetTime?: Date; // Claude API usage limit reset time\n};\n\n/**\n * Aggregated token counts for different token types\n */\ntype TokenCounts = {\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationInputTokens: number;\n\tcacheReadInputTokens: number;\n};\n\n/**\n * Represents a session block (typically 5-hour billing period) with usage data\n */\nexport type SessionBlock = {\n\tid: string; // ISO string of block start time\n\tstartTime: Date;\n\tendTime: Date; // startTime + 5 hours (for normal blocks) or gap end time (for gap blocks)\n\tactualEndTime?: Date; // Last activity in block\n\tisActive: boolean;\n\tisGap?: boolean; // True if this is a gap block\n\tentries: LoadedUsageEntry[];\n\ttokenCounts: TokenCounts;\n\tcostUSD: number;\n\tmodels: string[];\n\tusageLimitResetTime?: Date; // Claude API usage limit reset time\n};\n\n/**\n * Represents usage burn rate calculations\n */\ntype BurnRate = {\n\ttokensPerMinute: number;\n\ttokensPerMinuteForIndicator: number;\n\tcostPerHour: number;\n};\n\n/**\n * Represents projected usage for remaining time in a session block\n */\ntype ProjectedUsage = {\n\ttotalTokens: number;\n\ttotalCost: number;\n\tremainingMinutes: number;\n};\n\n/**\n * Identifies and creates session blocks from usage entries\n * Groups entries into time-based blocks (typically 5-hour periods) with gap detection\n * @param entries - Array of usage entries to process\n * @param sessionDurationHours - Duration of each session block in hours\n * @returns Array of session blocks with aggregated usage data\n */\nexport function identifySessionBlocks(\n\tentries: LoadedUsageEntry[],\n\tsessionDurationHours = DEFAULT_SESSION_DURATION_HOURS,\n): SessionBlock[] {\n\tif (entries.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;\n\tconst blocks: SessionBlock[] = [];\n\tconst sortedEntries = [...entries].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());\n\n\tlet currentBlockStart: Date | null = null;\n\tlet currentBlockEntries: LoadedUsageEntry[] = [];\n\tconst now = new Date();\n\n\tfor (const entry of sortedEntries) {\n\t\tconst entryTime = entry.timestamp;\n\n\t\tif (currentBlockStart == null) {\n\t\t\t// First entry - start a new block (floored to the hour)\n\t\t\tcurrentBlockStart = floorToHour(entryTime);\n\t\t\tcurrentBlockEntries = [entry];\n\t\t} else {\n\t\t\tconst timeSinceBlockStart = entryTime.getTime() - currentBlockStart.getTime();\n\t\t\tconst lastEntry = currentBlockEntries.at(-1);\n\t\t\tif (lastEntry == null) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst lastEntryTime = lastEntry.timestamp;\n\t\t\tconst timeSinceLastEntry = entryTime.getTime() - lastEntryTime.getTime();\n\n\t\t\tif (timeSinceBlockStart > sessionDurationMs || timeSinceLastEntry > sessionDurationMs) {\n\t\t\t\t// Close current block\n\t\t\t\tconst block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs);\n\t\t\t\tblocks.push(block);\n\n\t\t\t\t// Add gap block if there's a significant gap\n\t\t\t\tif (timeSinceLastEntry > sessionDurationMs) {\n\t\t\t\t\tconst gapBlock = createGapBlock(lastEntryTime, entryTime, sessionDurationMs);\n\t\t\t\t\tif (gapBlock != null) {\n\t\t\t\t\t\tblocks.push(gapBlock);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Start new block (floored to the hour)\n\t\t\t\tcurrentBlockStart = floorToHour(entryTime);\n\t\t\t\tcurrentBlockEntries = [entry];\n\t\t\t} else {\n\t\t\t\t// Add to current block\n\t\t\t\tcurrentBlockEntries.push(entry);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Close the last block\n\tif (currentBlockStart != null && currentBlockEntries.length > 0) {\n\t\tconst block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs);\n\t\tblocks.push(block);\n\t}\n\n\treturn blocks;\n}\n\n/**\n * Creates a session block from a start time and usage entries\n * @param startTime - When the block started\n * @param entries - Usage entries in this block\n * @param now - Current time for active block detection\n * @param sessionDurationMs - Session duration in milliseconds\n * @returns Session block with aggregated data\n */\nfunction createBlock(\n\tstartTime: Date,\n\tentries: LoadedUsageEntry[],\n\tnow: Date,\n\tsessionDurationMs: number,\n): SessionBlock {\n\tconst endTime = new Date(startTime.getTime() + sessionDurationMs);\n\tconst lastEntry = entries[entries.length - 1];\n\tconst actualEndTime = lastEntry != null ? lastEntry.timestamp : startTime;\n\tconst isActive = now.getTime() - actualEndTime.getTime() < sessionDurationMs && now < endTime;\n\n\t// Aggregate token counts\n\tconst tokenCounts: TokenCounts = {\n\t\tinputTokens: 0,\n\t\toutputTokens: 0,\n\t\tcacheCreationInputTokens: 0,\n\t\tcacheReadInputTokens: 0,\n\t};\n\n\tlet costUSD = 0;\n\tconst models: string[] = [];\n\tlet usageLimitResetTime: Date | undefined;\n\n\tfor (const entry of entries) {\n\t\ttokenCounts.inputTokens += entry.usage.inputTokens;\n\t\ttokenCounts.outputTokens += entry.usage.outputTokens;\n\t\ttokenCounts.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens;\n\t\ttokenCounts.cacheReadInputTokens += entry.usage.cacheReadInputTokens;\n\t\tcostUSD += entry.costUSD ?? 0;\n\t\tusageLimitResetTime = entry.usageLimitResetTime ?? usageLimitResetTime;\n\t\tmodels.push(entry.model);\n\t}\n\n\treturn {\n\t\tid: startTime.toISOString(),\n\t\tstartTime,\n\t\tendTime,\n\t\tactualEndTime,\n\t\tisActive,\n\t\tentries,\n\t\ttokenCounts,\n\t\tcostUSD,\n\t\tmodels: uniq(models),\n\t\tusageLimitResetTime,\n\t};\n}\n\n/**\n * Creates a gap block representing periods with no activity\n * @param lastActivityTime - Time of last activity before gap\n * @param nextActivityTime - Time of next activity after gap\n * @param sessionDurationMs - Session duration in milliseconds\n * @returns Gap block or null if gap is too short\n */\nfunction createGapBlock(\n\tlastActivityTime: Date,\n\tnextActivityTime: Date,\n\tsessionDurationMs: number,\n): SessionBlock | null {\n\t// Only create gap blocks for gaps longer than the session duration\n\tconst gapDuration = nextActivityTime.getTime() - lastActivityTime.getTime();\n\tif (gapDuration <= sessionDurationMs) {\n\t\treturn null;\n\t}\n\n\tconst gapStart = new Date(lastActivityTime.getTime() + sessionDurationMs);\n\tconst gapEnd = nextActivityTime;\n\n\treturn {\n\t\tid: `gap-${gapStart.toISOString()}`,\n\t\tstartTime: gapStart,\n\t\tendTime: gapEnd,\n\t\tisActive: false,\n\t\tisGap: true,\n\t\tentries: [],\n\t\ttokenCounts: {\n\t\t\tinputTokens: 0,\n\t\t\toutputTokens: 0,\n\t\t\tcacheCreationInputTokens: 0,\n\t\t\tcacheReadInputTokens: 0,\n\t\t},\n\t\tcostUSD: 0,\n\t\tmodels: [],\n\t};\n}\n\n/**\n * Calculates the burn rate (tokens/minute and cost/hour) for a session block\n * @param block - Session block to analyze\n * @returns Burn rate calculations or null if block has no activity\n */\nexport function calculateBurnRate(block: SessionBlock): BurnRate | null {\n\tif (block.entries.length === 0 || (block.isGap ?? false)) {\n\t\treturn null;\n\t}\n\n\tconst firstEntryData = block.entries[0];\n\tconst lastEntryData = block.entries[block.entries.length - 1];\n\tif (firstEntryData == null || lastEntryData == null) {\n\t\treturn null;\n\t}\n\n\tconst firstEntry = firstEntryData.timestamp;\n\tconst lastEntry = lastEntryData.timestamp;\n\tconst durationMinutes = (lastEntry.getTime() - firstEntry.getTime()) / (1000 * 60);\n\n\tif (durationMinutes <= 0) {\n\t\treturn null;\n\t}\n\n\tconst totalTokens = getTotalTokens(block.tokenCounts);\n\tconst tokensPerMinute = totalTokens / durationMinutes;\n\n\t// For burn rate indicator (HIGH/MODERATE/NORMAL), use only input and output tokens\n\t// to maintain consistent thresholds with pre-cache behavior\n\tconst nonCacheTokens =\n\t\t(block.tokenCounts.inputTokens ?? 0) + (block.tokenCounts.outputTokens ?? 0);\n\tconst tokensPerMinuteForIndicator = nonCacheTokens / durationMinutes;\n\n\tconst costPerHour = (block.costUSD / durationMinutes) * 60;\n\n\treturn {\n\t\ttokensPerMinute,\n\t\ttokensPerMinuteForIndicator,\n\t\tcostPerHour,\n\t};\n}\n\n/**\n * Projects total usage for an active session block based on current burn rate\n * @param block - Active session block to project\n * @returns Projected usage totals or null if block is inactive or has no burn rate\n */\nexport function projectBlockUsage(block: SessionBlock): ProjectedUsage | null {\n\tif (!block.isActive || (block.isGap ?? false)) {\n\t\treturn null;\n\t}\n\n\tconst burnRate = calculateBurnRate(block);\n\tif (burnRate == null) {\n\t\treturn null;\n\t}\n\n\tconst now = new Date();\n\tconst remainingTime = block.endTime.getTime() - now.getTime();\n\tconst remainingMinutes = Math.max(0, remainingTime / (1000 * 60));\n\n\tconst currentTokens = getTotalTokens(block.tokenCounts);\n\tconst projectedAdditionalTokens = burnRate.tokensPerMinute * remainingMinutes;\n\tconst totalTokens = currentTokens + projectedAdditionalTokens;\n\n\tconst projectedAdditionalCost = (burnRate.costPerHour / 60) * remainingMinutes;\n\tconst totalCost = block.costUSD + projectedAdditionalCost;\n\n\treturn {\n\t\ttotalTokens: Math.round(totalTokens),\n\t\ttotalCost: Math.round(totalCost * 100) / 100,\n\t\tremainingMinutes: Math.round(remainingMinutes),\n\t};\n}\n\n/**\n * Filters session blocks to include only recent ones and active blocks\n * @param blocks - Array of session blocks to filter\n * @param days - Number of recent days to include (default: 3)\n * @returns Filtered array of recent or active blocks\n */\nexport function filterRecentBlocks(\n\tblocks: SessionBlock[],\n\tdays: number = DEFAULT_RECENT_DAYS,\n): SessionBlock[] {\n\tconst now = new Date();\n\tconst cutoffTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);\n\n\treturn blocks.filter((block) => {\n\t\t// Include block if it started after cutoff or if it's still active\n\t\treturn block.startTime >= cutoffTime || block.isActive;\n\t});\n}\n\nif (import.meta.vitest != null) {\n\tconst SESSION_DURATION_MS = 5 * 60 * 60 * 1000;\n\n\tfunction createMockEntry(\n\t\ttimestamp: Date,\n\t\tinputTokens = 1000,\n\t\toutputTokens = 500,\n\t\tmodel = 'claude-sonnet-4-20250514',\n\t\tcostUSD = 0.01,\n\t): LoadedUsageEntry {\n\t\treturn {\n\t\t\ttimestamp,\n\t\t\tusage: {\n\t\t\t\tinputTokens,\n\t\t\t\toutputTokens,\n\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t},\n\t\t\tcostUSD,\n\t\t\tmodel,\n\t\t};\n\t}\n\n\tdescribe('identifySessionBlocks', () => {\n\t\tit('returns empty array for empty entries', () => {\n\t\t\tconst result = identifySessionBlocks([]);\n\t\t\texpect(result).toEqual([]);\n\t\t});\n\n\t\tit('creates single block for entries within 5 hours', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000)), // 1 hour later\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks).toHaveLength(1);\n\t\t\texpect(blocks[0]?.startTime).toEqual(baseTime);\n\t\t\texpect(blocks[0]?.entries).toHaveLength(3);\n\t\t\texpect(blocks[0]?.tokenCounts.inputTokens).toBe(3000);\n\t\t\texpect(blocks[0]?.tokenCounts.outputTokens).toBe(1500);\n\t\t\texpect(blocks[0]?.costUSD).toBe(0.03);\n\t\t});\n\n\t\tit('creates multiple blocks when entries span more than 5 hours', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 6 * 60 * 60 * 1000)), // 6 hours later\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks).toHaveLength(3); // first block, gap block, second block\n\t\t\texpect(blocks[0]?.entries).toHaveLength(1);\n\t\t\texpect(blocks[1]?.isGap).toBe(true); // gap block\n\t\t\texpect(blocks[2]?.entries).toHaveLength(1);\n\t\t});\n\n\t\tit('creates gap block when there is a gap longer than 5 hours', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 8 * 60 * 60 * 1000)), // 8 hours later\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks).toHaveLength(3); // first block, gap block, second block\n\t\t\texpect(blocks[0]?.entries).toHaveLength(2);\n\t\t\texpect(blocks[1]?.isGap).toBe(true);\n\t\t\texpect(blocks[1]?.entries).toHaveLength(0);\n\t\t\texpect(blocks[2]?.entries).toHaveLength(1);\n\t\t});\n\n\t\tit('sorts entries by timestamp before processing', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later\n\t\t\t\tcreateMockEntry(baseTime), // earlier\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 1 * 60 * 60 * 1000)), // 1 hour later\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks).toHaveLength(1);\n\t\t\texpect(blocks[0]?.entries[0]?.timestamp).toEqual(baseTime);\n\t\t\texpect(blocks[0]?.entries[1]?.timestamp).toEqual(\n\t\t\t\tnew Date(baseTime.getTime() + 1 * 60 * 60 * 1000),\n\t\t\t);\n\t\t\texpect(blocks[0]?.entries[2]?.timestamp).toEqual(\n\t\t\t\tnew Date(baseTime.getTime() + 2 * 60 * 60 * 1000),\n\t\t\t);\n\t\t});\n\n\t\tit('aggregates different models correctly', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514'),\n\t\t\t\tcreateMockEntry(\n\t\t\t\t\tnew Date(baseTime.getTime() + 60 * 60 * 1000),\n\t\t\t\t\t2000,\n\t\t\t\t\t1000,\n\t\t\t\t\t'claude-opus-4-20250514',\n\t\t\t\t),\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks).toHaveLength(1);\n\t\t\texpect(blocks[0]?.models).toEqual(['claude-sonnet-4-20250514', 'claude-opus-4-20250514']);\n\t\t});\n\n\t\tit('handles null costUSD correctly', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514', 0.01),\n\t\t\t\t{ ...createMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000)), costUSD: null },\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks).toHaveLength(1);\n\t\t\texpect(blocks[0]?.costUSD).toBe(0.01); // Only the first entry's cost\n\t\t});\n\n\t\tit('sets correct block ID as ISO string', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [createMockEntry(baseTime)];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks[0]?.id).toBe(baseTime.toISOString());\n\t\t});\n\n\t\tit('sets correct endTime as startTime + 5 hours', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [createMockEntry(baseTime)];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + SESSION_DURATION_MS));\n\t\t});\n\n\t\tit('handles cache tokens correctly', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entry: LoadedUsageEntry = {\n\t\t\t\ttimestamp: baseTime,\n\t\t\t\tusage: {\n\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\tcacheCreationInputTokens: 100,\n\t\t\t\t\tcacheReadInputTokens: 200,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.01,\n\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t};\n\n\t\t\tconst blocks = identifySessionBlocks([entry]);\n\t\t\texpect(blocks[0]?.tokenCounts.cacheCreationInputTokens).toBe(100);\n\t\t\texpect(blocks[0]?.tokenCounts.cacheReadInputTokens).toBe(200);\n\t\t});\n\n\t\tit('floors block start time to nearest hour', () => {\n\t\t\tconst entryTime = new Date('2024-01-01T10:55:30Z'); // 10:55:30 AM\n\t\t\tconst expectedStartTime = new Date('2024-01-01T10:00:00Z'); // Should floor to 10:00:00 AM\n\t\t\tconst entries: LoadedUsageEntry[] = [createMockEntry(entryTime)];\n\n\t\t\tconst blocks = identifySessionBlocks(entries);\n\t\t\texpect(blocks).toHaveLength(1);\n\t\t\texpect(blocks[0]?.startTime).toEqual(expectedStartTime);\n\t\t\texpect(blocks[0]?.id).toBe(expectedStartTime.toISOString());\n\t\t});\n\t});\n\n\tdescribe('calculateBurnRate', () => {\n\t\tit('returns null for empty entries', () => {\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: '2024-01-01T10:00:00.000Z',\n\t\t\t\tstartTime: new Date('2024-01-01T10:00:00Z'),\n\t\t\t\tendTime: new Date('2024-01-01T15:00:00Z'),\n\t\t\t\tisActive: true,\n\t\t\t\tentries: [],\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0,\n\t\t\t\tmodels: [],\n\t\t\t};\n\n\t\t\tconst result = calculateBurnRate(block);\n\t\t\texpect(result).toBeNull();\n\t\t});\n\n\t\tit('returns null for gap blocks', () => {\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: 'gap-2024-01-01T10:00:00.000Z',\n\t\t\t\tstartTime: new Date('2024-01-01T10:00:00Z'),\n\t\t\t\tendTime: new Date('2024-01-01T15:00:00Z'),\n\t\t\t\tisActive: false,\n\t\t\t\tisGap: true,\n\t\t\t\tentries: [],\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0,\n\t\t\t\tmodels: [],\n\t\t\t};\n\n\t\t\tconst result = calculateBurnRate(block);\n\t\t\texpect(result).toBeNull();\n\t\t});\n\n\t\tit('returns null when duration is zero or negative', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: baseTime.toISOString(),\n\t\t\t\tstartTime: baseTime,\n\t\t\t\tendTime: new Date(baseTime.getTime() + SESSION_DURATION_MS),\n\t\t\t\tisActive: true,\n\t\t\t\tentries: [\n\t\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\t\tcreateMockEntry(baseTime), // Same timestamp\n\t\t\t\t],\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 2000,\n\t\t\t\t\toutputTokens: 1000,\n\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.02,\n\t\t\t\tmodels: ['claude-sonnet-4-20250514'],\n\t\t\t};\n\n\t\t\tconst result = calculateBurnRate(block);\n\t\t\texpect(result).toBeNull();\n\t\t});\n\n\t\tit('calculates burn rate correctly', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst laterTime = new Date(baseTime.getTime() + 60 * 1000); // 1 minute later\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: baseTime.toISOString(),\n\t\t\t\tstartTime: baseTime,\n\t\t\t\tendTime: new Date(baseTime.getTime() + SESSION_DURATION_MS),\n\t\t\t\tisActive: true,\n\t\t\t\tentries: [\n\t\t\t\t\tcreateMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514', 0.01),\n\t\t\t\t\tcreateMockEntry(laterTime, 2000, 1000, 'claude-sonnet-4-20250514', 0.02),\n\t\t\t\t],\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 3000,\n\t\t\t\t\toutputTokens: 1500,\n\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.03,\n\t\t\t\tmodels: ['claude-sonnet-4-20250514'],\n\t\t\t};\n\n\t\t\tconst result = calculateBurnRate(block);\n\t\t\texpect(result).not.toBeNull();\n\t\t\texpect(result?.tokensPerMinute).toBe(4500); // 4500 tokens / 1 minute (includes all tokens)\n\t\t\texpect(result?.tokensPerMinuteForIndicator).toBe(4500); // 4500 tokens / 1 minute (non-cache only)\n\t\t\texpect(result?.costPerHour).toBeCloseTo(1.8, 2); // 0.03 / 1 minute * 60 minutes\n\t\t});\n\n\t\tit('correctly separates cache and non-cache tokens in burn rate calculation', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: baseTime.toISOString(),\n\t\t\t\tstartTime: baseTime,\n\t\t\t\tendTime: new Date(baseTime.getTime() + SESSION_DURATION_MS),\n\t\t\t\tisActive: true,\n\t\t\t\tentries: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttimestamp: baseTime,\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\ttimestamp: new Date(baseTime.getTime() + 60 * 1000),\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinputTokens: 500,\n\t\t\t\t\t\t\toutputTokens: 200,\n\t\t\t\t\t\t\tcacheCreationInputTokens: 2000,\n\t\t\t\t\t\t\tcacheReadInputTokens: 8000,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 1500,\n\t\t\t\t\toutputTokens: 700,\n\t\t\t\t\tcacheCreationInputTokens: 2000,\n\t\t\t\t\tcacheReadInputTokens: 8000,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.03,\n\t\t\t\tmodels: ['claude-sonnet-4-20250514'],\n\t\t\t};\n\n\t\t\tconst result = calculateBurnRate(block);\n\t\t\texpect(result).not.toBeNull();\n\t\t\texpect(result?.tokensPerMinute).toBe(12200); // 1500 + 700 + 2000 + 8000 = 12200 tokens / 1 minute\n\t\t\texpect(result?.tokensPerMinuteForIndicator).toBe(2200); // 1500 + 700 = 2200 tokens / 1 minute (non-cache only)\n\t\t\texpect(result?.costPerHour).toBeCloseTo(1.8, 2); // 0.03 / 1 minute * 60 minutes\n\t\t});\n\t});\n\n\tdescribe('projectBlockUsage', () => {\n\t\tit('returns null for inactive blocks', () => {\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: '2024-01-01T10:00:00.000Z',\n\t\t\t\tstartTime: new Date('2024-01-01T10:00:00Z'),\n\t\t\t\tendTime: new Date('2024-01-01T15:00:00Z'),\n\t\t\t\tisActive: false,\n\t\t\t\tentries: [],\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.01,\n\t\t\t\tmodels: [],\n\t\t\t};\n\n\t\t\tconst result = projectBlockUsage(block);\n\t\t\texpect(result).toBeNull();\n\t\t});\n\n\t\tit('returns null for gap blocks', () => {\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: 'gap-2024-01-01T10:00:00.000Z',\n\t\t\t\tstartTime: new Date('2024-01-01T10:00:00Z'),\n\t\t\t\tendTime: new Date('2024-01-01T15:00:00Z'),\n\t\t\t\tisActive: true,\n\t\t\t\tisGap: true,\n\t\t\t\tentries: [],\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0,\n\t\t\t\tmodels: [],\n\t\t\t};\n\n\t\t\tconst result = projectBlockUsage(block);\n\t\t\texpect(result).toBeNull();\n\t\t});\n\n\t\tit('returns null when burn rate cannot be calculated', () => {\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: '2024-01-01T10:00:00.000Z',\n\t\t\t\tstartTime: new Date('2024-01-01T10:00:00Z'),\n\t\t\t\tendTime: new Date('2024-01-01T15:00:00Z'),\n\t\t\t\tisActive: true,\n\t\t\t\tentries: [], // Empty entries\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.01,\n\t\t\t\tmodels: [],\n\t\t\t};\n\n\t\t\tconst result = projectBlockUsage(block);\n\t\t\texpect(result).toBeNull();\n\t\t});\n\n\t\tit('projects usage correctly for active block', () => {\n\t\t\tconst now = new Date();\n\t\t\tconst startTime = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago\n\t\t\tconst endTime = new Date(startTime.getTime() + SESSION_DURATION_MS);\n\t\t\tconst pastTime = new Date(startTime.getTime() + 30 * 60 * 1000); // 30 minutes after start\n\n\t\t\tconst block: SessionBlock = {\n\t\t\t\tid: startTime.toISOString(),\n\t\t\t\tstartTime,\n\t\t\t\tendTime,\n\t\t\t\tisActive: true,\n\t\t\t\tentries: [\n\t\t\t\t\tcreateMockEntry(startTime, 1000, 500, 'claude-sonnet-4-20250514', 0.01),\n\t\t\t\t\tcreateMockEntry(pastTime, 2000, 1000, 'claude-sonnet-4-20250514', 0.02),\n\t\t\t\t],\n\t\t\t\ttokenCounts: {\n\t\t\t\t\tinputTokens: 3000,\n\t\t\t\t\toutputTokens: 1500,\n\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.03,\n\t\t\t\tmodels: ['claude-sonnet-4-20250514'],\n\t\t\t};\n\n\t\t\tconst result = projectBlockUsage(block);\n\t\t\texpect(result).not.toBeNull();\n\t\t\texpect(result?.totalTokens).toBeGreaterThan(4500); // Current tokens + projected\n\t\t\texpect(result?.totalCost).toBeGreaterThan(0.03); // Current cost + projected\n\t\t\texpect(result?.remainingMinutes).toBeGreaterThan(0);\n\t\t});\n\t});\n\n\tdescribe('filterRecentBlocks', () => {\n\t\tit('filters blocks correctly with default 3 days', () => {\n\t\t\tconst now = new Date();\n\t\t\tconst recentTime = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); // 2 days ago\n\t\t\tconst oldTime = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago\n\n\t\t\tconst blocks: SessionBlock[] = [\n\t\t\t\t{\n\t\t\t\t\tid: recentTime.toISOString(),\n\t\t\t\t\tstartTime: recentTime,\n\t\t\t\t\tendTime: new Date(recentTime.getTime() + SESSION_DURATION_MS),\n\t\t\t\t\tisActive: false,\n\t\t\t\t\tentries: [],\n\t\t\t\t\ttokenCounts: {\n\t\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\tmodels: [],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tid: oldTime.toISOString(),\n\t\t\t\t\tstartTime: oldTime,\n\t\t\t\t\tendTime: new Date(oldTime.getTime() + SESSION_DURATION_MS),\n\t\t\t\t\tisActive: false,\n\t\t\t\t\tentries: [],\n\t\t\t\t\ttokenCounts: {\n\t\t\t\t\t\tinputTokens: 2000,\n\t\t\t\t\t\toutputTokens: 1000,\n\t\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t\tmodels: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst result = filterRecentBlocks(blocks);\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.startTime).toEqual(recentTime);\n\t\t});\n\n\t\tit('includes active blocks regardless of age', () => {\n\t\t\tconst now = new Date();\n\t\t\tconst oldTime = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); // 10 days ago\n\n\t\t\tconst blocks: SessionBlock[] = [\n\t\t\t\t{\n\t\t\t\t\tid: oldTime.toISOString(),\n\t\t\t\t\tstartTime: oldTime,\n\t\t\t\t\tendTime: new Date(oldTime.getTime() + SESSION_DURATION_MS),\n\t\t\t\t\tisActive: true, // Active block\n\t\t\t\t\tentries: [],\n\t\t\t\t\ttokenCounts: {\n\t\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\tmodels: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst result = filterRecentBlocks(blocks);\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.isActive).toBe(true);\n\t\t});\n\n\t\tit('supports custom days parameter', () => {\n\t\t\tconst now = new Date();\n\t\t\tconst withinCustomRange = new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000); // 4 days ago\n\t\t\tconst outsideCustomRange = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000); // 8 days ago\n\n\t\t\tconst blocks: SessionBlock[] = [\n\t\t\t\t{\n\t\t\t\t\tid: withinCustomRange.toISOString(),\n\t\t\t\t\tstartTime: withinCustomRange,\n\t\t\t\t\tendTime: new Date(withinCustomRange.getTime() + SESSION_DURATION_MS),\n\t\t\t\t\tisActive: false,\n\t\t\t\t\tentries: [],\n\t\t\t\t\ttokenCounts: {\n\t\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\tmodels: [],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tid: outsideCustomRange.toISOString(),\n\t\t\t\t\tstartTime: outsideCustomRange,\n\t\t\t\t\tendTime: new Date(outsideCustomRange.getTime() + SESSION_DURATION_MS),\n\t\t\t\t\tisActive: false,\n\t\t\t\t\tentries: [],\n\t\t\t\t\ttokenCounts: {\n\t\t\t\t\t\tinputTokens: 2000,\n\t\t\t\t\t\toutputTokens: 1000,\n\t\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t\tmodels: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst result = filterRecentBlocks(blocks, 7); // 7 days\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.startTime).toEqual(withinCustomRange);\n\t\t});\n\n\t\tit('returns empty array when no blocks match criteria', () => {\n\t\t\tconst now = new Date();\n\t\t\tconst oldTime = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); // 10 days ago\n\n\t\t\tconst blocks: SessionBlock[] = [\n\t\t\t\t{\n\t\t\t\t\tid: oldTime.toISOString(),\n\t\t\t\t\tstartTime: oldTime,\n\t\t\t\t\tendTime: new Date(oldTime.getTime() + SESSION_DURATION_MS),\n\t\t\t\t\tisActive: false,\n\t\t\t\t\tentries: [],\n\t\t\t\t\ttokenCounts: {\n\t\t\t\t\t\tinputTokens: 1000,\n\t\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\tmodels: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst result = filterRecentBlocks(blocks, 3);\n\t\t\texpect(result).toHaveLength(0);\n\t\t});\n\t});\n\n\tdescribe('identifySessionBlocks with configurable duration', () => {\n\t\tit('creates single block for entries within custom 3-hour duration', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000)), // 1 hour later\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries, 3);\n\t\t\texpect(blocks).toHaveLength(1);\n\t\t\texpect(blocks[0]?.startTime).toEqual(baseTime);\n\t\t\texpect(blocks[0]?.entries).toHaveLength(3);\n\t\t\texpect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 3 * 60 * 60 * 1000));\n\t\t});\n\n\t\tit('creates multiple blocks with custom 2-hour duration', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 3 * 60 * 60 * 1000)), // 3 hours later (beyond 2h limit)\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries, 2);\n\t\t\texpect(blocks).toHaveLength(3); // first block, gap block, second block\n\t\t\texpect(blocks[0]?.entries).toHaveLength(1);\n\t\t\texpect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000));\n\t\t\texpect(blocks[1]?.isGap).toBe(true); // gap block\n\t\t\texpect(blocks[2]?.entries).toHaveLength(1);\n\t\t});\n\n\t\tit('creates gap block with custom 1-hour duration', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 30 * 60 * 1000)), // 30 minutes later (within 1h)\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later (beyond 1h)\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries, 1);\n\t\t\texpect(blocks).toHaveLength(3); // first block, gap block, second block\n\t\t\texpect(blocks[0]?.entries).toHaveLength(2);\n\t\t\texpect(blocks[1]?.isGap).toBe(true);\n\t\t\texpect(blocks[2]?.entries).toHaveLength(1);\n\t\t});\n\n\t\tit('works with fractional hours (2.5 hours)', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later (within 2.5h)\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 6 * 60 * 60 * 1000)), // 6 hours later (4 hours from last entry, beyond 2.5h)\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries, 2.5);\n\t\t\texpect(blocks).toHaveLength(3); // first block, gap block, second block\n\t\t\texpect(blocks[0]?.entries).toHaveLength(2);\n\t\t\texpect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 2.5 * 60 * 60 * 1000));\n\t\t\texpect(blocks[1]?.isGap).toBe(true);\n\t\t\texpect(blocks[2]?.entries).toHaveLength(1);\n\t\t});\n\n\t\tit('works with very short duration (0.5 hours)', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 20 * 60 * 1000)), // 20 minutes later (within 0.5h)\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 80 * 60 * 1000)), // 80 minutes later (60 minutes from last entry, beyond 0.5h)\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries, 0.5);\n\t\t\texpect(blocks).toHaveLength(3); // first block, gap block, second block\n\t\t\texpect(blocks[0]?.entries).toHaveLength(2);\n\t\t\texpect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 0.5 * 60 * 60 * 1000));\n\t\t\texpect(blocks[1]?.isGap).toBe(true);\n\t\t\texpect(blocks[2]?.entries).toHaveLength(1);\n\t\t});\n\n\t\tit('works with very long duration (24 hours)', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 12 * 60 * 60 * 1000)), // 12 hours later (within 24h)\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 20 * 60 * 60 * 1000)), // 20 hours later (within 24h)\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries, 24);\n\t\t\texpect(blocks).toHaveLength(1); // single block\n\t\t\texpect(blocks[0]?.entries).toHaveLength(3);\n\t\t\texpect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 24 * 60 * 60 * 1000));\n\t\t});\n\n\t\tit('gap detection respects custom duration', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 1 * 60 * 60 * 1000)), // 1 hour later\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)), // 5 hours later (4h from last entry, beyond 3h)\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries, 3);\n\t\t\texpect(blocks).toHaveLength(3); // first block, gap block, second block\n\n\t\t\t// Gap block should start 3 hours after last activity in first block\n\t\t\tconst gapBlock = blocks[1];\n\t\t\texpect(gapBlock?.isGap).toBe(true);\n\t\t\texpect(gapBlock?.startTime).toEqual(\n\t\t\t\tnew Date(baseTime.getTime() + 1 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000),\n\t\t\t); // 1h + 3h\n\t\t\texpect(gapBlock?.endTime).toEqual(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)); // 5h\n\t\t});\n\n\t\tit('no gap created when gap is exactly equal to session duration', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [\n\t\t\t\tcreateMockEntry(baseTime),\n\t\t\t\tcreateMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // exactly 2 hours later (equal to session duration)\n\t\t\t];\n\n\t\t\tconst blocks = identifySessionBlocks(entries, 2);\n\t\t\texpect(blocks).toHaveLength(1); // single block (entries are exactly at session boundary)\n\t\t\texpect(blocks[0]?.entries).toHaveLength(2);\n\t\t});\n\n\t\tit('defaults to 5 hours when no duration specified', () => {\n\t\t\tconst baseTime = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst entries: LoadedUsageEntry[] = [createMockEntry(baseTime)];\n\n\t\t\tconst blocksDefault = identifySessionBlocks(entries);\n\t\t\tconst blocksExplicit = identifySessionBlocks(entries, 5);\n\n\t\t\texpect(blocksDefault).toHaveLength(1);\n\t\t\texpect(blocksExplicit).toHaveLength(1);\n\t\t\texpect(blocksDefault[0]!.endTime).toEqual(blocksExplicit[0]!.endTime);\n\t\t\texpect(blocksDefault[0]!.endTime).toEqual(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000));\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_shared-args.ts",
    "content": "import type { Args } from 'gunshi';\nimport type { CostMode, SortOrder } from './_types.ts';\nimport * as v from 'valibot';\nimport { DEFAULT_LOCALE } from './_consts.ts';\nimport { CostModes, filterDateSchema, SortOrders } from './_types.ts';\n\n/**\n * Parses and validates a date argument in YYYYMMDD format\n * @param value - Date string to parse\n * @returns Validated date string\n */\nfunction parseDateArg(value: string): string {\n\treturn v.parse(filterDateSchema, value);\n}\n\n/**\n * Shared command line arguments used across multiple CLI commands\n */\nexport const sharedArgs = {\n\tsince: {\n\t\ttype: 'custom',\n\t\tshort: 's',\n\t\tdescription: 'Filter from date (YYYYMMDD format)',\n\t\tparse: parseDateArg,\n\t},\n\tuntil: {\n\t\ttype: 'custom',\n\t\tshort: 'u',\n\t\tdescription: 'Filter until date (YYYYMMDD format)',\n\t\tparse: parseDateArg,\n\t},\n\tjson: {\n\t\ttype: 'boolean',\n\t\tshort: 'j',\n\t\tdescription: 'Output in JSON format',\n\t\tdefault: false,\n\t},\n\tmode: {\n\t\ttype: 'enum',\n\t\tshort: 'm',\n\t\tdescription:\n\t\t\t'Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)',\n\t\tdefault: 'auto' as const satisfies CostMode,\n\t\tchoices: CostModes,\n\t},\n\tdebug: {\n\t\ttype: 'boolean',\n\t\tshort: 'd',\n\t\tdescription: 'Show pricing mismatch information for debugging',\n\t\tdefault: false,\n\t},\n\tdebugSamples: {\n\t\ttype: 'number',\n\t\tdescription: 'Number of sample discrepancies to show in debug output (default: 5)',\n\t\tdefault: 5,\n\t},\n\torder: {\n\t\ttype: 'enum',\n\t\tshort: 'o',\n\t\tdescription: 'Sort order: desc (newest first) or asc (oldest first)',\n\t\tdefault: 'asc' as const satisfies SortOrder,\n\t\tchoices: SortOrders,\n\t},\n\tbreakdown: {\n\t\ttype: 'boolean',\n\t\tshort: 'b',\n\t\tdescription: 'Show per-model cost breakdown',\n\t\tdefault: false,\n\t},\n\toffline: {\n\t\ttype: 'boolean',\n\t\tnegatable: true,\n\t\tshort: 'O',\n\t\tdescription: 'Use cached pricing data for Claude models instead of fetching from API',\n\t\tdefault: false,\n\t},\n\tcolor: {\n\t\t// --color and FORCE_COLOR=1 is handled by picocolors\n\t\ttype: 'boolean',\n\t\tdescription: 'Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.',\n\t},\n\tnoColor: {\n\t\t// --no-color and NO_COLOR=1 is handled by picocolors\n\t\ttype: 'boolean',\n\t\tdescription: 'Disable colored output (default: auto). NO_COLOR=1 has the same effect.',\n\t},\n\ttimezone: {\n\t\ttype: 'string',\n\t\tshort: 'z',\n\t\tdescription:\n\t\t\t'Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone',\n\t},\n\tlocale: {\n\t\ttype: 'string',\n\t\tshort: 'l',\n\t\tdescription: 'Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)',\n\t\tdefault: DEFAULT_LOCALE,\n\t},\n\tjq: {\n\t\ttype: 'string',\n\t\tshort: 'q',\n\t\tdescription: 'Process JSON output with jq command (requires jq binary, implies --json)',\n\t},\n\tconfig: {\n\t\ttype: 'string',\n\t\tdescription: 'Path to configuration file (default: auto-discovery)',\n\t},\n\tcompact: {\n\t\ttype: 'boolean',\n\t\tdescription: 'Force compact mode for narrow displays (better for screenshots)',\n\t\tdefault: false,\n\t},\n} as const satisfies Args;\n\n/**\n * Shared command configuration for Gunshi CLI commands\n */\nexport const sharedCommandConfig = {\n\targs: sharedArgs,\n\ttoKebab: true,\n} as const;\n"
  },
  {
    "path": "apps/ccusage/src/_token-utils.ts",
    "content": "/**\n * @fileoverview Token calculation utilities\n *\n * This module provides shared utilities for calculating token totals\n * across different token types. Used throughout the application to\n * ensure consistent token counting logic.\n */\n\n/**\n * Token counts structure for raw usage data (uses InputTokens suffix)\n */\nexport type TokenCounts = {\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationInputTokens: number;\n\tcacheReadInputTokens: number;\n};\n\n/**\n * Token counts structure for aggregated data (uses shorter names)\n */\nexport type AggregatedTokenCounts = {\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n};\n\n/**\n * Union type that supports both token count formats\n */\nexport type AnyTokenCounts = TokenCounts | AggregatedTokenCounts;\n\n/**\n * Calculates the total number of tokens across all token types\n * Supports both raw usage data format and aggregated data format\n * @param tokenCounts - Object containing counts for each token type\n * @returns Total number of tokens\n */\nexport function getTotalTokens(tokenCounts: AnyTokenCounts): number {\n\t// Support both property naming conventions\n\tconst cacheCreation =\n\t\t'cacheCreationInputTokens' in tokenCounts\n\t\t\t? tokenCounts.cacheCreationInputTokens\n\t\t\t: tokenCounts.cacheCreationTokens;\n\n\tconst cacheRead =\n\t\t'cacheReadInputTokens' in tokenCounts\n\t\t\t? tokenCounts.cacheReadInputTokens\n\t\t\t: tokenCounts.cacheReadTokens;\n\n\treturn tokenCounts.inputTokens + tokenCounts.outputTokens + cacheCreation + cacheRead;\n}\n\n// In-source testing\nif (import.meta.vitest != null) {\n\tdescribe('getTotalTokens', () => {\n\t\tit('should sum all token types correctly (raw format)', () => {\n\t\t\tconst tokens: TokenCounts = {\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t\tcacheCreationInputTokens: 2000,\n\t\t\t\tcacheReadInputTokens: 300,\n\t\t\t};\n\t\t\texpect(getTotalTokens(tokens)).toBe(3800);\n\t\t});\n\n\t\tit('should sum all token types correctly (aggregated format)', () => {\n\t\t\tconst tokens: AggregatedTokenCounts = {\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t\tcacheCreationTokens: 2000,\n\t\t\t\tcacheReadTokens: 300,\n\t\t\t};\n\t\t\texpect(getTotalTokens(tokens)).toBe(3800);\n\t\t});\n\n\t\tit('should handle zero values (raw format)', () => {\n\t\t\tconst tokens: TokenCounts = {\n\t\t\t\tinputTokens: 0,\n\t\t\t\toutputTokens: 0,\n\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t};\n\t\t\texpect(getTotalTokens(tokens)).toBe(0);\n\t\t});\n\n\t\tit('should handle zero values (aggregated format)', () => {\n\t\t\tconst tokens: AggregatedTokenCounts = {\n\t\t\t\tinputTokens: 0,\n\t\t\t\toutputTokens: 0,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t};\n\t\t\texpect(getTotalTokens(tokens)).toBe(0);\n\t\t});\n\n\t\tit('should handle missing cache tokens (raw format)', () => {\n\t\t\tconst tokens: TokenCounts = {\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t\tcacheCreationInputTokens: 0,\n\t\t\t\tcacheReadInputTokens: 0,\n\t\t\t};\n\t\t\texpect(getTotalTokens(tokens)).toBe(1500);\n\t\t});\n\n\t\tit('should handle missing cache tokens (aggregated format)', () => {\n\t\t\tconst tokens: AggregatedTokenCounts = {\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t};\n\t\t\texpect(getTotalTokens(tokens)).toBe(1500);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/_types.ts",
    "content": "import type { TupleToUnion } from 'type-fest';\nimport * as v from 'valibot';\n\n/**\n * Branded Valibot schemas for type safety using brand markers.\n */\n\n// Core identifier schemas\nexport const modelNameSchema = v.pipe(\n\tv.string(),\n\tv.minLength(1, 'Model name cannot be empty'),\n\tv.brand('ModelName'),\n);\n\nexport const sessionIdSchema = v.pipe(\n\tv.string(),\n\tv.minLength(1, 'Session ID cannot be empty'),\n\tv.brand('SessionId'),\n);\n\nexport const requestIdSchema = v.pipe(\n\tv.string(),\n\tv.minLength(1, 'Request ID cannot be empty'),\n\tv.brand('RequestId'),\n);\n\nexport const messageIdSchema = v.pipe(\n\tv.string(),\n\tv.minLength(1, 'Message ID cannot be empty'),\n\tv.brand('MessageId'),\n);\n\n// Date and timestamp schemas\nconst isoTimestampRegex = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?Z$/;\nexport const isoTimestampSchema = v.pipe(\n\tv.string(),\n\tv.regex(isoTimestampRegex, 'Invalid ISO timestamp'),\n\tv.brand('ISOTimestamp'),\n);\n\nconst yyyymmddRegex = /^\\d{4}-\\d{2}-\\d{2}$/;\nexport const dailyDateSchema = v.pipe(\n\tv.string(),\n\tv.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'),\n\tv.brand('DailyDate'),\n);\n\nexport const activityDateSchema = v.pipe(\n\tv.string(),\n\tv.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'),\n\tv.brand('ActivityDate'),\n);\n\nconst yyyymmRegex = /^\\d{4}-\\d{2}$/;\nexport const monthlyDateSchema = v.pipe(\n\tv.string(),\n\tv.regex(yyyymmRegex, 'Date must be in YYYY-MM format'),\n\tv.brand('MonthlyDate'),\n);\n\nexport const weeklyDateSchema = v.pipe(\n\tv.string(),\n\tv.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'),\n\tv.brand('WeeklyDate'),\n);\n\nconst filterDateRegex = /^\\d{8}$/;\nexport const filterDateSchema = v.pipe(\n\tv.string(),\n\tv.regex(filterDateRegex, 'Date must be in YYYYMMDD format'),\n\tv.brand('FilterDate'),\n);\n\n// Other domain-specific schemas\nexport const projectPathSchema = v.pipe(\n\tv.string(),\n\tv.minLength(1, 'Project path cannot be empty'),\n\tv.brand('ProjectPath'),\n);\n\nconst versionRegex = /^\\d+\\.\\d+\\.\\d+/;\nexport const versionSchema = v.pipe(\n\tv.string(),\n\tv.regex(versionRegex, 'Invalid version format'),\n\tv.brand('Version'),\n);\n\n/**\n * Inferred branded types from schemas\n */\nexport type ModelName = v.InferOutput<typeof modelNameSchema>;\nexport type SessionId = v.InferOutput<typeof sessionIdSchema>;\nexport type RequestId = v.InferOutput<typeof requestIdSchema>;\nexport type MessageId = v.InferOutput<typeof messageIdSchema>;\nexport type ISOTimestamp = v.InferOutput<typeof isoTimestampSchema>;\nexport type DailyDate = v.InferOutput<typeof dailyDateSchema>;\nexport type ActivityDate = v.InferOutput<typeof activityDateSchema>;\nexport type MonthlyDate = v.InferOutput<typeof monthlyDateSchema>;\nexport type WeeklyDate = v.InferOutput<typeof weeklyDateSchema>;\nexport type Bucket = MonthlyDate | WeeklyDate;\nexport type FilterDate = v.InferOutput<typeof filterDateSchema>;\nexport type ProjectPath = v.InferOutput<typeof projectPathSchema>;\nexport type Version = v.InferOutput<typeof versionSchema>;\n\n/**\n * Helper functions to create branded values by parsing and validating input strings\n * These functions should be used when converting plain strings to branded types\n */\nexport const createModelName = (value: string): ModelName => v.parse(modelNameSchema, value);\nexport const createSessionId = (value: string): SessionId => v.parse(sessionIdSchema, value);\nexport const createRequestId = (value: string): RequestId => v.parse(requestIdSchema, value);\nexport const createMessageId = (value: string): MessageId => v.parse(messageIdSchema, value);\nexport function createISOTimestamp(value: string): ISOTimestamp {\n\treturn v.parse(isoTimestampSchema, value);\n}\nexport const createDailyDate = (value: string): DailyDate => v.parse(dailyDateSchema, value);\nexport function createActivityDate(value: string): ActivityDate {\n\treturn v.parse(activityDateSchema, value);\n}\nexport const createMonthlyDate = (value: string): MonthlyDate => v.parse(monthlyDateSchema, value);\nexport const createWeeklyDate = (value: string): WeeklyDate => v.parse(weeklyDateSchema, value);\nexport const createFilterDate = (value: string): FilterDate => v.parse(filterDateSchema, value);\nexport const createProjectPath = (value: string): ProjectPath => v.parse(projectPathSchema, value);\nexport const createVersion = (value: string): Version => v.parse(versionSchema, value);\n\nexport function createBucket(value: string): Bucket {\n\tconst weeklyResult = v.safeParse(weeklyDateSchema, value);\n\tif (weeklyResult.success) {\n\t\treturn weeklyResult.output;\n\t}\n\treturn createMonthlyDate(value);\n}\n\n/**\n * Available cost calculation modes\n * - auto: Use pre-calculated costs when available, otherwise calculate from tokens\n * - calculate: Always calculate costs from token counts using model pricing\n * - display: Always use pre-calculated costs, show 0 for missing costs\n */\nexport const CostModes = ['auto', 'calculate', 'display'] as const;\n\n/**\n * Union type for cost calculation modes\n */\nexport type CostMode = TupleToUnion<typeof CostModes>;\n\n/**\n * Available sort orders for data presentation\n */\nexport const SortOrders = ['desc', 'asc'] as const;\n\n/**\n * Union type for sort order options\n */\nexport type SortOrder = TupleToUnion<typeof SortOrders>;\n\n/**\n * Valibot schema for Claude Code statusline hook JSON data\n */\nexport const statuslineHookJsonSchema = v.object({\n\tsession_id: v.string(),\n\ttranscript_path: v.string(),\n\tcwd: v.string(),\n\tmodel: v.object({\n\t\tid: v.string(),\n\t\tdisplay_name: v.string(),\n\t}),\n\tworkspace: v.object({\n\t\tcurrent_dir: v.string(),\n\t\tproject_dir: v.string(),\n\t}),\n\tversion: v.optional(v.string()),\n\tcost: v.optional(\n\t\tv.object({\n\t\t\ttotal_cost_usd: v.number(),\n\t\t\ttotal_duration_ms: v.optional(v.number()),\n\t\t\ttotal_api_duration_ms: v.optional(v.number()),\n\t\t\ttotal_lines_added: v.optional(v.number()),\n\t\t\ttotal_lines_removed: v.optional(v.number()),\n\t\t}),\n\t),\n\tcontext_window: v.optional(\n\t\tv.object({\n\t\t\ttotal_input_tokens: v.number(),\n\t\t\ttotal_output_tokens: v.optional(v.number()),\n\t\t\tcontext_window_size: v.number(),\n\t\t}),\n\t),\n});\n\n/**\n * Type definition for Claude Code statusline hook JSON data\n */\nexport type StatuslineHookJson = v.InferOutput<typeof statuslineHookJsonSchema>;\n\n/**\n * Type definition for transcript usage data from Claude messages\n */\n"
  },
  {
    "path": "apps/ccusage/src/_utils.ts",
    "content": "import { stat, utimes, writeFile } from 'node:fs/promises';\nimport { Result } from '@praha/byethrow';\nimport { createFixture } from 'fs-fixture';\n\nexport function unreachable(value: never): never {\n\tthrow new Error(`Unreachable code reached with value: ${value as any}`);\n}\n\n/**\n * Gets the last modified time of a file using Result pattern\n * @param filePath - Path to the file\n * @returns Modification time in milliseconds, or 0 if file doesn't exist\n */\nexport async function getFileModifiedTime(filePath: string): Promise<number> {\n\treturn Result.pipe(\n\t\tResult.try({\n\t\t\ttry: stat(filePath),\n\t\t\tcatch: (error) => error,\n\t\t}),\n\t\tResult.map((stats) => stats.mtime.getTime()),\n\t\tResult.unwrap(0), // Default to 0 if file doesn't exist or can't be accessed\n\t);\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('unreachable', () => {\n\t\tit('should throw an error when called', () => {\n\t\t\texpect(() => unreachable('test' as never)).toThrow(\n\t\t\t\t'Unreachable code reached with value: test',\n\t\t\t);\n\t\t});\n\t});\n\n\tdescribe('getFileModifiedTime', () => {\n\t\tit('returns specific modification time when set', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'test.txt': 'content',\n\t\t\t});\n\n\t\t\t// Set specific time (2024-01-01 12:00:00 UTC)\n\t\t\tconst specificTime = new Date('2024-01-01T12:00:00.000Z');\n\t\t\tawait utimes(`${fixture.path}/test.txt`, specificTime, specificTime);\n\n\t\t\tconst mtime = await getFileModifiedTime(fixture.getPath('test.txt'));\n\t\t\texpect(mtime).toBe(specificTime.getTime());\n\t\t\texpect(typeof mtime).toBe('number');\n\t\t});\n\n\t\tit('returns 0 for non-existent file', async () => {\n\t\t\tconst mtime = await getFileModifiedTime('/non/existent/file.txt');\n\t\t\texpect(mtime).toBe(0);\n\t\t});\n\n\t\tit('detects file modification correctly', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'test.txt': 'content',\n\t\t\t});\n\n\t\t\t// Set first time\n\t\t\tconst firstTime = new Date('2024-01-01T10:00:00.000Z');\n\t\t\tawait utimes(`${fixture.path}/test.txt`, firstTime, firstTime);\n\n\t\t\tconst mtime1 = await getFileModifiedTime(`${fixture.path}/test.txt`);\n\t\t\texpect(mtime1).toBe(firstTime.getTime());\n\n\t\t\t// Modify file and set second time\n\t\t\tconst secondTime = new Date('2024-01-01T11:00:00.000Z');\n\t\t\tawait writeFile(fixture.getPath('test.txt'), 'modified content');\n\t\t\tawait utimes(fixture.getPath('test.txt'), secondTime, secondTime);\n\n\t\t\tconst mtime2 = await getFileModifiedTime(fixture.getPath('test.txt'));\n\t\t\texpect(mtime2).toBe(secondTime.getTime());\n\t\t\texpect(mtime2).toBeGreaterThan(mtime1);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/calculate-cost.ts",
    "content": "/**\n * @fileoverview Cost calculation utilities for usage data analysis\n *\n * This module provides functions for calculating costs and aggregating token usage\n * across different time periods and models. It handles both pre-calculated costs\n * and dynamic cost calculations based on model pricing.\n *\n * @module calculate-cost\n */\n\nimport type { AggregatedTokenCounts } from './_token-utils.ts';\nimport type { DailyUsage, MonthlyUsage, SessionUsage, WeeklyUsage } from './data-loader.ts';\nimport { getTotalTokens } from './_token-utils.ts';\nimport {\n\tcreateActivityDate,\n\tcreateDailyDate,\n\tcreateModelName,\n\tcreateProjectPath,\n\tcreateSessionId,\n\tcreateVersion,\n} from './_types.ts';\n\n/**\n * Alias for AggregatedTokenCounts from shared utilities\n * @deprecated Use AggregatedTokenCounts from _token-utils.ts instead\n */\ntype TokenData = AggregatedTokenCounts;\n\n/**\n * Token totals including cost information\n */\ntype TokenTotals = TokenData & {\n\ttotalCost: number;\n};\n\n/**\n * Complete totals object with token counts, cost, and total token sum\n */\ntype TotalsObject = TokenTotals & {\n\ttotalTokens: number;\n};\n\n/**\n * Calculates total token usage and cost across multiple usage data entries\n * @param data - Array of daily, monthly, or session usage data\n * @returns Aggregated token totals and cost\n */\nexport function calculateTotals(\n\tdata: Array<DailyUsage | MonthlyUsage | WeeklyUsage | SessionUsage>,\n): TokenTotals {\n\treturn data.reduce(\n\t\t(acc, item) => ({\n\t\t\tinputTokens: acc.inputTokens + item.inputTokens,\n\t\t\toutputTokens: acc.outputTokens + item.outputTokens,\n\t\t\tcacheCreationTokens: acc.cacheCreationTokens + item.cacheCreationTokens,\n\t\t\tcacheReadTokens: acc.cacheReadTokens + item.cacheReadTokens,\n\t\t\ttotalCost: acc.totalCost + item.totalCost,\n\t\t}),\n\t\t{\n\t\t\tinputTokens: 0,\n\t\t\toutputTokens: 0,\n\t\t\tcacheCreationTokens: 0,\n\t\t\tcacheReadTokens: 0,\n\t\t\ttotalCost: 0,\n\t\t},\n\t);\n}\n\n// Re-export getTotalTokens from shared utilities for backward compatibility\nexport { getTotalTokens };\n\n/**\n * Creates a complete totals object by adding total token count to existing totals\n * @param totals - Token totals with cost information\n * @returns Complete totals object including total token sum\n */\nexport function createTotalsObject(totals: TokenTotals): TotalsObject {\n\treturn {\n\t\t...totals,\n\t\ttotalTokens: getTotalTokens(totals),\n\t};\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('token aggregation utilities', () => {\n\t\tit('calculateTotals should aggregate daily usage data', () => {\n\t\t\tconst dailyData: DailyUsage[] = [\n\t\t\t\t{\n\t\t\t\t\tdate: createDailyDate('2024-01-01'),\n\t\t\t\t\tinputTokens: 100,\n\t\t\t\t\toutputTokens: 50,\n\t\t\t\t\tcacheCreationTokens: 25,\n\t\t\t\t\tcacheReadTokens: 10,\n\t\t\t\t\ttotalCost: 0.01,\n\t\t\t\t\tmodelsUsed: [createModelName('claude-sonnet-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tdate: createDailyDate('2024-01-02'),\n\t\t\t\t\tinputTokens: 200,\n\t\t\t\t\toutputTokens: 100,\n\t\t\t\t\tcacheCreationTokens: 50,\n\t\t\t\t\tcacheReadTokens: 20,\n\t\t\t\t\ttotalCost: 0.02,\n\t\t\t\t\tmodelsUsed: [createModelName('claude-opus-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst totals = calculateTotals(dailyData);\n\t\t\texpect(totals.inputTokens).toBe(300);\n\t\t\texpect(totals.outputTokens).toBe(150);\n\t\t\texpect(totals.cacheCreationTokens).toBe(75);\n\t\t\texpect(totals.cacheReadTokens).toBe(30);\n\t\t\texpect(totals.totalCost).toBeCloseTo(0.03);\n\t\t});\n\n\t\tit('calculateTotals should aggregate session usage data', () => {\n\t\t\tconst sessionData: SessionUsage[] = [\n\t\t\t\t{\n\t\t\t\t\tsessionId: createSessionId('session-1'),\n\t\t\t\t\tprojectPath: createProjectPath('project/path'),\n\t\t\t\t\tinputTokens: 100,\n\t\t\t\t\toutputTokens: 50,\n\t\t\t\t\tcacheCreationTokens: 25,\n\t\t\t\t\tcacheReadTokens: 10,\n\t\t\t\t\ttotalCost: 0.01,\n\t\t\t\t\tlastActivity: createActivityDate('2024-01-01'),\n\t\t\t\t\tversions: [createVersion('1.0.3')],\n\t\t\t\t\tmodelsUsed: [createModelName('claude-sonnet-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: createSessionId('session-2'),\n\t\t\t\t\tprojectPath: createProjectPath('project/path'),\n\t\t\t\t\tinputTokens: 200,\n\t\t\t\t\toutputTokens: 100,\n\t\t\t\t\tcacheCreationTokens: 50,\n\t\t\t\t\tcacheReadTokens: 20,\n\t\t\t\t\ttotalCost: 0.02,\n\t\t\t\t\tlastActivity: createActivityDate('2024-01-02'),\n\t\t\t\t\tversions: [createVersion('1.0.3'), createVersion('1.0.4')],\n\t\t\t\t\tmodelsUsed: [createModelName('claude-opus-4-20250514')],\n\t\t\t\t\tmodelBreakdowns: [],\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst totals = calculateTotals(sessionData);\n\t\t\texpect(totals.inputTokens).toBe(300);\n\t\t\texpect(totals.outputTokens).toBe(150);\n\t\t\texpect(totals.cacheCreationTokens).toBe(75);\n\t\t\texpect(totals.cacheReadTokens).toBe(30);\n\t\t\texpect(totals.totalCost).toBeCloseTo(0.03);\n\t\t});\n\n\t\tit('getTotalTokens should sum all token types', () => {\n\t\t\tconst tokens = {\n\t\t\t\tinputTokens: 100,\n\t\t\t\toutputTokens: 50,\n\t\t\t\tcacheCreationTokens: 25,\n\t\t\t\tcacheReadTokens: 10,\n\t\t\t};\n\n\t\t\tconst total = getTotalTokens(tokens);\n\t\t\texpect(total).toBe(185);\n\t\t});\n\n\t\tit('getTotalTokens should handle zero values', () => {\n\t\t\tconst tokens = {\n\t\t\t\tinputTokens: 0,\n\t\t\t\toutputTokens: 0,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t};\n\n\t\t\tconst total = getTotalTokens(tokens);\n\t\t\texpect(total).toBe(0);\n\t\t});\n\n\t\tit('createTotalsObject should create complete totals object', () => {\n\t\t\tconst totals = {\n\t\t\t\tinputTokens: 100,\n\t\t\t\toutputTokens: 50,\n\t\t\t\tcacheCreationTokens: 25,\n\t\t\t\tcacheReadTokens: 10,\n\t\t\t\ttotalCost: 0.01,\n\t\t\t};\n\n\t\t\tconst totalsObject = createTotalsObject(totals);\n\t\t\texpect(totalsObject).toEqual({\n\t\t\t\tinputTokens: 100,\n\t\t\t\toutputTokens: 50,\n\t\t\t\tcacheCreationTokens: 25,\n\t\t\t\tcacheReadTokens: 10,\n\t\t\t\ttotalTokens: 185,\n\t\t\t\ttotalCost: 0.01,\n\t\t\t});\n\t\t});\n\n\t\tit('calculateTotals should handle empty array', () => {\n\t\t\tconst totals = calculateTotals([]);\n\t\t\texpect(totals).toEqual({\n\t\t\t\tinputTokens: 0,\n\t\t\t\toutputTokens: 0,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalCost: 0,\n\t\t\t});\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/commands/_session_id.ts",
    "content": "import type { CostMode } from '../_types.ts';\nimport type { UsageData } from '../data-loader.ts';\nimport process from 'node:process';\nimport { formatCurrency, formatNumber, ResponsiveTable } from '@ccusage/terminal/table';\nimport { Result } from '@praha/byethrow';\nimport { formatDateCompact } from '../_date-utils.ts';\nimport { processWithJq } from '../_jq-processor.ts';\nimport { loadSessionUsageById } from '../data-loader.ts';\nimport { log, logger } from '../logger.ts';\n\nexport type SessionIdContext = {\n\tvalues: {\n\t\tid: string;\n\t\tmode: CostMode;\n\t\toffline: boolean;\n\t\tjq?: string;\n\t\ttimezone?: string;\n\t\tlocale: string; // normalized to non-optional to avoid touching data-loader\n\t};\n};\n\n/**\n * Handles the session ID lookup and displays usage data.\n */\nexport async function handleSessionIdLookup(\n\tctx: SessionIdContext,\n\tuseJson: boolean,\n): Promise<void> {\n\tconst sessionUsage = await loadSessionUsageById(ctx.values.id, {\n\t\tmode: ctx.values.mode,\n\t\toffline: ctx.values.offline,\n\t});\n\n\tif (sessionUsage == null) {\n\t\tif (useJson) {\n\t\t\tlog(JSON.stringify(null));\n\t\t} else {\n\t\t\tlogger.warn(`No session found with ID: ${ctx.values.id}`);\n\t\t}\n\t\tprocess.exit(0);\n\t}\n\n\tif (useJson) {\n\t\tconst jsonOutput = {\n\t\t\tsessionId: ctx.values.id,\n\t\t\ttotalCost: sessionUsage.totalCost,\n\t\t\ttotalTokens: calculateSessionTotalTokens(sessionUsage.entries),\n\t\t\tentries: sessionUsage.entries.map((entry) => ({\n\t\t\t\ttimestamp: entry.timestamp,\n\t\t\t\tinputTokens: entry.message.usage.input_tokens,\n\t\t\t\toutputTokens: entry.message.usage.output_tokens,\n\t\t\t\tcacheCreationTokens: entry.message.usage.cache_creation_input_tokens ?? 0,\n\t\t\t\tcacheReadTokens: entry.message.usage.cache_read_input_tokens ?? 0,\n\t\t\t\tmodel: entry.message.model ?? 'unknown',\n\t\t\t\tcostUSD: entry.costUSD ?? 0,\n\t\t\t})),\n\t\t};\n\n\t\tif (ctx.values.jq != null) {\n\t\t\tconst jqResult = await processWithJq(jsonOutput, ctx.values.jq);\n\t\t\tif (Result.isFailure(jqResult)) {\n\t\t\t\tlogger.error(jqResult.error.message);\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t\tlog(jqResult.value);\n\t\t} else {\n\t\t\tlog(JSON.stringify(jsonOutput, null, 2));\n\t\t}\n\t} else {\n\t\tlogger.box(`Claude Code Session Usage - ${ctx.values.id}`);\n\n\t\tconst totalTokens = calculateSessionTotalTokens(sessionUsage.entries);\n\n\t\tlog(`Total Cost: ${formatCurrency(sessionUsage.totalCost)}`);\n\t\tlog(`Total Tokens: ${formatNumber(totalTokens)}`);\n\t\tlog(`Total Entries: ${sessionUsage.entries.length}`);\n\t\tlog('');\n\n\t\tif (sessionUsage.entries.length > 0) {\n\t\t\tconst table = new ResponsiveTable({\n\t\t\t\thead: ['Timestamp', 'Model', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Cost (USD)'],\n\t\t\t\tstyle: { head: ['cyan'] },\n\t\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right'],\n\t\t\t});\n\n\t\t\tfor (const entry of sessionUsage.entries) {\n\t\t\t\ttable.push([\n\t\t\t\t\tformatDateCompact(entry.timestamp, ctx.values.timezone, ctx.values.locale),\n\t\t\t\t\tentry.message.model ?? 'unknown',\n\t\t\t\t\tformatNumber(entry.message.usage.input_tokens),\n\t\t\t\t\tformatNumber(entry.message.usage.output_tokens),\n\t\t\t\t\tformatNumber(entry.message.usage.cache_creation_input_tokens ?? 0),\n\t\t\t\t\tformatNumber(entry.message.usage.cache_read_input_tokens ?? 0),\n\t\t\t\t\tformatCurrency(entry.costUSD ?? 0),\n\t\t\t\t]);\n\t\t\t}\n\n\t\t\tlog(table.toString());\n\t\t}\n\t}\n}\n\nfunction calculateSessionTotalTokens(entries: UsageData[]): number {\n\treturn entries.reduce((sum, entry) => {\n\t\tconst usage = entry.message.usage;\n\t\treturn (\n\t\t\tsum +\n\t\t\tusage.input_tokens +\n\t\t\tusage.output_tokens +\n\t\t\t(usage.cache_creation_input_tokens ?? 0) +\n\t\t\t(usage.cache_read_input_tokens ?? 0)\n\t\t);\n\t}, 0);\n}\n"
  },
  {
    "path": "apps/ccusage/src/commands/blocks.ts",
    "content": "import type { SessionBlock } from '../_session-blocks.ts';\nimport process from 'node:process';\nimport {\n\tformatCurrency,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { Result } from '@praha/byethrow';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts';\nimport {\n\tBLOCKS_COMPACT_WIDTH_THRESHOLD,\n\tBLOCKS_DEFAULT_TERMINAL_WIDTH,\n\tBLOCKS_WARNING_THRESHOLD,\n\tDEFAULT_RECENT_DAYS,\n} from '../_consts.ts';\nimport { processWithJq } from '../_jq-processor.ts';\nimport {\n\tcalculateBurnRate,\n\tDEFAULT_SESSION_DURATION_HOURS,\n\tfilterRecentBlocks,\n\tprojectBlockUsage,\n} from '../_session-blocks.ts';\nimport { sharedCommandConfig } from '../_shared-args.ts';\nimport { getTotalTokens } from '../_token-utils.ts';\nimport { loadSessionBlockData } from '../data-loader.ts';\nimport { log, logger } from '../logger.ts';\n\n/**\n * Formats the time display for a session block\n * @param block - Session block to format\n * @param compact - Whether to use compact formatting for narrow terminals\n * @param locale - Locale for date/time formatting\n * @returns Formatted time string with duration and status information\n */\nfunction formatBlockTime(block: SessionBlock, compact = false, locale?: string): string {\n\tconst start = compact\n\t\t? block.startTime.toLocaleString(locale, {\n\t\t\t\tmonth: '2-digit',\n\t\t\t\tday: '2-digit',\n\t\t\t\thour: '2-digit',\n\t\t\t\tminute: '2-digit',\n\t\t\t})\n\t\t: block.startTime.toLocaleString(locale);\n\n\tif (block.isGap ?? false) {\n\t\tconst end = compact\n\t\t\t? block.endTime.toLocaleString(locale, {\n\t\t\t\t\thour: '2-digit',\n\t\t\t\t\tminute: '2-digit',\n\t\t\t\t})\n\t\t\t: block.endTime.toLocaleString(locale);\n\t\tconst duration = Math.round(\n\t\t\t(block.endTime.getTime() - block.startTime.getTime()) / (1000 * 60 * 60),\n\t\t);\n\t\treturn compact ? `${start}-${end}\\n(${duration}h gap)` : `${start} - ${end} (${duration}h gap)`;\n\t}\n\n\tconst duration =\n\t\tblock.actualEndTime != null\n\t\t\t? Math.round((block.actualEndTime.getTime() - block.startTime.getTime()) / (1000 * 60))\n\t\t\t: 0;\n\n\tif (block.isActive) {\n\t\tconst now = new Date();\n\t\tconst elapsed = Math.round((now.getTime() - block.startTime.getTime()) / (1000 * 60));\n\t\tconst remaining = Math.round((block.endTime.getTime() - now.getTime()) / (1000 * 60));\n\t\tconst elapsedHours = Math.floor(elapsed / 60);\n\t\tconst elapsedMins = elapsed % 60;\n\t\tconst remainingHours = Math.floor(remaining / 60);\n\t\tconst remainingMins = remaining % 60;\n\n\t\tif (compact) {\n\t\t\treturn `${start}\\n(${elapsedHours}h${elapsedMins}m/${remainingHours}h${remainingMins}m)`;\n\t\t}\n\t\treturn `${start} (${elapsedHours}h ${elapsedMins}m elapsed, ${remainingHours}h ${remainingMins}m remaining)`;\n\t}\n\n\tconst hours = Math.floor(duration / 60);\n\tconst mins = duration % 60;\n\tif (compact) {\n\t\treturn hours > 0 ? `${start}\\n(${hours}h${mins}m)` : `${start}\\n(${mins}m)`;\n\t}\n\tif (hours > 0) {\n\t\treturn `${start} (${hours}h ${mins}m)`;\n\t}\n\treturn `${start} (${mins}m)`;\n}\n\n/**\n * Formats the list of models used in a block for display\n * @param models - Array of model names\n * @returns Formatted model names string\n */\nfunction formatModels(models: string[]): string {\n\tif (models.length === 0) {\n\t\treturn '-';\n\t}\n\t// Use consistent multiline format across all commands\n\treturn formatModelsDisplayMultiline(models);\n}\n\n/**\n * Parses token limit argument, supporting 'max' keyword\n * @param value - Token limit string value\n * @param maxFromAll - Maximum token count found in all blocks\n * @returns Parsed token limit or undefined if invalid\n */\nfunction parseTokenLimit(value: string | undefined, maxFromAll: number): number | undefined {\n\tif (value == null || value === '' || value === 'max') {\n\t\treturn maxFromAll > 0 ? maxFromAll : undefined;\n\t}\n\n\tconst limit = Number.parseInt(value, 10);\n\treturn Number.isNaN(limit) ? undefined : limit;\n}\n\nexport const blocksCommand = define({\n\tname: 'blocks',\n\tdescription: 'Show usage report grouped by session billing blocks',\n\targs: {\n\t\t...sharedCommandConfig.args,\n\t\tactive: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'a',\n\t\t\tdescription: 'Show only active block with projections',\n\t\t\tdefault: false,\n\t\t},\n\t\trecent: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'r',\n\t\t\tdescription: `Show blocks from last ${DEFAULT_RECENT_DAYS} days (including active)`,\n\t\t\tdefault: false,\n\t\t},\n\t\ttokenLimit: {\n\t\t\ttype: 'string',\n\t\t\tshort: 't',\n\t\t\tdescription: 'Token limit for quota warnings (e.g., 500000 or \"max\")',\n\t\t},\n\t\tsessionLength: {\n\t\t\ttype: 'number',\n\t\t\tshort: 'n',\n\t\t\tdescription: `Session block duration in hours (default: ${DEFAULT_SESSION_DURATION_HOURS})`,\n\t\t\tdefault: DEFAULT_SESSION_DURATION_HOURS,\n\t\t},\n\t},\n\ttoKebab: true,\n\tasync run(ctx) {\n\t\t// Load configuration and merge with CLI arguments\n\t\tconst config = loadConfig(ctx.values.config, ctx.values.debug);\n\t\tconst mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug);\n\n\t\t// --jq implies --json\n\t\tconst useJson = mergedOptions.json || mergedOptions.jq != null;\n\t\tif (useJson) {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\t// Validate session length\n\t\tif (ctx.values.sessionLength <= 0) {\n\t\t\tlogger.error('Session length must be a positive number');\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tlet blocks = await loadSessionBlockData({\n\t\t\tsince: ctx.values.since,\n\t\t\tuntil: ctx.values.until,\n\t\t\tmode: ctx.values.mode,\n\t\t\torder: ctx.values.order,\n\t\t\toffline: ctx.values.offline,\n\t\t\tsessionDurationHours: ctx.values.sessionLength,\n\t\t\ttimezone: ctx.values.timezone,\n\t\t\tlocale: ctx.values.locale,\n\t\t});\n\n\t\tif (blocks.length === 0) {\n\t\t\tif (useJson) {\n\t\t\t\tlog(JSON.stringify({ blocks: [] }));\n\t\t\t} else {\n\t\t\t\tlogger.warn('No Claude usage data found.');\n\t\t\t}\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\t// Calculate max tokens from ALL blocks before applying filters\n\t\tlet maxTokensFromAll = 0;\n\t\tif (\n\t\t\tctx.values.tokenLimit === 'max' ||\n\t\t\tctx.values.tokenLimit == null ||\n\t\t\tctx.values.tokenLimit === ''\n\t\t) {\n\t\t\tfor (const block of blocks) {\n\t\t\t\tif (!(block.isGap ?? false) && !block.isActive) {\n\t\t\t\t\tconst blockTokens = getTotalTokens(block.tokenCounts);\n\t\t\t\t\tif (blockTokens > maxTokensFromAll) {\n\t\t\t\t\t\tmaxTokensFromAll = blockTokens;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!useJson && maxTokensFromAll > 0) {\n\t\t\t\tlogger.info(`Using max tokens from previous sessions: ${formatNumber(maxTokensFromAll)}`);\n\t\t\t}\n\t\t}\n\n\t\t// Apply filters\n\t\tif (ctx.values.recent) {\n\t\t\tblocks = filterRecentBlocks(blocks, DEFAULT_RECENT_DAYS);\n\t\t}\n\n\t\tif (ctx.values.active) {\n\t\t\tblocks = blocks.filter((block: SessionBlock) => block.isActive);\n\t\t\tif (blocks.length === 0) {\n\t\t\t\tif (useJson) {\n\t\t\t\t\tlog(JSON.stringify({ blocks: [], message: 'No active block' }));\n\t\t\t\t} else {\n\t\t\t\t\tlogger.info('No active session block found.');\n\t\t\t\t}\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t}\n\n\t\tif (useJson) {\n\t\t\t// JSON output\n\t\t\tconst jsonOutput = {\n\t\t\t\tblocks: blocks.map((block: SessionBlock) => {\n\t\t\t\t\tconst burnRate = block.isActive ? calculateBurnRate(block) : null;\n\t\t\t\t\tconst projection = block.isActive ? projectBlockUsage(block) : null;\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: block.id,\n\t\t\t\t\t\tstartTime: block.startTime.toISOString(),\n\t\t\t\t\t\tendTime: block.endTime.toISOString(),\n\t\t\t\t\t\tactualEndTime: block.actualEndTime?.toISOString() ?? null,\n\t\t\t\t\t\tisActive: block.isActive,\n\t\t\t\t\t\tisGap: block.isGap ?? false,\n\t\t\t\t\t\tentries: block.entries.length,\n\t\t\t\t\t\ttokenCounts: block.tokenCounts,\n\t\t\t\t\t\ttotalTokens: getTotalTokens(block.tokenCounts),\n\t\t\t\t\t\tcostUSD: block.costUSD,\n\t\t\t\t\t\tmodels: block.models,\n\t\t\t\t\t\tburnRate,\n\t\t\t\t\t\tprojection,\n\t\t\t\t\t\ttokenLimitStatus:\n\t\t\t\t\t\t\tprojection != null && ctx.values.tokenLimit != null\n\t\t\t\t\t\t\t\t? (() => {\n\t\t\t\t\t\t\t\t\t\tconst limit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll);\n\t\t\t\t\t\t\t\t\t\treturn limit != null\n\t\t\t\t\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t\t\t\t\tlimit,\n\t\t\t\t\t\t\t\t\t\t\t\t\tprojectedUsage: projection.totalTokens,\n\t\t\t\t\t\t\t\t\t\t\t\t\tpercentUsed: (projection.totalTokens / limit) * 100,\n\t\t\t\t\t\t\t\t\t\t\t\t\tstatus:\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tprojection.totalTokens > limit\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'exceeds'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: projection.totalTokens > limit * BLOCKS_WARNING_THRESHOLD\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'warning'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'ok',\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t: undefined;\n\t\t\t\t\t\t\t\t\t})()\n\t\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\t\tusageLimitResetTime: block.usageLimitResetTime,\n\t\t\t\t\t};\n\t\t\t\t}),\n\t\t\t};\n\n\t\t\t// Process with jq if specified\n\t\t\tif (ctx.values.jq != null) {\n\t\t\t\tconst jqResult = await processWithJq(jsonOutput, ctx.values.jq);\n\t\t\t\tif (Result.isFailure(jqResult)) {\n\t\t\t\t\tlogger.error(jqResult.error.message);\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\t\t\t\tlog(jqResult.value);\n\t\t\t} else {\n\t\t\t\tlog(JSON.stringify(jsonOutput, null, 2));\n\t\t\t}\n\t\t} else {\n\t\t\t// Table output\n\t\t\tif (ctx.values.active && blocks.length === 1) {\n\t\t\t\t// Detailed active block view\n\t\t\t\tconst block = blocks[0] as SessionBlock;\n\t\t\t\tif (block == null) {\n\t\t\t\t\tlogger.warn('No active block found.');\n\t\t\t\t\tprocess.exit(0);\n\t\t\t\t}\n\t\t\t\tconst burnRate = calculateBurnRate(block);\n\t\t\t\tconst projection = projectBlockUsage(block);\n\n\t\t\t\tlogger.box('Current Session Block Status');\n\n\t\t\t\tconst now = new Date();\n\t\t\t\tconst elapsed = Math.round((now.getTime() - block.startTime.getTime()) / (1000 * 60));\n\t\t\t\tconst remaining = Math.round((block.endTime.getTime() - now.getTime()) / (1000 * 60));\n\n\t\t\t\tlog(\n\t\t\t\t\t`Block Started: ${pc.cyan(block.startTime.toLocaleString())} (${pc.yellow(`${Math.floor(elapsed / 60)}h ${elapsed % 60}m`)} ago)`,\n\t\t\t\t);\n\t\t\t\tlog(`Time Remaining: ${pc.green(`${Math.floor(remaining / 60)}h ${remaining % 60}m`)}\\n`);\n\n\t\t\t\tlog(pc.bold('Current Usage:'));\n\t\t\t\tlog(`  Input Tokens:     ${formatNumber(block.tokenCounts.inputTokens)}`);\n\t\t\t\tlog(`  Output Tokens:    ${formatNumber(block.tokenCounts.outputTokens)}`);\n\t\t\t\tlog(`  Total Cost:       ${formatCurrency(block.costUSD)}\\n`);\n\n\t\t\t\tif (burnRate != null) {\n\t\t\t\t\tlog(pc.bold('Burn Rate:'));\n\t\t\t\t\tlog(`  Tokens/minute:    ${formatNumber(burnRate.tokensPerMinute)}`);\n\t\t\t\t\tlog(`  Cost/hour:        ${formatCurrency(burnRate.costPerHour)}\\n`);\n\t\t\t\t}\n\n\t\t\t\tif (projection != null) {\n\t\t\t\t\tlog(pc.bold('Projected Usage (if current rate continues):'));\n\t\t\t\t\tlog(`  Total Tokens:     ${formatNumber(projection.totalTokens)}`);\n\t\t\t\t\tlog(`  Total Cost:       ${formatCurrency(projection.totalCost)}\\n`);\n\n\t\t\t\t\tif (ctx.values.tokenLimit != null) {\n\t\t\t\t\t\t// Parse token limit\n\t\t\t\t\t\tconst limit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll);\n\t\t\t\t\t\tif (limit != null && limit > 0) {\n\t\t\t\t\t\t\tconst currentTokens = getTotalTokens(block.tokenCounts);\n\t\t\t\t\t\t\tconst remainingTokens = Math.max(0, limit - currentTokens);\n\t\t\t\t\t\t\tconst percentUsed = (projection.totalTokens / limit) * 100;\n\t\t\t\t\t\t\tconst status =\n\t\t\t\t\t\t\t\tpercentUsed > 100\n\t\t\t\t\t\t\t\t\t? pc.red('EXCEEDS LIMIT')\n\t\t\t\t\t\t\t\t\t: percentUsed > BLOCKS_WARNING_THRESHOLD * 100\n\t\t\t\t\t\t\t\t\t\t? pc.yellow('WARNING')\n\t\t\t\t\t\t\t\t\t\t: pc.green('OK');\n\n\t\t\t\t\t\t\tlog(pc.bold('Token Limit Status:'));\n\t\t\t\t\t\t\tlog(`  Limit:            ${formatNumber(limit)} tokens`);\n\t\t\t\t\t\t\tlog(\n\t\t\t\t\t\t\t\t`  Current Usage:    ${formatNumber(currentTokens)} (${((currentTokens / limit) * 100).toFixed(1)}%)`,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tlog(`  Remaining:        ${formatNumber(remainingTokens)} tokens`);\n\t\t\t\t\t\t\tlog(`  Projected Usage:  ${percentUsed.toFixed(1)}% ${status}`);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Table view for multiple blocks\n\t\t\t\tlogger.box('Claude Code Token Usage Report - Session Blocks');\n\n\t\t\t\t// Calculate token limit if \"max\" is specified\n\t\t\t\tconst actualTokenLimit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll);\n\n\t\t\t\tconst tableHeaders = ['Block Start', 'Duration/Status', 'Models', 'Tokens'];\n\t\t\t\tconst tableAligns: ('left' | 'right' | 'center')[] = ['left', 'left', 'left', 'right'];\n\n\t\t\t\t// Add % column if token limit is set\n\t\t\t\tif (actualTokenLimit != null && actualTokenLimit > 0) {\n\t\t\t\t\ttableHeaders.push('%');\n\t\t\t\t\ttableAligns.push('right');\n\t\t\t\t}\n\n\t\t\t\ttableHeaders.push('Cost');\n\t\t\t\ttableAligns.push('right');\n\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: tableHeaders,\n\t\t\t\t\tstyle: { head: ['cyan'] },\n\t\t\t\t\tcolAligns: tableAligns,\n\t\t\t\t});\n\n\t\t\t\t// Detect if we need compact formatting\n\t\t\t\t// Use compact format if:\n\t\t\t\t// 1. User explicitly requested it with --compact flag\n\t\t\t\t// 2. Terminal width is below threshold\n\t\t\t\tconst terminalWidth = process.stdout.columns || BLOCKS_DEFAULT_TERMINAL_WIDTH;\n\t\t\t\tconst isNarrowTerminal = terminalWidth < BLOCKS_COMPACT_WIDTH_THRESHOLD;\n\t\t\t\tconst useCompactFormat = ctx.values.compact || isNarrowTerminal;\n\n\t\t\t\tfor (const block of blocks) {\n\t\t\t\t\tif (block.isGap ?? false) {\n\t\t\t\t\t\t// Gap row\n\t\t\t\t\t\tconst gapRow = [\n\t\t\t\t\t\t\tpc.gray(formatBlockTime(block, useCompactFormat, ctx.values.locale)),\n\t\t\t\t\t\t\tpc.gray('(inactive)'),\n\t\t\t\t\t\t\tpc.gray('-'),\n\t\t\t\t\t\t\tpc.gray('-'),\n\t\t\t\t\t\t];\n\t\t\t\t\t\tif (actualTokenLimit != null && actualTokenLimit > 0) {\n\t\t\t\t\t\t\tgapRow.push(pc.gray('-'));\n\t\t\t\t\t\t}\n\t\t\t\t\t\tgapRow.push(pc.gray('-'));\n\t\t\t\t\t\ttable.push(gapRow);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst totalTokens = getTotalTokens(block.tokenCounts);\n\t\t\t\t\t\tconst status = block.isActive ? pc.green('ACTIVE') : '';\n\n\t\t\t\t\t\tconst row = [\n\t\t\t\t\t\t\tformatBlockTime(block, useCompactFormat, ctx.values.locale),\n\t\t\t\t\t\t\tstatus,\n\t\t\t\t\t\t\tformatModels(block.models),\n\t\t\t\t\t\t\tformatNumber(totalTokens),\n\t\t\t\t\t\t];\n\n\t\t\t\t\t\t// Add percentage if token limit is set\n\t\t\t\t\t\tif (actualTokenLimit != null && actualTokenLimit > 0) {\n\t\t\t\t\t\t\tconst percentage = (totalTokens / actualTokenLimit) * 100;\n\t\t\t\t\t\t\tconst percentText = `${percentage.toFixed(1)}%`;\n\t\t\t\t\t\t\trow.push(percentage > 100 ? pc.red(percentText) : percentText);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\trow.push(formatCurrency(block.costUSD));\n\t\t\t\t\t\ttable.push(row);\n\n\t\t\t\t\t\t// Add REMAINING and PROJECTED rows for active blocks\n\t\t\t\t\t\tif (block.isActive) {\n\t\t\t\t\t\t\t// REMAINING row - only show if token limit is set\n\t\t\t\t\t\t\tif (actualTokenLimit != null && actualTokenLimit > 0) {\n\t\t\t\t\t\t\t\tconst currentTokens = getTotalTokens(block.tokenCounts);\n\t\t\t\t\t\t\t\tconst remainingTokens = Math.max(0, actualTokenLimit - currentTokens);\n\t\t\t\t\t\t\t\tconst remainingText =\n\t\t\t\t\t\t\t\t\tremainingTokens > 0 ? formatNumber(remainingTokens) : pc.red('0');\n\n\t\t\t\t\t\t\t\t// Calculate remaining percentage (how much of limit is left)\n\t\t\t\t\t\t\t\tconst remainingPercent =\n\t\t\t\t\t\t\t\t\t((actualTokenLimit - currentTokens) / actualTokenLimit) * 100;\n\t\t\t\t\t\t\t\tconst remainingPercentText =\n\t\t\t\t\t\t\t\t\tremainingPercent > 0 ? `${remainingPercent.toFixed(1)}%` : pc.red('0.0%');\n\n\t\t\t\t\t\t\t\tconst remainingRow = [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tcontent: pc.gray(`(assuming ${formatNumber(actualTokenLimit)} token limit)`),\n\t\t\t\t\t\t\t\t\t\thAlign: 'right' as const,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tpc.blue('REMAINING'),\n\t\t\t\t\t\t\t\t\t'',\n\t\t\t\t\t\t\t\t\tremainingText,\n\t\t\t\t\t\t\t\t\tremainingPercentText,\n\t\t\t\t\t\t\t\t\t'', // No cost for remaining - it's about token limit, not cost\n\t\t\t\t\t\t\t\t];\n\t\t\t\t\t\t\t\ttable.push(remainingRow);\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// PROJECTED row\n\t\t\t\t\t\t\tconst projection = projectBlockUsage(block);\n\t\t\t\t\t\t\tif (projection != null) {\n\t\t\t\t\t\t\t\tconst projectedTokens = formatNumber(projection.totalTokens);\n\t\t\t\t\t\t\t\tconst projectedText =\n\t\t\t\t\t\t\t\t\tactualTokenLimit != null &&\n\t\t\t\t\t\t\t\t\tactualTokenLimit > 0 &&\n\t\t\t\t\t\t\t\t\tprojection.totalTokens > actualTokenLimit\n\t\t\t\t\t\t\t\t\t\t? pc.red(projectedTokens)\n\t\t\t\t\t\t\t\t\t\t: projectedTokens;\n\n\t\t\t\t\t\t\t\tconst projectedRow = [\n\t\t\t\t\t\t\t\t\t{ content: pc.gray('(assuming current burn rate)'), hAlign: 'right' as const },\n\t\t\t\t\t\t\t\t\tpc.yellow('PROJECTED'),\n\t\t\t\t\t\t\t\t\t'',\n\t\t\t\t\t\t\t\t\tprojectedText,\n\t\t\t\t\t\t\t\t];\n\n\t\t\t\t\t\t\t\t// Add percentage if token limit is set\n\t\t\t\t\t\t\t\tif (actualTokenLimit != null && actualTokenLimit > 0) {\n\t\t\t\t\t\t\t\t\tconst percentage = (projection.totalTokens / actualTokenLimit) * 100;\n\t\t\t\t\t\t\t\t\tconst percentText = `${percentage.toFixed(1)}%`;\n\t\t\t\t\t\t\t\t\tprojectedRow.push(percentText);\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\tprojectedRow.push(formatCurrency(projection.totalCost));\n\t\t\t\t\t\t\t\ttable.push(projectedRow);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlog(table.toString());\n\t\t\t}\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/ccusage/src/commands/daily.ts",
    "content": "import type { UsageReportConfig } from '@ccusage/terminal/table';\nimport process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tcreateUsageReportTable,\n\tformatTotalsRow,\n\tformatUsageDataRow,\n\tpushBreakdownRows,\n} from '@ccusage/terminal/table';\nimport { Result } from '@praha/byethrow';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts';\nimport { groupByProject, groupDataByProject } from '../_daily-grouping.ts';\nimport { formatDateCompact } from '../_date-utils.ts';\nimport { processWithJq } from '../_jq-processor.ts';\nimport { formatProjectName } from '../_project-names.ts';\nimport { sharedCommandConfig } from '../_shared-args.ts';\nimport { calculateTotals, createTotalsObject, getTotalTokens } from '../calculate-cost.ts';\nimport { loadDailyUsageData } from '../data-loader.ts';\nimport { detectMismatches, printMismatchReport } from '../debug.ts';\nimport { log, logger } from '../logger.ts';\n\nexport const dailyCommand = define({\n\tname: 'daily',\n\tdescription: 'Show usage report grouped by date',\n\t...sharedCommandConfig,\n\targs: {\n\t\t...sharedCommandConfig.args,\n\t\tinstances: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'i',\n\t\t\tdescription: 'Show usage breakdown by project/instance',\n\t\t\tdefault: false,\n\t\t},\n\t\tproject: {\n\t\t\ttype: 'string',\n\t\t\tshort: 'p',\n\t\t\tdescription: 'Filter to specific project name',\n\t\t},\n\t\tprojectAliases: {\n\t\t\ttype: 'string',\n\t\t\tdescription:\n\t\t\t\t\"Comma-separated project aliases (e.g., 'ccusage=Usage Tracker,myproject=My Project')\",\n\t\t\thidden: true,\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\t// Load configuration and merge with CLI arguments\n\t\tconst config = loadConfig(ctx.values.config, ctx.values.debug);\n\t\tconst mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug);\n\n\t\t// Convert projectAliases to Map if it exists\n\t\t// Parse comma-separated key=value pairs\n\t\tlet projectAliases: Map<string, string> | undefined;\n\t\tif (mergedOptions.projectAliases != null && typeof mergedOptions.projectAliases === 'string') {\n\t\t\tprojectAliases = new Map();\n\t\t\tconst pairs = mergedOptions.projectAliases\n\t\t\t\t.split(',')\n\t\t\t\t.map((pair) => pair.trim())\n\t\t\t\t.filter((pair) => pair !== '');\n\t\t\tfor (const pair of pairs) {\n\t\t\t\tconst parts = pair.split('=').map((s) => s.trim());\n\t\t\t\tconst rawName = parts[0];\n\t\t\t\tconst alias = parts[1];\n\t\t\t\tif (rawName != null && alias != null && rawName !== '' && alias !== '') {\n\t\t\t\t\tprojectAliases.set(rawName, alias);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// --jq implies --json\n\t\tconst useJson = Boolean(mergedOptions.json) || mergedOptions.jq != null;\n\t\tif (useJson) {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\tconst dailyData = await loadDailyUsageData({\n\t\t\t...mergedOptions,\n\t\t\tgroupByProject: mergedOptions.instances,\n\t\t});\n\n\t\tif (dailyData.length === 0) {\n\t\t\tif (useJson) {\n\t\t\t\tlog(JSON.stringify([]));\n\t\t\t} else {\n\t\t\t\tlogger.warn('No Claude usage data found.');\n\t\t\t}\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\t// Calculate totals\n\t\tconst totals = calculateTotals(dailyData);\n\n\t\t// Show debug information if requested\n\t\tif (mergedOptions.debug && !useJson) {\n\t\t\tconst mismatchStats = await detectMismatches(undefined);\n\t\t\tprintMismatchReport(mismatchStats, mergedOptions.debugSamples as number | undefined);\n\t\t}\n\n\t\tif (useJson) {\n\t\t\t// Output JSON format - group by project if instances flag is used\n\t\t\tconst jsonOutput =\n\t\t\t\tBoolean(mergedOptions.instances) && dailyData.some((d) => d.project != null)\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tprojects: groupByProject(dailyData),\n\t\t\t\t\t\t\ttotals: createTotalsObject(totals),\n\t\t\t\t\t\t}\n\t\t\t\t\t: {\n\t\t\t\t\t\t\tdaily: dailyData.map((data) => ({\n\t\t\t\t\t\t\t\tdate: data.date,\n\t\t\t\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\t\t\t\ttotalTokens: getTotalTokens(data),\n\t\t\t\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t\t\t\t\tmodelBreakdowns: data.modelBreakdowns,\n\t\t\t\t\t\t\t\t...(data.project != null && { project: data.project }),\n\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t\ttotals: createTotalsObject(totals),\n\t\t\t\t\t\t};\n\n\t\t\t// Process with jq if specified\n\t\t\tif (mergedOptions.jq != null) {\n\t\t\t\tconst jqResult = await processWithJq(jsonOutput, mergedOptions.jq);\n\t\t\t\tif (Result.isFailure(jqResult)) {\n\t\t\t\t\tlogger.error(jqResult.error.message);\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\t\t\t\tlog(jqResult.value);\n\t\t\t} else {\n\t\t\t\tlog(JSON.stringify(jsonOutput, null, 2));\n\t\t\t}\n\t\t} else {\n\t\t\t// Print header\n\t\t\tlogger.box('Claude Code Token Usage Report - Daily');\n\n\t\t\t// Create table with compact mode support\n\t\t\tconst tableConfig: UsageReportConfig = {\n\t\t\t\tfirstColumnName: 'Date',\n\t\t\t\tdateFormatter: (dateStr: string) =>\n\t\t\t\t\tformatDateCompact(dateStr, mergedOptions.timezone, mergedOptions.locale ?? undefined),\n\t\t\t\tforceCompact: ctx.values.compact,\n\t\t\t};\n\t\t\tconst table = createUsageReportTable(tableConfig);\n\n\t\t\t// Add daily data - group by project if instances flag is used\n\t\t\tif (Boolean(mergedOptions.instances) && dailyData.some((d) => d.project != null)) {\n\t\t\t\t// Group data by project for visual separation\n\t\t\t\tconst projectGroups = groupDataByProject(dailyData);\n\n\t\t\t\tlet isFirstProject = true;\n\t\t\t\tfor (const [projectName, projectData] of Object.entries(projectGroups)) {\n\t\t\t\t\t// Add project section header\n\t\t\t\t\tif (!isFirstProject) {\n\t\t\t\t\t\t// Add empty row for visual separation between projects\n\t\t\t\t\t\ttable.push(['', '', '', '', '', '', '', '']);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Add project header row\n\t\t\t\t\ttable.push([\n\t\t\t\t\t\tpc.cyan(`Project: ${formatProjectName(projectName, projectAliases)}`),\n\t\t\t\t\t\t'',\n\t\t\t\t\t\t'',\n\t\t\t\t\t\t'',\n\t\t\t\t\t\t'',\n\t\t\t\t\t\t'',\n\t\t\t\t\t\t'',\n\t\t\t\t\t\t'',\n\t\t\t\t\t]);\n\n\t\t\t\t\t// Add data rows for this project\n\t\t\t\t\tfor (const data of projectData) {\n\t\t\t\t\t\tconst row = formatUsageDataRow(data.date, {\n\t\t\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t\t\t});\n\t\t\t\t\t\ttable.push(row);\n\n\t\t\t\t\t\t// Add model breakdown rows if flag is set\n\t\t\t\t\t\tif (mergedOptions.breakdown) {\n\t\t\t\t\t\t\tpushBreakdownRows(table, data.modelBreakdowns);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tisFirstProject = false;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Standard display without project grouping\n\t\t\t\tfor (const data of dailyData) {\n\t\t\t\t\t// Main row\n\t\t\t\t\tconst row = formatUsageDataRow(data.date, {\n\t\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t\t});\n\t\t\t\t\ttable.push(row);\n\n\t\t\t\t\t// Add model breakdown rows if flag is set\n\t\t\t\t\tif (mergedOptions.breakdown) {\n\t\t\t\t\t\tpushBreakdownRows(table, data.modelBreakdowns);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Add empty row for visual separation before totals\n\t\t\taddEmptySeparatorRow(table, 8);\n\n\t\t\t// Add totals\n\t\t\tconst totalsRow = formatTotalsRow({\n\t\t\t\tinputTokens: totals.inputTokens,\n\t\t\t\toutputTokens: totals.outputTokens,\n\t\t\t\tcacheCreationTokens: totals.cacheCreationTokens,\n\t\t\t\tcacheReadTokens: totals.cacheReadTokens,\n\t\t\t\ttotalCost: totals.totalCost,\n\t\t\t});\n\t\t\ttable.push(totalsRow);\n\n\t\t\tlog(table.toString());\n\n\t\t\t// Show guidance message if in compact mode\n\t\t\tif (table.isCompactMode()) {\n\t\t\t\tlogger.info('\\nRunning in Compact Mode');\n\t\t\t\tlogger.info('Expand terminal width to see cache metrics and total tokens');\n\t\t\t}\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/ccusage/src/commands/index.ts",
    "content": "import process from 'node:process';\nimport { cli } from 'gunshi';\nimport { description, name, version } from '../../package.json';\nimport { blocksCommand } from './blocks.ts';\nimport { dailyCommand } from './daily.ts';\nimport { monthlyCommand } from './monthly.ts';\nimport { sessionCommand } from './session.ts';\nimport { statuslineCommand } from './statusline.ts';\nimport { weeklyCommand } from './weekly.ts';\n\n// Re-export all commands for easy importing\nexport {\n\tblocksCommand,\n\tdailyCommand,\n\tmonthlyCommand,\n\tsessionCommand,\n\tstatuslineCommand,\n\tweeklyCommand,\n};\n\n/**\n * Command entries as tuple array\n */\nexport const subCommandUnion = [\n\t['daily', dailyCommand],\n\t['monthly', monthlyCommand],\n\t['weekly', weeklyCommand],\n\t['session', sessionCommand],\n\t['blocks', blocksCommand],\n\t['statusline', statuslineCommand],\n] as const;\n\n/**\n * Available command names extracted from union\n */\nexport type CommandName = (typeof subCommandUnion)[number][0];\n\n/**\n * Map of available CLI subcommands\n */\nconst subCommands = new Map();\nfor (const [name, command] of subCommandUnion) {\n\tsubCommands.set(name, command);\n}\n\n/**\n * Default command when no subcommand is specified (defaults to daily)\n */\nconst mainCommand = dailyCommand;\n\nexport async function run(): Promise<void> {\n\t// When invoked through npx, the binary name might be passed as the first argument\n\t// Filter it out if it matches the expected binary name\n\tlet args = process.argv.slice(2);\n\tif (args[0] === 'ccusage') {\n\t\targs = args.slice(1);\n\t}\n\n\tawait cli(args, mainCommand, {\n\t\tname,\n\t\tversion,\n\t\tdescription,\n\t\tsubCommands,\n\t\trenderHeader: null,\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/commands/monthly.ts",
    "content": "import type { UsageReportConfig } from '@ccusage/terminal/table';\nimport process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tcreateUsageReportTable,\n\tformatTotalsRow,\n\tformatUsageDataRow,\n\tpushBreakdownRows,\n} from '@ccusage/terminal/table';\nimport { Result } from '@praha/byethrow';\nimport { define } from 'gunshi';\nimport { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts';\nimport { DEFAULT_LOCALE } from '../_consts.ts';\nimport { formatDateCompact } from '../_date-utils.ts';\nimport { processWithJq } from '../_jq-processor.ts';\nimport { sharedCommandConfig } from '../_shared-args.ts';\nimport { calculateTotals, createTotalsObject, getTotalTokens } from '../calculate-cost.ts';\nimport { loadMonthlyUsageData } from '../data-loader.ts';\nimport { detectMismatches, printMismatchReport } from '../debug.ts';\nimport { log, logger } from '../logger.ts';\n\nexport const monthlyCommand = define({\n\tname: 'monthly',\n\tdescription: 'Show usage report grouped by month',\n\t...sharedCommandConfig,\n\tasync run(ctx) {\n\t\t// Load configuration and merge with CLI arguments\n\t\tconst config = loadConfig(ctx.values.config, ctx.values.debug);\n\t\tconst mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug);\n\n\t\t// --jq implies --json\n\t\tconst useJson = Boolean(mergedOptions.json) || mergedOptions.jq != null;\n\t\tif (useJson) {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\tconst monthlyData = await loadMonthlyUsageData(mergedOptions);\n\n\t\tif (monthlyData.length === 0) {\n\t\t\tif (useJson) {\n\t\t\t\tconst emptyOutput = {\n\t\t\t\t\tmonthly: [],\n\t\t\t\t\ttotals: {\n\t\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\t\ttotalCost: 0,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t\tlog(JSON.stringify(emptyOutput, null, 2));\n\t\t\t} else {\n\t\t\t\tlogger.warn('No Claude usage data found.');\n\t\t\t}\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\t// Calculate totals\n\t\tconst totals = calculateTotals(monthlyData);\n\n\t\t// Show debug information if requested\n\t\tif (mergedOptions.debug && !useJson) {\n\t\t\tconst mismatchStats = await detectMismatches(undefined);\n\t\t\tprintMismatchReport(mismatchStats, mergedOptions.debugSamples as number | undefined);\n\t\t}\n\n\t\tif (useJson) {\n\t\t\t// Output JSON format\n\t\t\tconst jsonOutput = {\n\t\t\t\tmonthly: monthlyData.map((data) => ({\n\t\t\t\t\tmonth: data.month,\n\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\ttotalTokens: getTotalTokens(data),\n\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t\tmodelBreakdowns: data.modelBreakdowns,\n\t\t\t\t})),\n\t\t\t\ttotals: createTotalsObject(totals),\n\t\t\t};\n\n\t\t\t// Process with jq if specified\n\t\t\tif (mergedOptions.jq != null) {\n\t\t\t\tconst jqResult = await processWithJq(jsonOutput, mergedOptions.jq);\n\t\t\t\tif (Result.isFailure(jqResult)) {\n\t\t\t\t\tlogger.error(jqResult.error.message);\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\t\t\t\tlog(jqResult.value);\n\t\t\t} else {\n\t\t\t\tlog(JSON.stringify(jsonOutput, null, 2));\n\t\t\t}\n\t\t} else {\n\t\t\t// Print header\n\t\t\tlogger.box('Claude Code Token Usage Report - Monthly');\n\n\t\t\t// Create table with compact mode support\n\t\t\tconst tableConfig: UsageReportConfig = {\n\t\t\t\tfirstColumnName: 'Month',\n\t\t\t\tdateFormatter: (dateStr: string) =>\n\t\t\t\t\tformatDateCompact(\n\t\t\t\t\t\tdateStr,\n\t\t\t\t\t\tmergedOptions.timezone,\n\t\t\t\t\t\tmergedOptions.locale ?? DEFAULT_LOCALE,\n\t\t\t\t\t),\n\t\t\t\tforceCompact: ctx.values.compact,\n\t\t\t};\n\t\t\tconst table = createUsageReportTable(tableConfig);\n\n\t\t\t// Add monthly data\n\t\t\tfor (const data of monthlyData) {\n\t\t\t\t// Main row\n\t\t\t\tconst row = formatUsageDataRow(data.month, {\n\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t});\n\t\t\t\ttable.push(row);\n\n\t\t\t\t// Add model breakdown rows if flag is set\n\t\t\t\tif (mergedOptions.breakdown) {\n\t\t\t\t\tpushBreakdownRows(table, data.modelBreakdowns);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Add empty row for visual separation before totals\n\t\t\taddEmptySeparatorRow(table, 8);\n\n\t\t\t// Add totals\n\t\t\tconst totalsRow = formatTotalsRow({\n\t\t\t\tinputTokens: totals.inputTokens,\n\t\t\t\toutputTokens: totals.outputTokens,\n\t\t\t\tcacheCreationTokens: totals.cacheCreationTokens,\n\t\t\t\tcacheReadTokens: totals.cacheReadTokens,\n\t\t\t\ttotalCost: totals.totalCost,\n\t\t\t});\n\t\t\ttable.push(totalsRow);\n\n\t\t\tlog(table.toString());\n\n\t\t\t// Show guidance message if in compact mode\n\t\t\tif (table.isCompactMode()) {\n\t\t\t\tlogger.info('\\nRunning in Compact Mode');\n\t\t\t\tlogger.info('Expand terminal width to see cache metrics and total tokens');\n\t\t\t}\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/ccusage/src/commands/session.ts",
    "content": "import type { UsageReportConfig } from '@ccusage/terminal/table';\nimport process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tcreateUsageReportTable,\n\tformatTotalsRow,\n\tformatUsageDataRow,\n\tpushBreakdownRows,\n} from '@ccusage/terminal/table';\nimport { Result } from '@praha/byethrow';\nimport { define } from 'gunshi';\nimport { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts';\nimport { DEFAULT_LOCALE } from '../_consts.ts';\nimport { formatDateCompact } from '../_date-utils.ts';\nimport { processWithJq } from '../_jq-processor.ts';\nimport { sharedCommandConfig } from '../_shared-args.ts';\nimport { calculateTotals, createTotalsObject, getTotalTokens } from '../calculate-cost.ts';\nimport { loadSessionData } from '../data-loader.ts';\nimport { detectMismatches, printMismatchReport } from '../debug.ts';\nimport { log, logger } from '../logger.ts';\nimport { handleSessionIdLookup } from './_session_id.ts';\n\n// eslint-disable-next-line ts/no-unused-vars\nconst { order: _, ...sharedArgs } = sharedCommandConfig.args;\n\nexport const sessionCommand = define({\n\tname: 'session',\n\tdescription: 'Show usage report grouped by conversation session',\n\t...sharedCommandConfig,\n\targs: {\n\t\t...sharedArgs,\n\t\tid: {\n\t\t\ttype: 'string',\n\t\t\tshort: 'i',\n\t\t\tdescription: 'Load usage data for a specific session ID',\n\t\t},\n\t},\n\ttoKebab: true,\n\tasync run(ctx): Promise<void> {\n\t\t// Load configuration and merge with CLI arguments\n\t\tconst config = loadConfig(ctx.values.config, ctx.values.debug);\n\t\tconst mergedOptions: typeof ctx.values = mergeConfigWithArgs(ctx, config, ctx.values.debug);\n\n\t\t// --jq implies --json\n\t\tconst useJson = mergedOptions.json || mergedOptions.jq != null;\n\t\tif (useJson) {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\t// Handle specific session ID lookup\n\t\tif (mergedOptions.id != null) {\n\t\t\treturn handleSessionIdLookup(\n\t\t\t\t{\n\t\t\t\t\tvalues: {\n\t\t\t\t\t\tid: mergedOptions.id,\n\t\t\t\t\t\tmode: mergedOptions.mode,\n\t\t\t\t\t\toffline: mergedOptions.offline,\n\t\t\t\t\t\tjq: mergedOptions.jq,\n\t\t\t\t\t\ttimezone: mergedOptions.timezone,\n\t\t\t\t\t\tlocale: mergedOptions.locale ?? DEFAULT_LOCALE,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tuseJson,\n\t\t\t);\n\t\t}\n\n\t\t// Original session listing logic\n\t\tconst sessionData = await loadSessionData({\n\t\t\tsince: ctx.values.since,\n\t\t\tuntil: ctx.values.until,\n\t\t\tmode: ctx.values.mode,\n\t\t\toffline: ctx.values.offline,\n\t\t\ttimezone: ctx.values.timezone,\n\t\t\tlocale: ctx.values.locale,\n\t\t});\n\n\t\tif (sessionData.length === 0) {\n\t\t\tif (useJson) {\n\t\t\t\tlog(JSON.stringify([]));\n\t\t\t} else {\n\t\t\t\tlogger.warn('No Claude usage data found.');\n\t\t\t}\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\t// Calculate totals\n\t\tconst totals = calculateTotals(sessionData);\n\n\t\t// Show debug information if requested\n\t\tif (ctx.values.debug && !useJson) {\n\t\t\tconst mismatchStats = await detectMismatches(undefined);\n\t\t\tprintMismatchReport(mismatchStats, ctx.values.debugSamples);\n\t\t}\n\n\t\tif (useJson) {\n\t\t\t// Output JSON format\n\t\t\tconst jsonOutput = {\n\t\t\t\tsessions: sessionData.map((data) => ({\n\t\t\t\t\tsessionId: data.sessionId,\n\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\ttotalTokens: getTotalTokens(data),\n\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\tlastActivity: data.lastActivity,\n\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t\tmodelBreakdowns: data.modelBreakdowns,\n\t\t\t\t\tprojectPath: data.projectPath,\n\t\t\t\t})),\n\t\t\t\ttotals: createTotalsObject(totals),\n\t\t\t};\n\n\t\t\t// Process with jq if specified\n\t\t\tif (ctx.values.jq != null) {\n\t\t\t\tconst jqResult = await processWithJq(jsonOutput, ctx.values.jq);\n\t\t\t\tif (Result.isFailure(jqResult)) {\n\t\t\t\t\tlogger.error(jqResult.error.message);\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\t\t\t\tlog(jqResult.value);\n\t\t\t} else {\n\t\t\t\tlog(JSON.stringify(jsonOutput, null, 2));\n\t\t\t}\n\t\t} else {\n\t\t\t// Print header\n\t\t\tlogger.box('Claude Code Token Usage Report - By Session');\n\n\t\t\t// Create table with compact mode support\n\t\t\tconst tableConfig: UsageReportConfig = {\n\t\t\t\tfirstColumnName: 'Session',\n\t\t\t\tincludeLastActivity: true,\n\t\t\t\tdateFormatter: (dateStr: string) =>\n\t\t\t\t\tformatDateCompact(dateStr, ctx.values.timezone, ctx.values.locale),\n\t\t\t\tforceCompact: ctx.values.compact,\n\t\t\t};\n\t\t\tconst table = createUsageReportTable(tableConfig);\n\n\t\t\t// Add session data\n\t\t\tlet maxSessionLength = 0;\n\t\t\tfor (const data of sessionData) {\n\t\t\t\tconst sessionDisplay = data.sessionId.split('-').slice(-2).join('-'); // Display last two parts of session ID\n\n\t\t\t\tmaxSessionLength = Math.max(maxSessionLength, sessionDisplay.length);\n\n\t\t\t\t// Main row\n\t\t\t\tconst row = formatUsageDataRow(\n\t\t\t\t\tsessionDisplay,\n\t\t\t\t\t{\n\t\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t\t},\n\t\t\t\t\tdata.lastActivity,\n\t\t\t\t);\n\t\t\t\ttable.push(row);\n\n\t\t\t\t// Add model breakdown rows if flag is set\n\t\t\t\tif (ctx.values.breakdown) {\n\t\t\t\t\t// Session has 1 extra column before data and 1 trailing column\n\t\t\t\t\tpushBreakdownRows(table, data.modelBreakdowns, 1, 1);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Add empty row for visual separation before totals\n\t\t\taddEmptySeparatorRow(table, 9);\n\n\t\t\t// Add totals\n\t\t\tconst totalsRow = formatTotalsRow(\n\t\t\t\t{\n\t\t\t\t\tinputTokens: totals.inputTokens,\n\t\t\t\t\toutputTokens: totals.outputTokens,\n\t\t\t\t\tcacheCreationTokens: totals.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: totals.cacheReadTokens,\n\t\t\t\t\ttotalCost: totals.totalCost,\n\t\t\t\t},\n\t\t\t\ttrue,\n\t\t\t); // Include Last Activity column\n\t\t\ttable.push(totalsRow);\n\n\t\t\tlog(table.toString());\n\n\t\t\t// Show guidance message if in compact mode\n\t\t\tif (table.isCompactMode()) {\n\t\t\t\tlogger.info('\\nRunning in Compact Mode');\n\t\t\t\tlogger.info('Expand terminal width to see cache metrics and total tokens');\n\t\t\t}\n\t\t}\n\t},\n});\n\n// Note: Tests for --id functionality are covered by the existing loadSessionUsageById tests\n// in data-loader.ts, since this command directly uses that function.\n"
  },
  {
    "path": "apps/ccusage/src/commands/statusline.ts",
    "content": "import type { Formatter } from 'picocolors/types';\nimport { mkdirSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport process from 'node:process';\nimport { formatCurrency } from '@ccusage/terminal/table';\nimport { Result } from '@praha/byethrow';\nimport { createLimoJson } from '@ryoppippi/limo';\nimport getStdin from 'get-stdin';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport * as v from 'valibot';\nimport { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts';\nimport { DEFAULT_CONTEXT_USAGE_THRESHOLDS, DEFAULT_REFRESH_INTERVAL_SECONDS } from '../_consts.ts';\nimport { calculateBurnRate } from '../_session-blocks.ts';\nimport { sharedArgs } from '../_shared-args.ts';\nimport { statuslineHookJsonSchema } from '../_types.ts';\nimport { getFileModifiedTime, unreachable } from '../_utils.ts';\nimport { calculateTotals } from '../calculate-cost.ts';\nimport {\n\tcalculateContextTokens,\n\tloadDailyUsageData,\n\tloadSessionBlockData,\n\tloadSessionUsageById,\n} from '../data-loader.ts';\nimport { log, logger } from '../logger.ts';\n\n/**\n * Formats the remaining time for display\n * @param remaining - Remaining minutes\n * @returns Formatted time string\n */\nfunction formatRemainingTime(remaining: number): string {\n\tconst remainingHours = Math.floor(remaining / 60);\n\tconst remainingMins = remaining % 60;\n\n\tif (remainingHours > 0) {\n\t\treturn `${remainingHours}h ${remainingMins}m left`;\n\t}\n\treturn `${remainingMins}m left`;\n}\n\n/**\n * Gets semaphore file for session-specific caching and process coordination\n * Uses time-based expiry and transcript file modification detection for cache invalidation\n */\nfunction getSemaphore(\n\tsessionId: string,\n): ReturnType<typeof createLimoJson<SemaphoreType | undefined>> {\n\tconst semaphoreDir = join(tmpdir(), 'ccusage-semaphore');\n\tconst semaphorePath = join(semaphoreDir, `${sessionId}.lock`);\n\n\t// Ensure semaphore directory exists\n\tmkdirSync(semaphoreDir, { recursive: true });\n\n\tconst semaphore = createLimoJson<SemaphoreType>(semaphorePath);\n\treturn semaphore;\n}\n\n/**\n * Semaphore structure for hybrid caching system\n * Combines time-based expiry with transcript file modification detection\n */\ntype SemaphoreType = {\n\t/** ISO timestamp of last update */\n\tdate: string;\n\t/** Cached status line output */\n\tlastOutput: string;\n\t/** Timestamp (milliseconds) of last successful update for time-based expiry */\n\tlastUpdateTime: number;\n\t/** Last processed transcript file path */\n\ttranscriptPath: string;\n\t/** Last modification time of transcript file for change detection */\n\ttranscriptMtime: number;\n\t/** Whether another process is currently updating (prevents concurrent updates) */\n\tisUpdating?: boolean;\n\t/** Process ID of updating process for deadlock detection */\n\tpid?: number;\n};\n\nconst visualBurnRateChoices = ['off', 'emoji', 'text', 'emoji-text'] as const;\nconst costSourceChoices = ['auto', 'ccusage', 'cc', 'both'] as const;\n\n// Valibot schema for context threshold validation\nconst contextThresholdSchema = v.pipe(\n\tv.union([\n\t\tv.number(),\n\t\tv.pipe(\n\t\t\tv.string(),\n\t\t\tv.trim(),\n\t\t\tv.check((value) => /^-?\\d+$/u.test(value), 'Context threshold must be an integer'),\n\t\t\tv.transform((value) => Number.parseInt(value, 10)),\n\t\t),\n\t]),\n\tv.number('Context threshold must be a number'),\n\tv.integer('Context threshold must be an integer'),\n\tv.minValue(0, 'Context threshold must be at least 0'),\n\tv.maxValue(100, 'Context threshold must be at most 100'),\n);\n\nfunction parseContextThreshold(value: string): number {\n\treturn v.parse(contextThresholdSchema, value);\n}\n\nexport const statuslineCommand = define({\n\tname: 'statusline',\n\tdescription:\n\t\t'Display compact status line for Claude Code hooks with hybrid time+file caching (Beta)',\n\ttoKebab: true,\n\targs: {\n\t\toffline: {\n\t\t\t...sharedArgs.offline,\n\t\t\tdefault: true, // Default to offline mode for faster performance\n\t\t},\n\t\tvisualBurnRate: {\n\t\t\ttype: 'enum',\n\t\t\tchoices: visualBurnRateChoices,\n\t\t\tdescription: 'Controls the visualization of the burn rate status',\n\t\t\tdefault: 'off',\n\t\t\t// Use capital 'B' to avoid conflicts and follow 1-letter short alias rule\n\t\t\tshort: 'B',\n\t\t\tnegatable: false,\n\t\t\ttoKebab: true,\n\t\t},\n\t\tcostSource: {\n\t\t\ttype: 'enum',\n\t\t\tchoices: costSourceChoices,\n\t\t\tdescription:\n\t\t\t\t'Session cost source: auto (prefer CC then ccusage), ccusage (always calculate), cc (always use Claude Code cost), both (show both costs)',\n\t\t\tdefault: 'auto',\n\t\t\tnegatable: false,\n\t\t\ttoKebab: true,\n\t\t},\n\t\tcache: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Enable cache for status line output (default: true)',\n\t\t\tnegatable: true,\n\t\t\tdefault: true,\n\t\t},\n\t\trefreshInterval: {\n\t\t\ttype: 'number',\n\t\t\tdescription: `Refresh interval in seconds for cache expiry (default: ${DEFAULT_REFRESH_INTERVAL_SECONDS})`,\n\t\t\tdefault: DEFAULT_REFRESH_INTERVAL_SECONDS,\n\t\t},\n\t\tcontextLowThreshold: {\n\t\t\ttype: 'custom',\n\t\t\tdescription: 'Context usage percentage below which status is shown in green (0-100)',\n\t\t\tparse: (value) => parseContextThreshold(value),\n\t\t\tdefault: DEFAULT_CONTEXT_USAGE_THRESHOLDS.LOW,\n\t\t},\n\t\tcontextMediumThreshold: {\n\t\t\ttype: 'custom',\n\t\t\tdescription: 'Context usage percentage below which status is shown in yellow (0-100)',\n\t\t\tparse: (value) => parseContextThreshold(value),\n\t\t\tdefault: DEFAULT_CONTEXT_USAGE_THRESHOLDS.MEDIUM,\n\t\t},\n\t\tconfig: sharedArgs.config,\n\t\tdebug: sharedArgs.debug,\n\t},\n\tasync run(ctx) {\n\t\t// Set logger to silent for statusline output\n\t\tlogger.level = 0;\n\n\t\t// Validate threshold ordering constraint: LOW must be less than MEDIUM\n\t\tif (ctx.values.contextLowThreshold >= ctx.values.contextMediumThreshold) {\n\t\t\tthrow new Error(\n\t\t\t\t`Context low threshold (${ctx.values.contextLowThreshold}) must be less than medium threshold (${ctx.values.contextMediumThreshold})`,\n\t\t\t);\n\t\t}\n\n\t\t// Load configuration and merge with CLI args\n\t\tconst config = loadConfig(ctx.values.config, ctx.values.debug);\n\t\tconst mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug);\n\n\t\t// Use refresh interval from merged options\n\t\tconst refreshInterval = mergedOptions.refreshInterval;\n\n\t\t// Read input from stdin\n\t\tconst stdin = await getStdin();\n\t\tif (stdin.length === 0) {\n\t\t\tlog('❌ No input provided');\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\t// Parse input as JSON\n\t\tconst hookDataJson: unknown = JSON.parse(stdin.trim());\n\t\tconst hookDataParseResult = v.safeParse(statuslineHookJsonSchema, hookDataJson);\n\t\tif (!hookDataParseResult.success) {\n\t\t\tlog('❌ Invalid input format:', v.flatten(hookDataParseResult.issues));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconst hookData = hookDataParseResult.output;\n\n\t\t// Extract session ID from hook data\n\t\tconst sessionId = hookData.session_id;\n\n\t\t/**\n\t\t * Read initial semaphore state for cache validation and process checking\n\t\t * This is a snapshot taken at the beginning to avoid race conditions\n\t\t */\n\t\tconst initialSemaphoreState = Result.pipe(\n\t\t\tResult.succeed(getSemaphore(sessionId)),\n\t\t\tResult.map((semaphore) => semaphore.data),\n\t\t\tResult.unwrap(undefined),\n\t\t);\n\n\t\t// Get current file modification time for cache validation and semaphore update\n\t\tconst currentMtime = await getFileModifiedTime(hookData.transcript_path);\n\n\t\tif (mergedOptions.cache && initialSemaphoreState != null) {\n\t\t\t/**\n\t\t\t * Hybrid cache validation:\n\t\t\t * 1. Time-based expiry: Cache expires after refreshInterval seconds\n\t\t\t * 2. File modification: Immediate invalidation when transcript file is modified\n\t\t\t * This ensures real-time updates while maintaining good performance\n\t\t\t */\n\t\t\tconst now = Date.now();\n\t\t\tconst timeElapsed = now - (initialSemaphoreState.lastUpdateTime ?? 0);\n\t\t\tconst isExpired = timeElapsed >= refreshInterval * 1000;\n\t\t\tconst isFileModified = initialSemaphoreState.transcriptMtime !== currentMtime;\n\n\t\t\tif (!isExpired && !isFileModified) {\n\t\t\t\t// Cache is still valid, return cached output\n\t\t\t\tlog(initialSemaphoreState.lastOutput);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// If another process is updating, return stale output\n\t\t\tif (initialSemaphoreState.isUpdating === true) {\n\t\t\t\t// Check if the updating process is still alive (optional deadlock protection)\n\t\t\t\tconst pid = initialSemaphoreState.pid;\n\t\t\t\tlet isProcessAlive = false;\n\t\t\t\tif (pid != null) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tprocess.kill(pid, 0); // Signal 0 doesn't kill, just checks if process exists\n\t\t\t\t\t\tisProcessAlive = true;\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Process doesn't exist, likely dead\n\t\t\t\t\t\tisProcessAlive = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (isProcessAlive) {\n\t\t\t\t\t// Another process is actively updating, return stale output\n\t\t\t\t\tlog(initialSemaphoreState.lastOutput);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Process is dead, continue to update ourselves\n\t\t\t}\n\t\t}\n\n\t\t// Acquisition phase: Mark as updating\n\t\t{\n\t\t\tconst currentPid = process.pid;\n\t\t\tusing semaphore = getSemaphore(sessionId);\n\t\t\tif (semaphore.data != null) {\n\t\t\t\tsemaphore.data = {\n\t\t\t\t\t...semaphore.data,\n\t\t\t\t\tisUpdating: true,\n\t\t\t\t\tpid: currentPid,\n\t\t\t\t} as const satisfies SemaphoreType;\n\t\t\t} else {\n\t\t\t\tconst currentMtimeForInit = await getFileModifiedTime(hookData.transcript_path);\n\t\t\t\tsemaphore.data = {\n\t\t\t\t\tdate: new Date().toISOString(),\n\t\t\t\t\tlastOutput: '',\n\t\t\t\t\tlastUpdateTime: 0,\n\t\t\t\t\ttranscriptPath: hookData.transcript_path,\n\t\t\t\t\ttranscriptMtime: currentMtimeForInit,\n\t\t\t\t\tisUpdating: true,\n\t\t\t\t\tpid: currentPid,\n\t\t\t\t} as const satisfies SemaphoreType;\n\t\t\t}\n\t\t}\n\n\t\tconst mainProcessingResult = Result.pipe(\n\t\t\tawait Result.try({\n\t\t\t\ttry: async () => {\n\t\t\t\t\t// Determine session cost based on cost source\n\t\t\t\t\tconst { sessionCost, ccCost, ccusageCost } = await (async (): Promise<{\n\t\t\t\t\t\tsessionCost?: number;\n\t\t\t\t\t\tccCost?: number;\n\t\t\t\t\t\tccusageCost?: number;\n\t\t\t\t\t}> => {\n\t\t\t\t\t\tconst costSource = ctx.values.costSource;\n\n\t\t\t\t\t\t// Helper function to get ccusage cost\n\t\t\t\t\t\tconst getCcusageCost = async (): Promise<number | undefined> => {\n\t\t\t\t\t\t\treturn Result.pipe(\n\t\t\t\t\t\t\t\tResult.try({\n\t\t\t\t\t\t\t\t\ttry: async () =>\n\t\t\t\t\t\t\t\t\t\tloadSessionUsageById(sessionId, {\n\t\t\t\t\t\t\t\t\t\t\tmode: 'auto',\n\t\t\t\t\t\t\t\t\t\t\toffline: mergedOptions.offline,\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\tcatch: (error) => error,\n\t\t\t\t\t\t\t\t})(),\n\t\t\t\t\t\t\t\tResult.map((sessionCost) => sessionCost?.totalCost),\n\t\t\t\t\t\t\t\tResult.inspectError((error) => logger.error('Failed to load session data:', error)),\n\t\t\t\t\t\t\t\tResult.unwrap(undefined),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t};\n\n\t\t\t\t\t\t// If 'both' mode, calculate both costs\n\t\t\t\t\t\tif (costSource === 'both') {\n\t\t\t\t\t\t\tconst ccCost = hookData.cost?.total_cost_usd;\n\t\t\t\t\t\t\tconst ccusageCost = await getCcusageCost();\n\t\t\t\t\t\t\treturn { ccCost, ccusageCost };\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// If 'cc' mode and cost is available from Claude Code, use it\n\t\t\t\t\t\tif (costSource === 'cc') {\n\t\t\t\t\t\t\treturn { sessionCost: hookData.cost?.total_cost_usd };\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// If 'ccusage' mode, always calculate using ccusage\n\t\t\t\t\t\tif (costSource === 'ccusage') {\n\t\t\t\t\t\t\tconst cost = await getCcusageCost();\n\t\t\t\t\t\t\treturn { sessionCost: cost };\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// If 'auto' mode (default), prefer Claude Code cost, fallback to ccusage\n\t\t\t\t\t\tif (costSource === 'auto') {\n\t\t\t\t\t\t\tif (hookData.cost?.total_cost_usd != null) {\n\t\t\t\t\t\t\t\treturn { sessionCost: hookData.cost.total_cost_usd };\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// Fallback to ccusage calculation\n\t\t\t\t\t\t\tconst cost = await getCcusageCost();\n\t\t\t\t\t\t\treturn { sessionCost: cost };\n\t\t\t\t\t\t}\n\t\t\t\t\t\tunreachable(costSource);\n\t\t\t\t\t\treturn {}; // This line should never be reached\n\t\t\t\t\t})();\n\n\t\t\t\t\t// Load today's usage data\n\t\t\t\t\tconst today = new Date();\n\t\t\t\t\tconst todayStr = today.toISOString().split('T')[0]?.replace(/-/g, '') ?? ''; // Convert to YYYYMMDD format\n\n\t\t\t\t\tconst todayCost = await Result.pipe(\n\t\t\t\t\t\tResult.try({\n\t\t\t\t\t\t\ttry: async () =>\n\t\t\t\t\t\t\t\tloadDailyUsageData({\n\t\t\t\t\t\t\t\t\tsince: todayStr,\n\t\t\t\t\t\t\t\t\tuntil: todayStr,\n\t\t\t\t\t\t\t\t\tmode: 'auto',\n\t\t\t\t\t\t\t\t\toffline: mergedOptions.offline,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\tcatch: (error) => error,\n\t\t\t\t\t\t})(),\n\t\t\t\t\t\tResult.map((dailyData) => {\n\t\t\t\t\t\t\tif (dailyData.length > 0) {\n\t\t\t\t\t\t\t\tconst totals = calculateTotals(dailyData);\n\t\t\t\t\t\t\t\treturn totals.totalCost;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn 0;\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tResult.inspectError((error) => logger.error('Failed to load daily data:', error)),\n\t\t\t\t\t\tResult.unwrap(0),\n\t\t\t\t\t);\n\n\t\t\t\t\t// Load session block data to find active block\n\t\t\t\t\tconst { blockInfo, burnRateInfo } = await Result.pipe(\n\t\t\t\t\t\tResult.try({\n\t\t\t\t\t\t\ttry: async () =>\n\t\t\t\t\t\t\t\tloadSessionBlockData({\n\t\t\t\t\t\t\t\t\tmode: 'auto',\n\t\t\t\t\t\t\t\t\toffline: mergedOptions.offline,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\tcatch: (error) => error,\n\t\t\t\t\t\t})(),\n\t\t\t\t\t\tResult.map((blocks) => {\n\t\t\t\t\t\t\t// Only identify blocks if we have data\n\t\t\t\t\t\t\tif (blocks.length === 0) {\n\t\t\t\t\t\t\t\treturn { blockInfo: 'No active block', burnRateInfo: '' };\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// Find active block that contains our session\n\t\t\t\t\t\t\tconst activeBlock = blocks.find((block) => {\n\t\t\t\t\t\t\t\tif (!block.isActive) {\n\t\t\t\t\t\t\t\t\treturn false;\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t// Check if any entry in this block matches our session\n\t\t\t\t\t\t\t\t// Since we don't have direct session mapping in entries,\n\t\t\t\t\t\t\t\t// we use the active block that's currently running\n\t\t\t\t\t\t\t\treturn true;\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\tif (activeBlock != null) {\n\t\t\t\t\t\t\t\tconst now = new Date();\n\t\t\t\t\t\t\t\tconst remaining = Math.round(\n\t\t\t\t\t\t\t\t\t(activeBlock.endTime.getTime() - now.getTime()) / (1000 * 60),\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tconst blockCost = activeBlock.costUSD;\n\n\t\t\t\t\t\t\t\tconst blockInfo = `${formatCurrency(blockCost)} block (${formatRemainingTime(remaining)})`;\n\n\t\t\t\t\t\t\t\t// Calculate burn rate\n\t\t\t\t\t\t\t\tconst burnRate = calculateBurnRate(activeBlock);\n\t\t\t\t\t\t\t\tconst burnRateInfo =\n\t\t\t\t\t\t\t\t\tburnRate != null\n\t\t\t\t\t\t\t\t\t\t? (() => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst renderEmojiStatus =\n\t\t\t\t\t\t\t\t\t\t\t\t\tctx.values.visualBurnRate === 'emoji' ||\n\t\t\t\t\t\t\t\t\t\t\t\t\tctx.values.visualBurnRate === 'emoji-text';\n\t\t\t\t\t\t\t\t\t\t\t\tconst renderTextStatus =\n\t\t\t\t\t\t\t\t\t\t\t\t\tctx.values.visualBurnRate === 'text' ||\n\t\t\t\t\t\t\t\t\t\t\t\t\tctx.values.visualBurnRate === 'emoji-text';\n\t\t\t\t\t\t\t\t\t\t\t\tconst costPerHour = burnRate.costPerHour;\n\t\t\t\t\t\t\t\t\t\t\t\tconst costPerHourStr = `${formatCurrency(costPerHour)}/hr`;\n\n\t\t\t\t\t\t\t\t\t\t\t\ttype BurnStatus = 'normal' | 'moderate' | 'high';\n\n\t\t\t\t\t\t\t\t\t\t\t\tconst burnStatus: BurnStatus =\n\t\t\t\t\t\t\t\t\t\t\t\t\tburnRate.tokensPerMinuteForIndicator < 2000\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'normal'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: burnRate.tokensPerMinuteForIndicator < 5000\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'moderate'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'high';\n\n\t\t\t\t\t\t\t\t\t\t\t\tconst burnStatusMappings: Record<\n\t\t\t\t\t\t\t\t\t\t\t\t\tBurnStatus,\n\t\t\t\t\t\t\t\t\t\t\t\t\t{ emoji: string; textValue: string; coloredString: Formatter }\n\t\t\t\t\t\t\t\t\t\t\t\t> = {\n\t\t\t\t\t\t\t\t\t\t\t\t\tnormal: { emoji: '🟢', textValue: 'Normal', coloredString: pc.green },\n\t\t\t\t\t\t\t\t\t\t\t\t\tmoderate: {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\temoji: '⚠️',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttextValue: 'Moderate',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tcoloredString: pc.yellow,\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t\thigh: { emoji: '🚨', textValue: 'High', coloredString: pc.red },\n\t\t\t\t\t\t\t\t\t\t\t\t};\n\n\t\t\t\t\t\t\t\t\t\t\t\tconst { emoji, textValue, coloredString } = burnStatusMappings[burnStatus];\n\n\t\t\t\t\t\t\t\t\t\t\t\tconst burnRateOutputSegments: string[] = [coloredString(costPerHourStr)];\n\n\t\t\t\t\t\t\t\t\t\t\t\tif (renderEmojiStatus) {\n\t\t\t\t\t\t\t\t\t\t\t\t\tburnRateOutputSegments.push(emoji);\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t\t\tif (renderTextStatus) {\n\t\t\t\t\t\t\t\t\t\t\t\t\tburnRateOutputSegments.push(coloredString(`(${textValue})`));\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t\t\treturn ` | 🔥 ${burnRateOutputSegments.join(' ')}`;\n\t\t\t\t\t\t\t\t\t\t\t})()\n\t\t\t\t\t\t\t\t\t\t: '';\n\n\t\t\t\t\t\t\t\treturn { blockInfo, burnRateInfo };\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\treturn { blockInfo: 'No active block', burnRateInfo: '' };\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tResult.inspectError((error) => logger.error('Failed to load block data:', error)),\n\t\t\t\t\t\tResult.unwrap({ blockInfo: 'No active block', burnRateInfo: '' }),\n\t\t\t\t\t);\n\n\t\t\t\t\t// Helper function to format context info with color coding\n\t\t\t\t\tconst formatContextInfo = (inputTokens: number, contextLimit: number): string => {\n\t\t\t\t\t\tconst percentage = Math.round((inputTokens / contextLimit) * 100);\n\t\t\t\t\t\tconst color =\n\t\t\t\t\t\t\tpercentage < ctx.values.contextLowThreshold\n\t\t\t\t\t\t\t\t? pc.green\n\t\t\t\t\t\t\t\t: percentage < ctx.values.contextMediumThreshold\n\t\t\t\t\t\t\t\t\t? pc.yellow\n\t\t\t\t\t\t\t\t\t: pc.red;\n\t\t\t\t\t\tconst coloredPercentage = color(`${percentage}%`);\n\t\t\t\t\t\tconst tokenDisplay = inputTokens.toLocaleString();\n\t\t\t\t\t\treturn `${tokenDisplay} (${coloredPercentage})`;\n\t\t\t\t\t};\n\n\t\t\t\t\t// Get context tokens from Claude Code hook data, or fall back to calculating from transcript\n\t\t\t\t\tconst contextDataResult =\n\t\t\t\t\t\thookData.context_window != null\n\t\t\t\t\t\t\t? // Prefer context_window data from Claude Code hook if available\n\t\t\t\t\t\t\t\tResult.succeed({\n\t\t\t\t\t\t\t\t\tinputTokens: hookData.context_window.total_input_tokens,\n\t\t\t\t\t\t\t\t\tcontextLimit: hookData.context_window.context_window_size,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t: // Fall back to calculating context tokens from transcript\n\t\t\t\t\t\t\t\tawait Result.try({\n\t\t\t\t\t\t\t\t\ttry: async () =>\n\t\t\t\t\t\t\t\t\t\tcalculateContextTokens(\n\t\t\t\t\t\t\t\t\t\t\thookData.transcript_path,\n\t\t\t\t\t\t\t\t\t\t\thookData.model.id,\n\t\t\t\t\t\t\t\t\t\t\tmergedOptions.offline,\n\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\tcatch: (error) => error,\n\t\t\t\t\t\t\t\t})();\n\n\t\t\t\t\tconst contextInfo = Result.pipe(\n\t\t\t\t\t\tcontextDataResult,\n\t\t\t\t\t\tResult.inspectError((error) =>\n\t\t\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t\t\t`Failed to calculate context tokens: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\t\t\t\t\t\tResult.map((contextResult) => {\n\t\t\t\t\t\t\tif (contextResult == null) {\n\t\t\t\t\t\t\t\treturn undefined;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn formatContextInfo(contextResult.inputTokens, contextResult.contextLimit);\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tResult.unwrap(undefined),\n\t\t\t\t\t);\n\n\t\t\t\t\t// Get model display name\n\t\t\t\t\tconst modelName = hookData.model.display_name;\n\n\t\t\t\t\t// Format and output the status line\n\t\t\t\t\t// Format: 🤖 model | 💰 session / today / block | 🔥 burn | 🧠 context\n\t\t\t\t\tconst sessionDisplay = (() => {\n\t\t\t\t\t\t// If both costs are available, show them side by side\n\t\t\t\t\t\tif (ccCost != null || ccusageCost != null) {\n\t\t\t\t\t\t\tconst ccDisplay = ccCost != null ? formatCurrency(ccCost) : 'N/A';\n\t\t\t\t\t\t\tconst ccusageDisplay = ccusageCost != null ? formatCurrency(ccusageCost) : 'N/A';\n\t\t\t\t\t\t\treturn `(${ccDisplay} cc / ${ccusageDisplay} ccusage)`;\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Single cost display\n\t\t\t\t\t\treturn sessionCost != null ? formatCurrency(sessionCost) : 'N/A';\n\t\t\t\t\t})();\n\t\t\t\t\tconst statusLine = `🤖 ${modelName} | 💰 ${sessionDisplay} session / ${formatCurrency(todayCost)} today / ${blockInfo}${burnRateInfo} | 🧠 ${contextInfo ?? 'N/A'}`;\n\t\t\t\t\treturn statusLine;\n\t\t\t\t},\n\t\t\t\tcatch: (error) => error,\n\t\t\t})(),\n\t\t);\n\n\t\tif (Result.isSuccess(mainProcessingResult)) {\n\t\t\tconst statusLine = mainProcessingResult.value;\n\t\t\tlog(statusLine);\n\t\t\tif (!mergedOptions.cache) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// update semaphore with result (use mtime from cache validation time)\n\t\t\tusing semaphore = getSemaphore(sessionId);\n\t\t\tsemaphore.data = {\n\t\t\t\tdate: new Date().toISOString(),\n\t\t\t\tlastOutput: statusLine,\n\t\t\t\tlastUpdateTime: Date.now(),\n\t\t\t\ttranscriptPath: hookData.transcript_path,\n\t\t\t\ttranscriptMtime: currentMtime, // Use mtime from when we started processing\n\t\t\t\tisUpdating: false,\n\t\t\t\tpid: undefined,\n\t\t\t};\n\t\t\treturn;\n\t\t}\n\n\t\t// Handle processing result\n\t\tif (Result.isFailure(mainProcessingResult)) {\n\t\t\t// Reset updating flag on error to prevent deadlock\n\n\t\t\t// If we have a cached output from previous run, use it\n\t\t\tif (initialSemaphoreState?.lastOutput != null && initialSemaphoreState.lastOutput !== '') {\n\t\t\t\tlog(initialSemaphoreState.lastOutput);\n\t\t\t} else {\n\t\t\t\t// Fallback minimal output\n\t\t\t\tlog('❌ Error generating status');\n\t\t\t}\n\n\t\t\tlogger.error('Error in statusline command:', mainProcessingResult.error);\n\n\t\t\tif (!mergedOptions.cache) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Release semaphore and reset updating flag\n\t\t\tusing semaphore = getSemaphore(sessionId);\n\t\t\tif (semaphore.data != null) {\n\t\t\t\tsemaphore.data.isUpdating = false;\n\t\t\t\tsemaphore.data.pid = undefined;\n\t\t\t}\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/ccusage/src/commands/weekly.ts",
    "content": "import type { UsageReportConfig } from '@ccusage/terminal/table';\nimport process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tcreateUsageReportTable,\n\tformatTotalsRow,\n\tformatUsageDataRow,\n\tpushBreakdownRows,\n} from '@ccusage/terminal/table';\nimport { Result } from '@praha/byethrow';\nimport { define } from 'gunshi';\nimport { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts';\nimport { WEEK_DAYS } from '../_consts.ts';\nimport { formatDateCompact } from '../_date-utils.ts';\nimport { processWithJq } from '../_jq-processor.ts';\nimport { sharedArgs } from '../_shared-args.ts';\nimport { calculateTotals, createTotalsObject, getTotalTokens } from '../calculate-cost.ts';\nimport { loadWeeklyUsageData } from '../data-loader.ts';\nimport { detectMismatches, printMismatchReport } from '../debug.ts';\nimport { log, logger } from '../logger.ts';\n\nexport const weeklyCommand = define({\n\tname: 'weekly',\n\tdescription: 'Show usage report grouped by week',\n\targs: {\n\t\t...sharedArgs,\n\t\tstartOfWeek: {\n\t\t\ttype: 'enum',\n\t\t\tshort: 'w',\n\t\t\tdescription: 'Day to start the week on',\n\t\t\tdefault: 'sunday' as const,\n\t\t\tchoices: WEEK_DAYS,\n\t\t},\n\t},\n\ttoKebab: true,\n\tasync run(ctx) {\n\t\t// Load configuration and merge with CLI arguments\n\t\tconst config = loadConfig(ctx.values.config, ctx.values.debug);\n\t\tconst mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug);\n\n\t\t// --jq implies --json\n\t\tconst useJson = Boolean(mergedOptions.json) || mergedOptions.jq != null;\n\t\tif (useJson) {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\tconst weeklyData = await loadWeeklyUsageData(mergedOptions);\n\n\t\tif (weeklyData.length === 0) {\n\t\t\tif (useJson) {\n\t\t\t\tconst emptyOutput = {\n\t\t\t\t\tweekly: [],\n\t\t\t\t\ttotals: {\n\t\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\t\ttotalCost: 0,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t\tlog(JSON.stringify(emptyOutput, null, 2));\n\t\t\t} else {\n\t\t\t\tlogger.warn('No Claude usage data found.');\n\t\t\t}\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\t// Calculate totals\n\t\tconst totals = calculateTotals(weeklyData);\n\n\t\t// Show debug information if requested\n\t\tif (mergedOptions.debug && !useJson) {\n\t\t\tconst mismatchStats = await detectMismatches(undefined);\n\t\t\tprintMismatchReport(mismatchStats, mergedOptions.debugSamples as number | undefined);\n\t\t}\n\n\t\tif (useJson) {\n\t\t\t// Output JSON format\n\t\t\tconst jsonOutput = {\n\t\t\t\tweekly: weeklyData.map((data) => ({\n\t\t\t\t\tweek: data.week,\n\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\ttotalTokens: getTotalTokens(data),\n\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t\tmodelBreakdowns: data.modelBreakdowns,\n\t\t\t\t})),\n\t\t\t\ttotals: createTotalsObject(totals),\n\t\t\t};\n\n\t\t\t// Process with jq if specified\n\t\t\tif (mergedOptions.jq != null) {\n\t\t\t\tconst jqResult = await processWithJq(jsonOutput, mergedOptions.jq);\n\t\t\t\tif (Result.isFailure(jqResult)) {\n\t\t\t\t\tlogger.error(jqResult.error.message);\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\t\t\t\tlog(jqResult.value);\n\t\t\t} else {\n\t\t\t\tlog(JSON.stringify(jsonOutput, null, 2));\n\t\t\t}\n\t\t} else {\n\t\t\t// Print header\n\t\t\tlogger.box('Claude Code Token Usage Report - Weekly');\n\n\t\t\t// Create table with compact mode support\n\t\t\tconst tableConfig: UsageReportConfig = {\n\t\t\t\tfirstColumnName: 'Week',\n\t\t\t\tdateFormatter: (dateStr: string) =>\n\t\t\t\t\tformatDateCompact(dateStr, mergedOptions.timezone, mergedOptions.locale ?? undefined),\n\t\t\t\tforceCompact: ctx.values.compact,\n\t\t\t};\n\t\t\tconst table = createUsageReportTable(tableConfig);\n\n\t\t\t// Add weekly data\n\t\t\tfor (const data of weeklyData) {\n\t\t\t\t// Main row\n\t\t\t\tconst row = formatUsageDataRow(data.week, {\n\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t});\n\t\t\t\ttable.push(row);\n\n\t\t\t\t// Add model breakdown rows if flag is set\n\t\t\t\tif (mergedOptions.breakdown) {\n\t\t\t\t\tpushBreakdownRows(table, data.modelBreakdowns);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Add empty row for visual separation before totals\n\t\t\taddEmptySeparatorRow(table, 8);\n\n\t\t\t// Add totals\n\t\t\tconst totalsRow = formatTotalsRow({\n\t\t\t\tinputTokens: totals.inputTokens,\n\t\t\t\toutputTokens: totals.outputTokens,\n\t\t\t\tcacheCreationTokens: totals.cacheCreationTokens,\n\t\t\t\tcacheReadTokens: totals.cacheReadTokens,\n\t\t\t\ttotalCost: totals.totalCost,\n\t\t\t});\n\t\t\ttable.push(totalsRow);\n\n\t\t\tlog(table.toString());\n\n\t\t\t// Show guidance message if in compact mode\n\t\t\tif (table.isCompactMode()) {\n\t\t\t\tlogger.info('\\nRunning in Compact Mode');\n\t\t\t\tlogger.info('Expand terminal width to see cache metrics and total tokens');\n\t\t\t}\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/ccusage/src/data-loader.ts",
    "content": "/**\n * @fileoverview Data loading utilities for Claude Code usage analysis\n *\n * This module provides functions for loading and parsing Claude Code usage data\n * from JSONL files stored in Claude data directories. It handles data aggregation\n * for daily, monthly, and session-based reporting.\n *\n * @module data-loader\n */\n\nimport type { WeekDay } from './_consts.ts';\nimport type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts';\nimport type { ActivityDate, Bucket, CostMode, ModelName, SortOrder, Version } from './_types.ts';\nimport { Buffer } from 'node:buffer';\nimport { createReadStream, createWriteStream } from 'node:fs';\nimport { readFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport process from 'node:process';\nimport { createInterface } from 'node:readline';\nimport { toArray } from '@antfu/utils';\nimport { Result } from '@praha/byethrow';\nimport { groupBy, uniq } from 'es-toolkit'; // TODO: after node20 is deprecated, switch to native Object.groupBy\nimport { createFixture } from 'fs-fixture';\nimport { isDirectorySync } from 'path-type';\nimport { glob } from 'tinyglobby';\nimport * as v from 'valibot';\nimport {\n\tCLAUDE_CONFIG_DIR_ENV,\n\tCLAUDE_PROJECTS_DIR_NAME,\n\tDEFAULT_CLAUDE_CODE_PATH,\n\tDEFAULT_CLAUDE_CONFIG_PATH,\n\tDEFAULT_LOCALE,\n\tUSAGE_DATA_GLOB_PATTERN,\n\tUSER_HOME_DIR,\n} from './_consts.ts';\nimport {\n\tfilterByDateRange,\n\tformatDate,\n\tformatDateCompact,\n\tgetDateWeek,\n\tgetDayNumber,\n\tsortByDate,\n} from './_date-utils.ts';\nimport { PricingFetcher } from './_pricing-fetcher.ts';\nimport { identifySessionBlocks } from './_session-blocks.ts';\nimport {\n\tactivityDateSchema,\n\tcreateBucket,\n\tcreateDailyDate,\n\tcreateISOTimestamp,\n\tcreateMessageId,\n\tcreateModelName,\n\tcreateMonthlyDate,\n\tcreateProjectPath,\n\tcreateRequestId,\n\tcreateSessionId,\n\tcreateVersion,\n\tdailyDateSchema,\n\tisoTimestampSchema,\n\tmessageIdSchema,\n\tmodelNameSchema,\n\tmonthlyDateSchema,\n\tprojectPathSchema,\n\trequestIdSchema,\n\tsessionIdSchema,\n\tversionSchema,\n\tweeklyDateSchema,\n} from './_types.ts';\nimport { unreachable } from './_utils.ts';\nimport { logger } from './logger.ts';\n\n/**\n * Get Claude data directories to search for usage data\n * When CLAUDE_CONFIG_DIR is set: uses only those paths\n * When not set: uses default paths (~/.config/claude and ~/.claude)\n * @returns Array of valid Claude data directory paths\n */\nexport function getClaudePaths(): string[] {\n\tconst paths = [];\n\tconst normalizedPaths = new Set<string>();\n\n\t// Check environment variable first (supports comma-separated paths)\n\tconst envPaths = (process.env[CLAUDE_CONFIG_DIR_ENV] ?? '').trim();\n\tif (envPaths !== '') {\n\t\tconst envPathList = envPaths\n\t\t\t.split(',')\n\t\t\t.map((p) => p.trim())\n\t\t\t.filter((p) => p !== '');\n\t\tfor (const envPath of envPathList) {\n\t\t\tconst normalizedPath = path.resolve(envPath);\n\t\t\tif (isDirectorySync(normalizedPath)) {\n\t\t\t\tconst projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME);\n\t\t\t\tif (isDirectorySync(projectsPath)) {\n\t\t\t\t\t// Avoid duplicates using normalized paths\n\t\t\t\t\tif (!normalizedPaths.has(normalizedPath)) {\n\t\t\t\t\t\tnormalizedPaths.add(normalizedPath);\n\t\t\t\t\t\tpaths.push(normalizedPath);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// If environment variable is set, return only those paths (or error if none valid)\n\t\tif (paths.length > 0) {\n\t\t\treturn paths;\n\t\t}\n\t\t// If environment variable is set but no valid paths found, throw error\n\t\tthrow new Error(\n\t\t\t`No valid Claude data directories found in CLAUDE_CONFIG_DIR. Please ensure the following exists:\n- ${envPaths}/${CLAUDE_PROJECTS_DIR_NAME}`.trim(),\n\t\t);\n\t}\n\n\t// Only check default paths if no environment variable is set\n\tconst defaultPaths = [\n\t\tDEFAULT_CLAUDE_CONFIG_PATH, // New default: XDG config directory\n\t\tpath.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH), // Old default: ~/.claude\n\t];\n\n\tfor (const defaultPath of defaultPaths) {\n\t\tconst normalizedPath = path.resolve(defaultPath);\n\t\tif (isDirectorySync(normalizedPath)) {\n\t\t\tconst projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME);\n\t\t\tif (isDirectorySync(projectsPath)) {\n\t\t\t\t// Avoid duplicates using normalized paths\n\t\t\t\tif (!normalizedPaths.has(normalizedPath)) {\n\t\t\t\t\tnormalizedPaths.add(normalizedPath);\n\t\t\t\t\tpaths.push(normalizedPath);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif (paths.length === 0) {\n\t\tthrow new Error(\n\t\t\t`No valid Claude data directories found. Please ensure at least one of the following exists:\n- ${path.join(DEFAULT_CLAUDE_CONFIG_PATH, CLAUDE_PROJECTS_DIR_NAME)}\n- ${path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH, CLAUDE_PROJECTS_DIR_NAME)}\n- Or set ${CLAUDE_CONFIG_DIR_ENV} environment variable to valid directory path(s) containing a '${CLAUDE_PROJECTS_DIR_NAME}' subdirectory`.trim(),\n\t\t);\n\t}\n\n\treturn paths;\n}\n\n/**\n * Extract project name from Claude JSONL file path\n * @param jsonlPath - Absolute path to JSONL file\n * @returns Project name extracted from path, or \"unknown\" if malformed\n */\nexport function extractProjectFromPath(jsonlPath: string): string {\n\t// Normalize path separators for cross-platform compatibility\n\tconst normalizedPath = jsonlPath.replace(/[/\\\\]/g, path.sep);\n\tconst segments = normalizedPath.split(path.sep);\n\tconst projectsIndex = segments.findIndex((segment) => segment === CLAUDE_PROJECTS_DIR_NAME);\n\n\tif (projectsIndex === -1 || projectsIndex + 1 >= segments.length) {\n\t\treturn 'unknown';\n\t}\n\n\tconst projectName = segments[projectsIndex + 1];\n\treturn projectName != null && projectName.trim() !== '' ? projectName : 'unknown';\n}\n\n/**\n * Valibot schema for validating Claude usage data from JSONL files\n */\nexport const usageDataSchema = v.object({\n\tcwd: v.optional(v.string()), // Claude Code version, optional for compatibility\n\tsessionId: v.optional(sessionIdSchema), // Session ID for deduplication\n\ttimestamp: isoTimestampSchema,\n\tversion: v.optional(versionSchema), // Claude Code version\n\tmessage: v.object({\n\t\tusage: v.object({\n\t\t\tinput_tokens: v.number(),\n\t\t\toutput_tokens: v.number(),\n\t\t\tcache_creation_input_tokens: v.optional(v.number()),\n\t\t\tcache_read_input_tokens: v.optional(v.number()),\n\t\t\tspeed: v.optional(v.picklist(['standard', 'fast'])),\n\t\t}),\n\t\tmodel: v.optional(modelNameSchema), // Model is inside message object\n\t\tid: v.optional(messageIdSchema), // Message ID for deduplication\n\t\tcontent: v.optional(\n\t\t\tv.array(\n\t\t\t\tv.object({\n\t\t\t\t\ttext: v.optional(v.string()),\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\t}),\n\tcostUSD: v.optional(v.number()), // Made optional for new schema\n\trequestId: v.optional(requestIdSchema), // Request ID for deduplication\n\tisApiErrorMessage: v.optional(v.boolean()),\n});\n\n/**\n * Valibot schema for transcript usage data from Claude messages\n */\nexport const transcriptUsageSchema = v.object({\n\tinput_tokens: v.optional(v.number()),\n\tcache_creation_input_tokens: v.optional(v.number()),\n\tcache_read_input_tokens: v.optional(v.number()),\n\toutput_tokens: v.optional(v.number()),\n});\n\n/**\n * Valibot schema for transcript message data\n */\nexport const transcriptMessageSchema = v.object({\n\ttype: v.optional(v.string()),\n\tmessage: v.optional(\n\t\tv.object({\n\t\t\tusage: v.optional(transcriptUsageSchema),\n\t\t}),\n\t),\n});\n\n/**\n * Type definition for Claude usage data entries from JSONL files\n */\nexport type UsageData = v.InferOutput<typeof usageDataSchema>;\n\n/**\n * Valibot schema for model-specific usage breakdown data\n */\nexport const modelBreakdownSchema = v.object({\n\tmodelName: modelNameSchema,\n\tinputTokens: v.number(),\n\toutputTokens: v.number(),\n\tcacheCreationTokens: v.number(),\n\tcacheReadTokens: v.number(),\n\tcost: v.number(),\n});\n\n/**\n * Type definition for model-specific usage breakdown\n */\nexport type ModelBreakdown = v.InferOutput<typeof modelBreakdownSchema>;\n\n/**\n * Valibot schema for daily usage aggregation data\n */\nexport const dailyUsageSchema = v.object({\n\tdate: dailyDateSchema, // YYYY-MM-DD format\n\tinputTokens: v.number(),\n\toutputTokens: v.number(),\n\tcacheCreationTokens: v.number(),\n\tcacheReadTokens: v.number(),\n\ttotalCost: v.number(),\n\tmodelsUsed: v.array(modelNameSchema),\n\tmodelBreakdowns: v.array(modelBreakdownSchema),\n\tproject: v.optional(v.string()), // Project name when groupByProject is enabled\n});\n\n/**\n * Type definition for daily usage aggregation\n */\nexport type DailyUsage = v.InferOutput<typeof dailyUsageSchema>;\n\n/**\n * Valibot schema for session-based usage aggregation data\n */\nexport const sessionUsageSchema = v.object({\n\tsessionId: sessionIdSchema,\n\tprojectPath: projectPathSchema,\n\tinputTokens: v.number(),\n\toutputTokens: v.number(),\n\tcacheCreationTokens: v.number(),\n\tcacheReadTokens: v.number(),\n\ttotalCost: v.number(),\n\tlastActivity: activityDateSchema,\n\tversions: v.array(versionSchema), // List of unique versions used in this session\n\tmodelsUsed: v.array(modelNameSchema),\n\tmodelBreakdowns: v.array(modelBreakdownSchema),\n});\n\n/**\n * Type definition for session-based usage aggregation\n */\nexport type SessionUsage = v.InferOutput<typeof sessionUsageSchema>;\n\n/**\n * Valibot schema for monthly usage aggregation data\n */\nexport const monthlyUsageSchema = v.object({\n\tmonth: monthlyDateSchema, // YYYY-MM format\n\tinputTokens: v.number(),\n\toutputTokens: v.number(),\n\tcacheCreationTokens: v.number(),\n\tcacheReadTokens: v.number(),\n\ttotalCost: v.number(),\n\tmodelsUsed: v.array(modelNameSchema),\n\tmodelBreakdowns: v.array(modelBreakdownSchema),\n\tproject: v.optional(v.string()), // Project name when groupByProject is enabled\n});\n\n/**\n * Type definition for monthly usage aggregation\n */\nexport type MonthlyUsage = v.InferOutput<typeof monthlyUsageSchema>;\n\n/**\n * Valibot schema for weekly usage aggregation data\n */\nexport const weeklyUsageSchema = v.object({\n\tweek: weeklyDateSchema, // YYYY-MM-DD format\n\tinputTokens: v.number(),\n\toutputTokens: v.number(),\n\tcacheCreationTokens: v.number(),\n\tcacheReadTokens: v.number(),\n\ttotalCost: v.number(),\n\tmodelsUsed: v.array(modelNameSchema),\n\tmodelBreakdowns: v.array(modelBreakdownSchema),\n\tproject: v.optional(v.string()), // Project name when groupByProject is enabled\n});\n\n/**\n * Type definition for weekly usage aggregation\n */\nexport type WeeklyUsage = v.InferOutput<typeof weeklyUsageSchema>;\n\n/**\n * Valibot schema for bucket usage aggregation data\n */\nexport const bucketUsageSchema = v.object({\n\tbucket: v.union([weeklyDateSchema, monthlyDateSchema]), // WeeklyDate or MonthlyDate\n\tinputTokens: v.number(),\n\toutputTokens: v.number(),\n\tcacheCreationTokens: v.number(),\n\tcacheReadTokens: v.number(),\n\ttotalCost: v.number(),\n\tmodelsUsed: v.array(modelNameSchema),\n\tmodelBreakdowns: v.array(modelBreakdownSchema),\n\tproject: v.optional(v.string()), // Project name when groupByProject is enabled\n});\n\n/**\n * Type definition for bucket usage aggregation\n */\nexport type BucketUsage = v.InferOutput<typeof bucketUsageSchema>;\n\n/**\n * Internal type for aggregating token statistics and costs\n */\ntype TokenStats = {\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\tcost: number;\n};\n\nfunction getDisplayModelName(data: UsageData): string | undefined {\n\tconst model = data.message.model;\n\tif (model == null) {\n\t\treturn undefined;\n\t}\n\treturn data.message.usage.speed === 'fast' ? `${model}-fast` : model;\n}\n\n/**\n * Aggregates token counts and costs by model name\n */\nfunction aggregateByModel<T>(\n\tentries: T[],\n\tgetModel: (entry: T) => string | undefined,\n\tgetUsage: (entry: T) => UsageData['message']['usage'],\n\tgetCost: (entry: T) => number,\n): Map<string, TokenStats> {\n\tconst modelAggregates = new Map<string, TokenStats>();\n\tconst defaultStats: TokenStats = {\n\t\tinputTokens: 0,\n\t\toutputTokens: 0,\n\t\tcacheCreationTokens: 0,\n\t\tcacheReadTokens: 0,\n\t\tcost: 0,\n\t};\n\n\tfor (const entry of entries) {\n\t\tconst modelName = getModel(entry) ?? 'unknown';\n\t\t// Skip synthetic model\n\t\tif (modelName === '<synthetic>') {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst usage = getUsage(entry);\n\t\tconst cost = getCost(entry);\n\n\t\tconst existing = modelAggregates.get(modelName) ?? defaultStats;\n\n\t\tmodelAggregates.set(modelName, {\n\t\t\tinputTokens: existing.inputTokens + (usage.input_tokens ?? 0),\n\t\t\toutputTokens: existing.outputTokens + (usage.output_tokens ?? 0),\n\t\t\tcacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0),\n\t\t\tcacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0),\n\t\t\tcost: existing.cost + cost,\n\t\t});\n\t}\n\n\treturn modelAggregates;\n}\n\n/**\n * Aggregates model breakdowns from multiple sources\n */\nfunction aggregateModelBreakdowns(breakdowns: ModelBreakdown[]): Map<string, TokenStats> {\n\tconst modelAggregates = new Map<string, TokenStats>();\n\tconst defaultStats: TokenStats = {\n\t\tinputTokens: 0,\n\t\toutputTokens: 0,\n\t\tcacheCreationTokens: 0,\n\t\tcacheReadTokens: 0,\n\t\tcost: 0,\n\t};\n\n\tfor (const breakdown of breakdowns) {\n\t\t// Skip synthetic model\n\t\tif (breakdown.modelName === '<synthetic>') {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst existing = modelAggregates.get(breakdown.modelName) ?? defaultStats;\n\n\t\tmodelAggregates.set(breakdown.modelName, {\n\t\t\tinputTokens: existing.inputTokens + breakdown.inputTokens,\n\t\t\toutputTokens: existing.outputTokens + breakdown.outputTokens,\n\t\t\tcacheCreationTokens: existing.cacheCreationTokens + breakdown.cacheCreationTokens,\n\t\t\tcacheReadTokens: existing.cacheReadTokens + breakdown.cacheReadTokens,\n\t\t\tcost: existing.cost + breakdown.cost,\n\t\t});\n\t}\n\n\treturn modelAggregates;\n}\n\n/**\n * Converts model aggregates to sorted model breakdowns\n */\nfunction createModelBreakdowns(modelAggregates: Map<string, TokenStats>): ModelBreakdown[] {\n\treturn Array.from(modelAggregates.entries())\n\t\t.map(([modelName, stats]) => ({\n\t\t\tmodelName: modelName as ModelName,\n\t\t\t...stats,\n\t\t}))\n\t\t.sort((a, b) => b.cost - a.cost); // Sort by cost descending\n}\n\n/**\n * Calculates total token counts and costs from entries\n */\nfunction calculateTotals<T>(\n\tentries: T[],\n\tgetUsage: (entry: T) => UsageData['message']['usage'],\n\tgetCost: (entry: T) => number,\n): TokenStats & { totalCost: number } {\n\treturn entries.reduce(\n\t\t(acc, entry) => {\n\t\t\tconst usage = getUsage(entry);\n\t\t\tconst cost = getCost(entry);\n\n\t\t\treturn {\n\t\t\t\tinputTokens: acc.inputTokens + (usage.input_tokens ?? 0),\n\t\t\t\toutputTokens: acc.outputTokens + (usage.output_tokens ?? 0),\n\t\t\t\tcacheCreationTokens: acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0),\n\t\t\t\tcacheReadTokens: acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0),\n\t\t\t\tcost: acc.cost + cost,\n\t\t\t\ttotalCost: acc.totalCost + cost,\n\t\t\t};\n\t\t},\n\t\t{\n\t\t\tinputTokens: 0,\n\t\t\toutputTokens: 0,\n\t\t\tcacheCreationTokens: 0,\n\t\t\tcacheReadTokens: 0,\n\t\t\tcost: 0,\n\t\t\ttotalCost: 0,\n\t\t},\n\t);\n}\n\n/**\n * Filters items by project name\n */\nfunction filterByProject<T>(\n\titems: T[],\n\tgetProject: (item: T) => string | undefined,\n\tprojectFilter?: string,\n): T[] {\n\tif (projectFilter == null) {\n\t\treturn items;\n\t}\n\n\treturn items.filter((item) => {\n\t\tconst projectName = getProject(item);\n\t\treturn projectName === projectFilter;\n\t});\n}\n\n/**\n * Checks if an entry is a duplicate based on hash\n */\nfunction isDuplicateEntry(uniqueHash: string | null, processedHashes: Set<string>): boolean {\n\tif (uniqueHash == null) {\n\t\treturn false;\n\t}\n\treturn processedHashes.has(uniqueHash);\n}\n\n/**\n * Marks an entry as processed\n */\nfunction markAsProcessed(uniqueHash: string | null, processedHashes: Set<string>): void {\n\tif (uniqueHash != null) {\n\t\tprocessedHashes.add(uniqueHash);\n\t}\n}\n\n/**\n * Extracts unique models from entries, excluding synthetic model\n */\nfunction extractUniqueModels<T>(\n\tentries: T[],\n\tgetModel: (entry: T) => string | undefined,\n): string[] {\n\treturn uniq(entries.map(getModel).filter((m): m is string => m != null && m !== '<synthetic>'));\n}\n\n/**\n * Create a unique identifier for deduplication using message ID and request ID\n */\nexport function createUniqueHash(data: UsageData): string | null {\n\tconst messageId = data.message.id;\n\tconst requestId = data.requestId;\n\n\tif (messageId == null || requestId == null) {\n\t\treturn null;\n\t}\n\n\t// Create a hash using simple concatenation\n\treturn `${messageId}:${requestId}`;\n}\n\n/**\n * Process a JSONL file line by line using streams to avoid memory issues with large files\n * @param filePath - Path to the JSONL file\n * @param processLine - Callback function to process each line\n */\nasync function processJSONLFileByLine(\n\tfilePath: string,\n\tprocessLine: (line: string, lineNumber: number) => void | Promise<void>,\n): Promise<void> {\n\tconst fileStream = createReadStream(filePath, { encoding: 'utf-8' });\n\tconst rl = createInterface({\n\t\tinput: fileStream,\n\t\tcrlfDelay: Number.POSITIVE_INFINITY,\n\t});\n\n\tlet lineNumber = 0;\n\tfor await (const line of rl) {\n\t\tlineNumber++;\n\t\tif (line.trim().length === 0) {\n\t\t\tcontinue;\n\t\t}\n\t\tawait processLine(line, lineNumber);\n\t}\n}\n\n/**\n * Extract the earliest timestamp from a JSONL file\n * Scans through the file until it finds a valid timestamp\n * Uses streaming to handle large files without loading entire content into memory\n */\nexport async function getEarliestTimestamp(filePath: string): Promise<Date | null> {\n\ttry {\n\t\tlet earliestDate: Date | null = null;\n\n\t\tawait processJSONLFileByLine(filePath, (line) => {\n\t\t\ttry {\n\t\t\t\tconst json = JSON.parse(line) as Record<string, unknown>;\n\t\t\t\tif (json.timestamp != null && typeof json.timestamp === 'string') {\n\t\t\t\t\tconst date = new Date(json.timestamp);\n\t\t\t\t\tif (!Number.isNaN(date.getTime())) {\n\t\t\t\t\t\tif (earliestDate == null || date < earliestDate) {\n\t\t\t\t\t\t\tearliestDate = date;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Skip invalid JSON lines\n\t\t\t}\n\t\t});\n\n\t\treturn earliestDate;\n\t} catch (error) {\n\t\t// Log file access errors for diagnostics, but continue processing\n\t\t// This ensures files without timestamps or with access issues are sorted to the end\n\t\tlogger.debug(`Failed to get earliest timestamp for ${filePath}:`, error);\n\t\treturn null;\n\t}\n}\n\n/**\n * Sort files by their earliest timestamp\n * Files without valid timestamps are placed at the end\n */\nexport async function sortFilesByTimestamp(files: string[]): Promise<string[]> {\n\tconst filesWithTimestamps = await Promise.all(\n\t\tfiles.map(async (file) => ({\n\t\t\tfile,\n\t\t\ttimestamp: await getEarliestTimestamp(file),\n\t\t})),\n\t);\n\n\treturn filesWithTimestamps\n\t\t.sort((a, b) => {\n\t\t\t// Files without timestamps go to the end\n\t\t\tif (a.timestamp == null && b.timestamp == null) {\n\t\t\t\treturn 0;\n\t\t\t}\n\t\t\tif (a.timestamp == null) {\n\t\t\t\treturn 1;\n\t\t\t}\n\t\t\tif (b.timestamp == null) {\n\t\t\t\treturn -1;\n\t\t\t}\n\t\t\t// Sort by timestamp (oldest first)\n\t\t\treturn a.timestamp.getTime() - b.timestamp.getTime();\n\t\t})\n\t\t.map((item) => item.file);\n}\n\n/**\n * Calculates cost for a single usage data entry based on the specified cost calculation mode\n * @param data - Usage data entry\n * @param mode - Cost calculation mode (auto, calculate, or display)\n * @param fetcher - Pricing fetcher instance for calculating costs from tokens\n * @returns Calculated cost in USD\n */\nexport async function calculateCostForEntry(\n\tdata: UsageData,\n\tmode: CostMode,\n\tfetcher: PricingFetcher,\n): Promise<number> {\n\tconst speed = data.message.usage.speed;\n\n\tif (mode === 'display') {\n\t\t// Always use costUSD, even if undefined\n\t\treturn data.costUSD ?? 0;\n\t}\n\n\tif (mode === 'calculate') {\n\t\t// Always calculate from tokens\n\t\tif (data.message.model != null) {\n\t\t\treturn Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(data.message.usage, data.message.model, { speed }),\n\t\t\t\t0,\n\t\t\t);\n\t\t}\n\t\treturn 0;\n\t}\n\n\tif (mode === 'auto') {\n\t\t// Auto mode: use costUSD if available, otherwise calculate\n\t\tif (data.costUSD != null) {\n\t\t\treturn data.costUSD;\n\t\t}\n\n\t\tif (data.message.model != null) {\n\t\t\treturn Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(data.message.usage, data.message.model, { speed }),\n\t\t\t\t0,\n\t\t\t);\n\t\t}\n\n\t\treturn 0;\n\t}\n\n\tunreachable(mode);\n}\n\n/**\n * Get Claude Code usage limit expiration date\n * @param data - Usage data entry\n * @returns Usage limit expiration date\n */\nexport function getUsageLimitResetTime(data: UsageData): Date | null {\n\tlet resetTime: Date | null = null;\n\n\tif (data.isApiErrorMessage === true) {\n\t\tconst timestampMatch =\n\t\t\tdata.message?.content\n\t\t\t\t?.find((c) => c.text != null && c.text.includes('Claude AI usage limit reached'))\n\t\t\t\t?.text?.match(/\\|(\\d+)/) ?? null;\n\n\t\tif (timestampMatch?.[1] != null) {\n\t\t\tconst resetTimestamp = Number.parseInt(timestampMatch[1]);\n\t\t\tresetTime = resetTimestamp > 0 ? new Date(resetTimestamp * 1000) : null;\n\t\t}\n\t}\n\n\treturn resetTime;\n}\n\n/**\n * Result of glob operation with base directory information\n */\nexport type GlobResult = {\n\tfile: string;\n\tbaseDir: string;\n};\n\n/**\n * Glob files from multiple Claude paths in parallel\n * @param claudePaths - Array of Claude base paths\n * @returns Array of file paths with their base directories\n */\nexport async function globUsageFiles(claudePaths: string[]): Promise<GlobResult[]> {\n\tconst filePromises = claudePaths.map(async (claudePath) => {\n\t\tconst claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME);\n\t\tconst files = await glob([USAGE_DATA_GLOB_PATTERN], {\n\t\t\tcwd: claudeDir,\n\t\t\tabsolute: true,\n\t\t}).catch(() => []); // Gracefully handle errors for individual paths\n\n\t\t// Map each file to include its base directory\n\t\treturn files.map((file) => ({ file, baseDir: claudeDir }));\n\t});\n\treturn (await Promise.all(filePromises)).flat();\n}\n\n/**\n * Date range filter for limiting usage data by date\n */\nexport type DateFilter = {\n\tsince?: string; // YYYYMMDD format\n\tuntil?: string; // YYYYMMDD format\n};\n\n/**\n * Configuration options for loading usage data\n */\nexport type LoadOptions = {\n\tclaudePath?: string; // Custom path to Claude data directory\n\tmode?: CostMode; // Cost calculation mode\n\torder?: SortOrder; // Sort order for dates\n\toffline?: boolean; // Use offline mode for pricing\n\tsessionDurationHours?: number; // Session block duration in hours\n\tgroupByProject?: boolean; // Group data by project instead of aggregating\n\tproject?: string; // Filter to specific project name\n\tstartOfWeek?: WeekDay; // Start of week for weekly aggregation\n\ttimezone?: string; // Timezone for date grouping (e.g., 'UTC', 'America/New_York'). Defaults to system timezone\n\tlocale?: string; // Locale for date/time formatting (e.g., 'en-US', 'ja-JP'). Defaults to 'en-US'\n} & DateFilter;\n\n/**\n * Loads and aggregates Claude usage data by day\n * Processes all JSONL files in the Claude projects directory and groups usage by date\n * @param options - Optional configuration for loading and filtering data\n * @returns Array of daily usage summaries sorted by date\n */\nexport async function loadDailyUsageData(options?: LoadOptions): Promise<DailyUsage[]> {\n\t// Get all Claude paths or use the specific one from options\n\tconst claudePaths = toArray(options?.claudePath ?? getClaudePaths());\n\n\t// Collect files from all paths in parallel\n\tconst allFiles = await globUsageFiles(claudePaths);\n\tconst fileList = allFiles.map((f) => f.file);\n\n\tif (fileList.length === 0) {\n\t\treturn [];\n\t}\n\n\t// Filter by project if specified\n\tconst projectFilteredFiles = filterByProject(\n\t\tfileList,\n\t\t(filePath) => extractProjectFromPath(filePath),\n\t\toptions?.project,\n\t);\n\n\t// Sort files by timestamp to ensure chronological processing\n\tconst sortedFiles = await sortFilesByTimestamp(projectFilteredFiles);\n\n\t// Fetch pricing data for cost calculation only when needed\n\tconst mode = options?.mode ?? 'auto';\n\n\t// Use PricingFetcher with using statement for automatic cleanup\n\tusing fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline);\n\n\t// Track processed message+request combinations for deduplication\n\tconst processedHashes = new Set<string>();\n\n\t// Collect all valid data entries first\n\tconst allEntries: {\n\t\tdata: UsageData;\n\t\tdate: string;\n\t\tcost: number;\n\t\tmodel: string | undefined;\n\t\tproject: string;\n\t}[] = [];\n\n\tfor (const file of sortedFiles) {\n\t\t// Extract project name from file path once per file\n\t\tconst project = extractProjectFromPath(file);\n\n\t\tawait processJSONLFileByLine(file, async (line) => {\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(line) as unknown;\n\t\t\t\tconst result = v.safeParse(usageDataSchema, parsed);\n\t\t\t\tif (!result.success) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst data = result.output;\n\n\t\t\t\t// Check for duplicate message + request ID combination\n\t\t\t\tconst uniqueHash = createUniqueHash(data);\n\t\t\t\tif (isDuplicateEntry(uniqueHash, processedHashes)) {\n\t\t\t\t\t// Skip duplicate message\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Mark this combination as processed\n\t\t\t\tmarkAsProcessed(uniqueHash, processedHashes);\n\n\t\t\t\t// Always use DEFAULT_LOCALE for date grouping to ensure YYYY-MM-DD format\n\t\t\t\tconst date = formatDate(data.timestamp, options?.timezone, DEFAULT_LOCALE);\n\t\t\t\t// If fetcher is available, calculate cost based on mode and tokens\n\t\t\t\t// If fetcher is null, use pre-calculated costUSD or default to 0\n\t\t\t\tconst cost =\n\t\t\t\t\tfetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0);\n\n\t\t\t\tallEntries.push({ data, date, cost, model: getDisplayModelName(data), project });\n\t\t\t} catch {\n\t\t\t\t// Skip invalid JSON lines\n\t\t\t}\n\t\t});\n\t}\n\n\t// Group by date, optionally including project\n\t// Automatically enable project grouping when project filter is specified\n\tconst needsProjectGrouping = options?.groupByProject === true || options?.project != null;\n\tconst groupingKey = needsProjectGrouping\n\t\t? (entry: (typeof allEntries)[0]) => `${entry.date}\\x00${entry.project}`\n\t\t: (entry: (typeof allEntries)[0]) => entry.date;\n\n\tconst groupedData = groupBy(allEntries, groupingKey);\n\n\t// Aggregate each group\n\tconst results = Object.entries(groupedData)\n\t\t.map(([groupKey, entries]) => {\n\t\t\tif (entries == null) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\t// Extract date and project from groupKey (format: \"date\" or \"date\\x00project\")\n\t\t\tconst parts = groupKey.split('\\x00');\n\t\t\tconst date = parts[0] ?? groupKey;\n\t\t\tconst project = parts.length > 1 ? parts[1] : undefined;\n\n\t\t\t// Aggregate by model first\n\t\t\tconst modelAggregates = aggregateByModel(\n\t\t\t\tentries,\n\t\t\t\t(entry) => entry.model,\n\t\t\t\t(entry) => entry.data.message.usage,\n\t\t\t\t(entry) => entry.cost,\n\t\t\t);\n\n\t\t\t// Create model breakdowns\n\t\t\tconst modelBreakdowns = createModelBreakdowns(modelAggregates);\n\n\t\t\t// Calculate totals\n\t\t\tconst totals = calculateTotals(\n\t\t\t\tentries,\n\t\t\t\t(entry) => entry.data.message.usage,\n\t\t\t\t(entry) => entry.cost,\n\t\t\t);\n\n\t\t\tconst modelsUsed = extractUniqueModels(entries, (e) => e.model);\n\n\t\t\treturn {\n\t\t\t\tdate: createDailyDate(date),\n\t\t\t\t...totals,\n\t\t\t\tmodelsUsed: modelsUsed as ModelName[],\n\t\t\t\tmodelBreakdowns,\n\t\t\t\t...(project != null && { project }),\n\t\t\t};\n\t\t})\n\t\t.filter((item) => item != null);\n\n\t// Filter by date range if specified\n\tconst dateFiltered = filterByDateRange(\n\t\tresults,\n\t\t(item) => item.date,\n\t\toptions?.since,\n\t\toptions?.until,\n\t);\n\n\t// Filter by project if specified\n\tconst finalFiltered = filterByProject(dateFiltered, (item) => item.project, options?.project);\n\n\t// Sort by date based on order option (default to descending)\n\treturn sortByDate(finalFiltered, (item) => item.date, options?.order);\n}\n\n/**\n * Loads and aggregates Claude usage data by session\n * Groups usage data by project path and session ID based on file structure\n * @param options - Optional configuration for loading and filtering data\n * @returns Array of session usage summaries sorted by last activity\n */\nexport async function loadSessionData(options?: LoadOptions): Promise<SessionUsage[]> {\n\t// Get all Claude paths or use the specific one from options\n\tconst claudePaths = toArray(options?.claudePath ?? getClaudePaths());\n\n\t// Collect files from all paths with their base directories in parallel\n\tconst filesWithBase = await globUsageFiles(claudePaths);\n\n\tif (filesWithBase.length === 0) {\n\t\treturn [];\n\t}\n\n\t// Filter by project if specified\n\tconst projectFilteredWithBase = filterByProject(\n\t\tfilesWithBase,\n\t\t(item) => extractProjectFromPath(item.file),\n\t\toptions?.project,\n\t);\n\n\t// Sort files by timestamp to ensure chronological processing\n\t// Create a map for O(1) lookup instead of O(N) find operations\n\tconst fileToBaseMap = new Map(projectFilteredWithBase.map((f) => [f.file, f.baseDir]));\n\tconst sortedFilesWithBase = await sortFilesByTimestamp(\n\t\tprojectFilteredWithBase.map((f) => f.file),\n\t).then((sortedFiles) =>\n\t\tsortedFiles.map((file) => ({\n\t\t\tfile,\n\t\t\tbaseDir: fileToBaseMap.get(file) ?? '',\n\t\t})),\n\t);\n\n\t// Fetch pricing data for cost calculation only when needed\n\tconst mode = options?.mode ?? 'auto';\n\n\t// Use PricingFetcher with using statement for automatic cleanup\n\tusing fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline);\n\n\t// Track processed message+request combinations for deduplication\n\tconst processedHashes = new Set<string>();\n\n\t// Collect all valid data entries with session info first\n\tconst allEntries: Array<{\n\t\tdata: UsageData;\n\t\tsessionKey: string;\n\t\tsessionId: string;\n\t\tprojectPath: string;\n\t\tcost: number;\n\t\ttimestamp: string;\n\t\tmodel: string | undefined;\n\t}> = [];\n\n\tfor (const { file, baseDir } of sortedFilesWithBase) {\n\t\t// Extract session info from file path using its specific base directory\n\t\tconst relativePath = path.relative(baseDir, file);\n\t\tconst parts = relativePath.split(path.sep);\n\n\t\t// Session ID is the directory name containing the JSONL file\n\t\tconst sessionId = parts[parts.length - 2] ?? 'unknown';\n\t\t// Project path is everything before the session ID\n\t\tconst joinedPath = parts.slice(0, -2).join(path.sep);\n\t\tconst projectPath = joinedPath.length > 0 ? joinedPath : 'Unknown Project';\n\n\t\tawait processJSONLFileByLine(file, async (line) => {\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(line) as unknown;\n\t\t\t\tconst result = v.safeParse(usageDataSchema, parsed);\n\t\t\t\tif (!result.success) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst data = result.output;\n\n\t\t\t\t// Check for duplicate message + request ID combination\n\t\t\t\tconst uniqueHash = createUniqueHash(data);\n\t\t\t\tif (isDuplicateEntry(uniqueHash, processedHashes)) {\n\t\t\t\t\t// Skip duplicate message\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Mark this combination as processed\n\t\t\t\tmarkAsProcessed(uniqueHash, processedHashes);\n\n\t\t\t\tconst sessionKey = `${projectPath}/${sessionId}`;\n\t\t\t\tconst cost =\n\t\t\t\t\tfetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0);\n\n\t\t\t\tallEntries.push({\n\t\t\t\t\tdata,\n\t\t\t\t\tsessionKey,\n\t\t\t\t\tsessionId,\n\t\t\t\t\tprojectPath,\n\t\t\t\t\tcost,\n\t\t\t\t\ttimestamp: data.timestamp,\n\t\t\t\t\tmodel: getDisplayModelName(data),\n\t\t\t\t});\n\t\t\t} catch {\n\t\t\t\t// Skip invalid JSON lines\n\t\t\t}\n\t\t});\n\t}\n\n\t// Group by session using Object.groupBy\n\tconst groupedBySessions = groupBy(allEntries, (entry) => entry.sessionKey);\n\n\t// Aggregate each session group\n\tconst results = Object.entries(groupedBySessions)\n\t\t.map(([_, entries]) => {\n\t\t\tif (entries == null) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\t// Find the latest timestamp for lastActivity\n\t\t\tconst latestEntry = entries.reduce((latest, current) =>\n\t\t\t\tcurrent.timestamp > latest.timestamp ? current : latest,\n\t\t\t);\n\n\t\t\t// Collect all unique versions\n\t\t\tconst versions: string[] = [];\n\t\t\tfor (const entry of entries) {\n\t\t\t\tif (entry.data.version != null) {\n\t\t\t\t\tversions.push(entry.data.version);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Aggregate by model\n\t\t\tconst modelAggregates = aggregateByModel(\n\t\t\t\tentries,\n\t\t\t\t(entry) => entry.model,\n\t\t\t\t(entry) => entry.data.message.usage,\n\t\t\t\t(entry) => entry.cost,\n\t\t\t);\n\n\t\t\t// Create model breakdowns\n\t\t\tconst modelBreakdowns = createModelBreakdowns(modelAggregates);\n\n\t\t\t// Calculate totals\n\t\t\tconst totals = calculateTotals(\n\t\t\t\tentries,\n\t\t\t\t(entry) => entry.data.message.usage,\n\t\t\t\t(entry) => entry.cost,\n\t\t\t);\n\n\t\t\tconst modelsUsed = extractUniqueModels(entries, (e) => e.model);\n\n\t\t\treturn {\n\t\t\t\tsessionId: createSessionId(latestEntry.sessionId),\n\t\t\t\tprojectPath: createProjectPath(latestEntry.projectPath),\n\t\t\t\t...totals,\n\t\t\t\t// Always use DEFAULT_LOCALE for date storage to ensure YYYY-MM-DD format\n\t\t\t\tlastActivity: formatDate(\n\t\t\t\t\tlatestEntry.timestamp,\n\t\t\t\t\toptions?.timezone,\n\t\t\t\t\tDEFAULT_LOCALE,\n\t\t\t\t) as ActivityDate,\n\t\t\t\tversions: uniq(versions).sort() as Version[],\n\t\t\t\tmodelsUsed: modelsUsed as ModelName[],\n\t\t\t\tmodelBreakdowns,\n\t\t\t};\n\t\t})\n\t\t.filter((item) => item != null);\n\n\t// Filter by date range if specified\n\tconst dateFiltered = filterByDateRange(\n\t\tresults,\n\t\t(item) => item.lastActivity,\n\t\toptions?.since,\n\t\toptions?.until,\n\t);\n\n\t// Filter by project if specified\n\tconst sessionFiltered = filterByProject(\n\t\tdateFiltered,\n\t\t(item) => item.projectPath,\n\t\toptions?.project,\n\t);\n\n\treturn sortByDate(sessionFiltered, (item) => item.lastActivity, options?.order);\n}\n\n/**\n * Loads and aggregates Claude usage data by month\n * Uses daily usage data as the source and groups by month\n * @param options - Optional configuration for loading and filtering data\n * @returns Array of monthly usage summaries sorted by month\n */\nexport async function loadMonthlyUsageData(options?: LoadOptions): Promise<MonthlyUsage[]> {\n\treturn loadBucketUsageData(\n\t\t(data: DailyUsage) => createMonthlyDate(data.date.slice(0, 7)),\n\t\toptions,\n\t).then((usages) =>\n\t\tusages.map<MonthlyUsage>(({ bucket, ...rest }) => ({\n\t\t\tmonth: v.parse(monthlyDateSchema, bucket),\n\t\t\t...rest,\n\t\t})),\n\t);\n}\n\nexport async function loadWeeklyUsageData(options?: LoadOptions): Promise<WeeklyUsage[]> {\n\tconst startDay =\n\t\toptions?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday');\n\n\treturn loadBucketUsageData(\n\t\t(data: DailyUsage) => getDateWeek(new Date(data.date), startDay),\n\t\toptions,\n\t).then((usages) =>\n\t\tusages.map<WeeklyUsage>(({ bucket, ...rest }) => ({\n\t\t\tweek: v.parse(weeklyDateSchema, bucket),\n\t\t\t...rest,\n\t\t})),\n\t);\n}\n\n/**\n * Load usage data for a specific session by sessionId\n * Searches for a JSONL file named {sessionId}.jsonl in all Claude project directories\n * @param sessionId - The session ID to load data for (matches the JSONL filename)\n * @param options - Options for loading data\n * @param options.mode - Cost calculation mode (auto, calculate, display)\n * @param options.offline - Whether to use offline pricing data\n * @returns Usage data for the specific session or null if not found\n */\nexport async function loadSessionUsageById(\n\tsessionId: string,\n\toptions?: { mode?: CostMode; offline?: boolean },\n): Promise<{ totalCost: number; entries: UsageData[] } | null> {\n\tconst claudePaths = getClaudePaths();\n\n\t// Find the JSONL file for this session ID\n\t// On Windows, replace backslashes from path.join with forward slashes for tinyglobby compatibility\n\tconst patterns = claudePaths.map((p) =>\n\t\tpath.join(p, 'projects', '**', `${sessionId}.jsonl`).replace(/\\\\/g, '/'),\n\t);\n\tconst jsonlFiles = await glob(patterns);\n\n\tif (jsonlFiles.length === 0) {\n\t\treturn null;\n\t}\n\n\tconst file = jsonlFiles[0];\n\tif (file == null) {\n\t\treturn null;\n\t}\n\n\tconst mode = options?.mode ?? 'auto';\n\tusing fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline);\n\n\tconst entries: UsageData[] = [];\n\tlet totalCost = 0;\n\n\tawait processJSONLFileByLine(file, async (line) => {\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(line) as unknown;\n\t\t\tconst result = v.safeParse(usageDataSchema, parsed);\n\t\t\tif (!result.success) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst data = result.output;\n\n\t\t\tconst cost =\n\t\t\t\tfetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0);\n\n\t\t\ttotalCost += cost;\n\t\t\tentries.push(data);\n\t\t} catch {\n\t\t\t// Skip invalid JSON lines\n\t\t}\n\t});\n\n\treturn { totalCost, entries };\n}\n\nexport async function loadBucketUsageData(\n\tgroupingFn: (data: DailyUsage) => Bucket,\n\toptions?: LoadOptions,\n): Promise<BucketUsage[]> {\n\tconst dailyData = await loadDailyUsageData(options);\n\n\t// Group daily data by week, optionally including project\n\t// Automatically enable project grouping when project filter is specified\n\tconst needsProjectGrouping = options?.groupByProject === true || options?.project != null;\n\n\tconst groupingKey = needsProjectGrouping\n\t\t? (data: DailyUsage) => {\n\t\t\t\tconst bucketValue = groupingFn(data);\n\t\t\t\tconst projectSegment = data.project ?? 'unknown';\n\t\t\t\treturn `${bucketValue}\\x00${projectSegment}`;\n\t\t\t}\n\t\t: (data: DailyUsage) => `${groupingFn(data)}`;\n\n\tconst grouped = groupBy(dailyData, groupingKey);\n\n\tconst buckets: BucketUsage[] = [];\n\tfor (const [groupKey, dailyEntries] of Object.entries(grouped)) {\n\t\tif (dailyEntries == null) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst parts = groupKey.split('\\x00');\n\t\tconst bucket = createBucket(parts[0] ?? groupKey);\n\t\tconst project = parts.length > 1 ? parts[1] : undefined;\n\n\t\t// Aggregate model breakdowns across all days\n\t\tconst allBreakdowns = dailyEntries.flatMap((daily) => daily.modelBreakdowns);\n\t\tconst modelAggregates = aggregateModelBreakdowns(allBreakdowns);\n\n\t\t// Create model breakdowns\n\t\tconst modelBreakdowns = createModelBreakdowns(modelAggregates);\n\n\t\t// Collect unique models\n\t\tconst models: string[] = [];\n\t\tfor (const data of dailyEntries) {\n\t\t\tfor (const model of data.modelsUsed) {\n\t\t\t\t// Skip synthetic model\n\t\t\t\tif (model !== '<synthetic>') {\n\t\t\t\t\tmodels.push(model);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Calculate totals from daily entries\n\t\tlet totalInputTokens = 0;\n\t\tlet totalOutputTokens = 0;\n\t\tlet totalCacheCreationTokens = 0;\n\t\tlet totalCacheReadTokens = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const daily of dailyEntries) {\n\t\t\ttotalInputTokens += daily.inputTokens;\n\t\t\ttotalOutputTokens += daily.outputTokens;\n\t\t\ttotalCacheCreationTokens += daily.cacheCreationTokens;\n\t\t\ttotalCacheReadTokens += daily.cacheReadTokens;\n\t\t\ttotalCost += daily.totalCost;\n\t\t}\n\t\tconst bucketUsage: BucketUsage = {\n\t\t\tbucket,\n\t\t\tinputTokens: totalInputTokens,\n\t\t\toutputTokens: totalOutputTokens,\n\t\t\tcacheCreationTokens: totalCacheCreationTokens,\n\t\t\tcacheReadTokens: totalCacheReadTokens,\n\t\t\ttotalCost,\n\t\t\tmodelsUsed: uniq(models) as ModelName[],\n\t\t\tmodelBreakdowns,\n\t\t\t...(project != null && { project }),\n\t\t};\n\n\t\tbuckets.push(bucketUsage);\n\t}\n\n\treturn sortByDate(buckets, (item) => item.bucket, options?.order);\n}\n\n/**\n * Calculate context tokens from transcript file using improved JSONL parsing\n * Based on the Python reference implementation for better accuracy\n * @param transcriptPath - Path to the transcript JSONL file\n * @returns Object with context tokens info or null if unavailable\n */\nexport async function calculateContextTokens(\n\ttranscriptPath: string,\n\tmodelId?: string,\n\toffline = false,\n): Promise<{\n\tinputTokens: number;\n\tpercentage: number;\n\tcontextLimit: number;\n} | null> {\n\tlet content: string;\n\ttry {\n\t\tcontent = await readFile(transcriptPath, 'utf-8');\n\t} catch (error: unknown) {\n\t\tlogger.debug(`Failed to read transcript file: ${String(error)}`);\n\t\treturn null;\n\t}\n\n\tconst lines = content.split('\\n').reverse(); // Iterate from last line to first line\n\n\tfor (const line of lines) {\n\t\tconst trimmedLine = line.trim();\n\t\tif (trimmedLine === '') {\n\t\t\tcontinue;\n\t\t}\n\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(trimmedLine) as unknown;\n\t\t\tconst result = v.safeParse(transcriptMessageSchema, parsed);\n\t\t\tif (!result.success) {\n\t\t\t\tcontinue; // Skip malformed JSON lines\n\t\t\t}\n\t\t\tconst obj = result.output;\n\n\t\t\t// Check if this line contains the required token usage fields\n\t\t\tif (\n\t\t\t\tobj.type === 'assistant' &&\n\t\t\t\tobj.message != null &&\n\t\t\t\tobj.message.usage != null &&\n\t\t\t\tobj.message.usage.input_tokens != null\n\t\t\t) {\n\t\t\t\tconst usage = obj.message.usage;\n\t\t\t\tconst inputTokens =\n\t\t\t\t\tusage.input_tokens! +\n\t\t\t\t\t(usage.cache_creation_input_tokens ?? 0) +\n\t\t\t\t\t(usage.cache_read_input_tokens ?? 0);\n\n\t\t\t\t// Get context limit from PricingFetcher\n\t\t\t\tlet contextLimit = 200_000; // Fallback for when modelId is not provided\n\t\t\t\tif (modelId != null && modelId !== '') {\n\t\t\t\t\tusing fetcher = new PricingFetcher(offline);\n\t\t\t\t\tconst contextLimitResult = await fetcher.getModelContextLimit(modelId);\n\t\t\t\t\tif (Result.isSuccess(contextLimitResult) && contextLimitResult.value != null) {\n\t\t\t\t\t\tcontextLimit = contextLimitResult.value;\n\t\t\t\t\t} else if (Result.isSuccess(contextLimitResult)) {\n\t\t\t\t\t\t// Context limit not available for this model in LiteLLM\n\t\t\t\t\t\tlogger.debug(`No context limit data available for model ${modelId} in LiteLLM`);\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Error occurred\n\t\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t\t`Failed to get context limit for model ${modelId}: ${contextLimitResult.error.message}`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst percentage = Math.min(\n\t\t\t\t\t100,\n\t\t\t\t\tMath.max(0, Math.round((inputTokens / contextLimit) * 100)),\n\t\t\t\t);\n\n\t\t\t\treturn {\n\t\t\t\t\tinputTokens,\n\t\t\t\t\tpercentage,\n\t\t\t\t\tcontextLimit,\n\t\t\t\t};\n\t\t\t}\n\t\t} catch {\n\t\t\tcontinue; // Skip malformed JSON lines\n\t\t}\n\t}\n\n\t// No valid usage information found\n\tlogger.debug('No usage information found in transcript');\n\treturn null;\n}\n\n/**\n * Loads usage data and organizes it into session blocks (typically 5-hour billing periods)\n * Processes all usage data and groups it into time-based blocks for billing analysis\n * @param options - Optional configuration including session duration and filtering\n * @returns Array of session blocks with usage and cost information\n */\nexport async function loadSessionBlockData(options?: LoadOptions): Promise<SessionBlock[]> {\n\t// Get all Claude paths or use the specific one from options\n\tconst claudePaths = toArray(options?.claudePath ?? getClaudePaths());\n\n\t// Collect files from all paths\n\tconst allFiles: string[] = [];\n\tfor (const claudePath of claudePaths) {\n\t\tconst claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME);\n\t\tconst files = await glob([USAGE_DATA_GLOB_PATTERN], {\n\t\t\tcwd: claudeDir,\n\t\t\tabsolute: true,\n\t\t});\n\t\tallFiles.push(...files);\n\t}\n\n\tif (allFiles.length === 0) {\n\t\treturn [];\n\t}\n\n\t// Filter by project if specified\n\tconst blocksFilteredFiles = filterByProject(\n\t\tallFiles,\n\t\t(filePath) => extractProjectFromPath(filePath),\n\t\toptions?.project,\n\t);\n\n\t// Sort files by timestamp to ensure chronological processing\n\tconst sortedFiles = await sortFilesByTimestamp(blocksFilteredFiles);\n\n\t// Fetch pricing data for cost calculation only when needed\n\tconst mode = options?.mode ?? 'auto';\n\n\t// Use PricingFetcher with using statement for automatic cleanup\n\tusing fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline);\n\n\t// Track processed message+request combinations for deduplication\n\tconst processedHashes = new Set<string>();\n\n\t// Collect all valid data entries first\n\tconst allEntries: LoadedUsageEntry[] = [];\n\n\tfor (const file of sortedFiles) {\n\t\tawait processJSONLFileByLine(file, async (line) => {\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(line) as unknown;\n\t\t\t\tconst result = v.safeParse(usageDataSchema, parsed);\n\t\t\t\tif (!result.success) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst data = result.output;\n\n\t\t\t\t// Check for duplicate message + request ID combination\n\t\t\t\tconst uniqueHash = createUniqueHash(data);\n\t\t\t\tif (isDuplicateEntry(uniqueHash, processedHashes)) {\n\t\t\t\t\t// Skip duplicate message\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Mark this combination as processed\n\t\t\t\tmarkAsProcessed(uniqueHash, processedHashes);\n\n\t\t\t\tconst cost =\n\t\t\t\t\tfetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0);\n\n\t\t\t\t// Get Claude Code usage limit expiration date\n\t\t\t\tconst usageLimitResetTime = getUsageLimitResetTime(data);\n\n\t\t\t\tallEntries.push({\n\t\t\t\t\ttimestamp: new Date(data.timestamp),\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinputTokens: data.message.usage.input_tokens,\n\t\t\t\t\t\toutputTokens: data.message.usage.output_tokens,\n\t\t\t\t\t\tcacheCreationInputTokens: data.message.usage.cache_creation_input_tokens ?? 0,\n\t\t\t\t\t\tcacheReadInputTokens: data.message.usage.cache_read_input_tokens ?? 0,\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: cost,\n\t\t\t\t\tmodel: getDisplayModelName(data) ?? 'unknown',\n\t\t\t\t\tversion: data.version,\n\t\t\t\t\tusageLimitResetTime: usageLimitResetTime ?? undefined,\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\t// Skip invalid JSON lines but log for debugging purposes\n\t\t\t\tlogger.debug(\n\t\t\t\t\t`Skipping invalid JSON line in 5-hour blocks: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t);\n\t\t\t}\n\t\t});\n\t}\n\n\t// Identify session blocks\n\tconst blocks = identifySessionBlocks(allEntries, options?.sessionDurationHours);\n\n\t// Filter by date range if specified\n\tconst dateFiltered =\n\t\t(options?.since != null && options.since !== '') ||\n\t\t(options?.until != null && options.until !== '')\n\t\t\t? blocks.filter((block) => {\n\t\t\t\t\t// Always use DEFAULT_LOCALE for date comparison to ensure YYYY-MM-DD format\n\t\t\t\t\tconst blockDateStr = formatDate(\n\t\t\t\t\t\tblock.startTime.toISOString(),\n\t\t\t\t\t\toptions?.timezone,\n\t\t\t\t\t\tDEFAULT_LOCALE,\n\t\t\t\t\t).replace(/-/g, '');\n\t\t\t\t\tif (options.since != null && options.since !== '' && blockDateStr < options.since) {\n\t\t\t\t\t\treturn false;\n\t\t\t\t\t}\n\t\t\t\t\tif (options.until != null && options.until !== '' && blockDateStr > options.until) {\n\t\t\t\t\t\treturn false;\n\t\t\t\t\t}\n\t\t\t\t\treturn true;\n\t\t\t\t})\n\t\t\t: blocks;\n\n\t// Sort by start time based on order option\n\treturn sortByDate(dateFiltered, (block) => block.startTime, options?.order);\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('formatDate', () => {\n\t\tit('formats UTC timestamp to local date', () => {\n\t\t\t// Test with UTC timestamps - results depend on local timezone\n\t\t\texpect(formatDate('2024-01-01T00:00:00Z')).toBe('2024-01-01');\n\t\t\texpect(formatDate('2024-12-31T23:59:59Z')).toBe('2024-12-31');\n\t\t});\n\n\t\tit('respects timezone parameter', () => {\n\t\t\t// Test date that crosses day boundary\n\t\t\tconst testTimestamp = '2024-01-01T15:00:00Z'; // 3 PM UTC = midnight JST next day\n\n\t\t\t// Default behavior (no timezone) uses system timezone\n\t\t\texpect(formatDate(testTimestamp)).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n\n\t\t\t// UTC timezone\n\t\t\texpect(formatDate(testTimestamp, 'UTC')).toBe('2024-01-01');\n\n\t\t\t// Asia/Tokyo timezone (crosses to next day)\n\t\t\texpect(formatDate(testTimestamp, 'Asia/Tokyo')).toBe('2024-01-02');\n\n\t\t\t// America/New_York timezone\n\t\t\texpect(formatDate('2024-01-02T03:00:00Z', 'America/New_York')).toBe('2024-01-01'); // 3 AM UTC = 10 PM EST previous day\n\n\t\t\t// Invalid timezone should throw a RangeError\n\t\t\texpect(() => formatDate(testTimestamp, 'Invalid/Timezone')).toThrow(RangeError);\n\t\t});\n\n\t\tit('formatDateCompact respects timezone parameter', () => {\n\t\t\tconst testTimestamp = '2024-01-01T15:00:00Z';\n\n\t\t\t// UTC timezone\n\t\t\texpect(formatDateCompact(testTimestamp, 'UTC', 'en-US')).toBe('2024\\n01-01');\n\n\t\t\t// Asia/Tokyo timezone (crosses to next day)\n\t\t\texpect(formatDateCompact(testTimestamp, 'Asia/Tokyo', 'en-US')).toBe('2024\\n01-02');\n\n\t\t\t// Daily date defined as UTC is preserved\n\t\t\texpect(formatDateCompact('2024-01-01', 'UTC', 'en-US')).toBe('2024\\n01-01');\n\n\t\t\t// Daily date already in local time is preserved instead of being interpreted as UTC\n\t\t\texpect(formatDateCompact('2024-01-01', undefined, 'en-US')).toBe('2024\\n01-01');\n\t\t});\n\n\t\tit('handles various date formats', () => {\n\t\t\texpect(formatDate('2024-01-01')).toBe('2024-01-01');\n\t\t\texpect(formatDate('2024-01-01T12:00:00')).toBe('2024-01-01');\n\t\t\texpect(formatDate('2024-01-01T12:00:00.000Z')).toBe('2024-01-01');\n\t\t});\n\n\t\tit('pads single digit months and days', () => {\n\t\t\t// Use UTC noon to avoid timezone issues\n\t\t\texpect(formatDate('2024-01-05T12:00:00Z')).toBe('2024-01-05');\n\t\t\texpect(formatDate('2024-10-01T12:00:00Z')).toBe('2024-10-01');\n\t\t});\n\n\t\tit('respects locale parameter', () => {\n\t\t\tconst testDate = '2024-08-04T12:00:00Z';\n\n\t\t\t// Different locales format dates differently\n\t\t\texpect(formatDate(testDate, 'UTC', 'en-US')).toBe('08/04/2024');\n\t\t\texpect(formatDate(testDate, 'UTC', 'en-CA')).toBe('2024-08-04');\n\t\t\texpect(formatDate(testDate, 'UTC', 'ja-JP')).toBe('2024/08/04');\n\t\t\texpect(formatDate(testDate, 'UTC', 'de-DE')).toBe('04.08.2024');\n\t\t});\n\t});\n\n\tdescribe('loadSessionUsageById', async () => {\n\t\tconst { createFixture } = await import('fs-fixture');\n\n\t\tafterEach(() => {\n\t\t\tvi.unstubAllEnvs();\n\t\t});\n\n\t\tit('loads usage data for a specific session', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.claude': {\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\t'session-123.jsonl': `${JSON.stringify({\n\t\t\t\t\t\t\t\ttimestamp: '2024-01-01T00:00:00Z',\n\t\t\t\t\t\t\t\tsessionId: 'session-123',\n\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\t\t\t\tcache_creation_input_tokens: 10,\n\t\t\t\t\t\t\t\t\t\tcache_read_input_tokens: 20,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tcostUSD: 0.5,\n\t\t\t\t\t\t\t})}\\n${JSON.stringify({\n\t\t\t\t\t\t\t\ttimestamp: '2024-01-01T01:00:00Z',\n\t\t\t\t\t\t\t\tsessionId: 'session-123',\n\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\tinput_tokens: 200,\n\t\t\t\t\t\t\t\t\t\toutput_tokens: 100,\n\t\t\t\t\t\t\t\t\t\tcache_creation_input_tokens: 20,\n\t\t\t\t\t\t\t\t\t\tcache_read_input_tokens: 40,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tcostUSD: 1.0,\n\t\t\t\t\t\t\t})}`,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tvi.stubEnv('CLAUDE_CONFIG_DIR', fixture.getPath('.claude'));\n\n\t\t\tconst result = await loadSessionUsageById('session-123', { mode: 'display' });\n\n\t\t\texpect(result).not.toBeNull();\n\t\t\texpect(result?.totalCost).toBe(1.5);\n\t\t\texpect(result?.entries).toHaveLength(2);\n\t\t});\n\n\t\tit('returns null for non-existent session', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'.claude': {\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\t'other-session.jsonl': JSON.stringify({\n\t\t\t\t\t\t\t\ttimestamp: '2024-01-01T00:00:00Z',\n\t\t\t\t\t\t\t\tsessionId: 'other-session',\n\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tcostUSD: 0.5,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tvi.stubEnv('CLAUDE_CONFIG_DIR', fixture.getPath('.claude'));\n\n\t\t\tconst result = await loadSessionUsageById('non-existent', { mode: 'display' });\n\n\t\t\texpect(result).toBeNull();\n\t\t});\n\t});\n\n\tdescribe('formatDateCompact', () => {\n\t\tit('formats UTC timestamp to local date with line break', () => {\n\t\t\texpect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe('2024\\n01-01');\n\t\t});\n\n\t\tit('handles various date formats', () => {\n\t\t\texpect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe('2024\\n12-31');\n\t\t\texpect(formatDateCompact('2024-01-01', undefined, 'en-US')).toBe('2024\\n01-01');\n\t\t\texpect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe('2024\\n01-01');\n\t\t\texpect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe('2024\\n01-01');\n\t\t});\n\n\t\tit('pads single digit months and days', () => {\n\t\t\t// Use UTC noon to avoid timezone issues\n\t\t\texpect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe('2024\\n01-05');\n\t\t\texpect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe('2024\\n10-01');\n\t\t});\n\n\t\tit('respects locale parameter', () => {\n\t\t\tconst testDate = '2024-08-04T12:00:00Z';\n\n\t\t\t// Different locales format dates differently\n\t\t\texpect(formatDateCompact(testDate, 'UTC', 'en-US')).toBe('2024\\n08-04');\n\t\t\texpect(formatDateCompact(testDate, 'UTC', 'en-CA')).toBe('2024\\n08-04');\n\t\t\texpect(formatDateCompact(testDate, 'UTC', 'ja-JP')).toBe('2024\\n08-04');\n\t\t\t// All locales should produce similar compact format\n\t\t});\n\t});\n\n\tdescribe('getDisplayModelName', () => {\n\t\tit('returns model name as-is for standard speed', () => {\n\t\t\tconst data: UsageData = {\n\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\tmessage: {\n\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 50, speed: 'standard' },\n\t\t\t\t\tmodel: createModelName('claude-opus-4-6'),\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(getDisplayModelName(data)).toBe('claude-opus-4-6');\n\t\t});\n\n\t\tit('appends (fast) suffix for fast speed', () => {\n\t\t\tconst data: UsageData = {\n\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\tmessage: {\n\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 50, speed: 'fast' },\n\t\t\t\t\tmodel: createModelName('claude-opus-4-6'),\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(getDisplayModelName(data)).toBe('claude-opus-4-6-fast');\n\t\t});\n\n\t\tit('returns model name as-is when speed is undefined', () => {\n\t\t\tconst data: UsageData = {\n\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\tmessage: {\n\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 50 },\n\t\t\t\t\tmodel: createModelName('claude-opus-4-6'),\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(getDisplayModelName(data)).toBe('claude-opus-4-6');\n\t\t});\n\n\t\tit('returns undefined when model is undefined', () => {\n\t\t\tconst data: UsageData = {\n\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\tmessage: {\n\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 50, speed: 'fast' },\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(getDisplayModelName(data)).toBeUndefined();\n\t\t});\n\t});\n\n\tdescribe('loadDailyUsageData', () => {\n\t\tit('returns empty array when no files found', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({ claudePath: fixture.path });\n\t\t\texpect(result).toEqual([]);\n\t\t});\n\n\t\tit('aggregates daily usage data correctly', async () => {\n\t\t\t// Use timestamps in the middle of the day to avoid timezone issues\n\t\t\tconst mockData1: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst mockData2: UsageData = {\n\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T18:00:00Z'),\n\t\t\t\tmessage: { usage: { input_tokens: 300, output_tokens: 150 } },\n\t\t\t\tcostUSD: 0.03,\n\t\t\t};\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file1.jsonl': mockData1.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsession2: {\n\t\t\t\t\t\t\t'file2.jsonl': JSON.stringify(mockData2),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.date).toBe('2024-01-01');\n\t\t\texpect(result[0]?.inputTokens).toBe(600); // 100 + 200 + 300\n\t\t\texpect(result[0]?.outputTokens).toBe(300); // 50 + 100 + 150\n\t\t\texpect(result[0]?.totalCost).toBe(0.06); // 0.01 + 0.02 + 0.03\n\t\t});\n\n\t\tit('handles cache tokens', async () => {\n\t\t\tconst mockData: UsageData = {\n\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\tmessage: {\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\tcache_creation_input_tokens: 25,\n\t\t\t\t\t\tcache_read_input_tokens: 10,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.01,\n\t\t\t};\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': JSON.stringify(mockData),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result[0]?.cacheCreationTokens).toBe(25);\n\t\t\texpect(result[0]?.cacheReadTokens).toBe(10);\n\t\t});\n\n\t\tit('filters by date range', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 300, output_tokens: 150 } },\n\t\t\t\t\tcostUSD: 0.03,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\tsince: '20240110',\n\t\t\t\tuntil: '20240125',\n\t\t\t});\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.date).toBe('2024-01-15');\n\t\t\texpect(result[0]?.inputTokens).toBe(200);\n\t\t});\n\n\t\tit('sorts by date descending by default', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 300, output_tokens: 150 } },\n\t\t\t\t\tcostUSD: 0.03,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result[0]?.date).toBe('2024-01-31');\n\t\t\texpect(result[1]?.date).toBe('2024-01-15');\n\t\t\texpect(result[2]?.date).toBe('2024-01-01');\n\t\t});\n\n\t\tit(\"sorts by date ascending when order is 'asc'\", async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 300, output_tokens: 150 } },\n\t\t\t\t\tcostUSD: 0.03,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'usage.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'asc',\n\t\t\t});\n\n\t\t\texpect(result).toHaveLength(3);\n\t\t\texpect(result[0]?.date).toBe('2024-01-01');\n\t\t\texpect(result[1]?.date).toBe('2024-01-15');\n\t\t\texpect(result[2]?.date).toBe('2024-01-31');\n\t\t});\n\n\t\tit(\"sorts by date descending when order is 'desc'\", async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 300, output_tokens: 150 } },\n\t\t\t\t\tcostUSD: 0.03,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'usage.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'desc',\n\t\t\t});\n\n\t\t\texpect(result).toHaveLength(3);\n\t\t\texpect(result[0]?.date).toBe('2024-01-31');\n\t\t\texpect(result[1]?.date).toBe('2024-01-15');\n\t\t\texpect(result[2]?.date).toBe('2024-01-01');\n\t\t});\n\n\t\tit('handles invalid JSON lines gracefully', async () => {\n\t\t\tconst mockData = `\n{\"timestamp\":\"2024-01-01T12:00:00Z\",\"message\":{\"usage\":{\"input_tokens\":100,\"output_tokens\":50}},\"costUSD\":0.01}\ninvalid json line\n{\"timestamp\":\"2024-01-01T12:00:00Z\",\"message\":{\"usage\":{\"input_tokens\":200,\"output_tokens\":100}},\"costUSD\":0.02}\n{ broken json\n{\"timestamp\":\"2024-01-01T18:00:00Z\",\"message\":{\"usage\":{\"input_tokens\":300,\"output_tokens\":150}},\"costUSD\":0.03}\n`.trim();\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t// Should only process valid lines\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.inputTokens).toBe(600); // 100 + 200 + 300\n\t\t\texpect(result[0]?.totalCost).toBe(0.06); // 0.01 + 0.02 + 0.03\n\t\t});\n\n\t\tit('skips data without required fields', async () => {\n\t\t\tconst mockData = `\n{\"timestamp\":\"2024-01-01T12:00:00Z\",\"message\":{\"usage\":{\"input_tokens\":100,\"output_tokens\":50}},\"costUSD\":0.01}\n{\"timestamp\":\"2024-01-01T14:00:00Z\",\"message\":{\"usage\":{}}}\n{\"timestamp\":\"2024-01-01T18:00:00Z\",\"message\":{}}\n{\"timestamp\":\"2024-01-01T20:00:00Z\"}\n{\"message\":{\"usage\":{\"input_tokens\":200,\"output_tokens\":100}}}\n{\"timestamp\":\"2024-01-01T22:00:00Z\",\"message\":{\"usage\":{\"input_tokens\":300,\"output_tokens\":150}},\"costUSD\":0.03}\n`.trim();\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t// Should only include valid entries\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.inputTokens).toBe(400); // 100 + 300\n\t\t\texpect(result[0]?.totalCost).toBe(0.04); // 0.01 + 0.03\n\t\t});\n\t});\n\n\tdescribe('loadMonthlyUsageData', () => {\n\t\tit('aggregates daily data by month correctly', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-02-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 150, output_tokens: 75 } },\n\t\t\t\t\tcostUSD: 0.015,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadMonthlyUsageData({ claudePath: fixture.path });\n\n\t\t\t// Should be sorted by month descending (2024-02 first)\n\t\t\texpect(result).toHaveLength(2);\n\t\t\texpect(result[0]).toEqual({\n\t\t\t\tmonth: '2024-02',\n\t\t\t\tinputTokens: 150,\n\t\t\t\toutputTokens: 75,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalCost: 0.015,\n\t\t\t\tmodelsUsed: [],\n\t\t\t\tmodelBreakdowns: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmodelName: 'unknown',\n\t\t\t\t\t\tinputTokens: 150,\n\t\t\t\t\t\toutputTokens: 75,\n\t\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\t\tcost: 0.015,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\t\t\texpect(result[1]).toEqual({\n\t\t\t\tmonth: '2024-01',\n\t\t\t\tinputTokens: 300,\n\t\t\t\toutputTokens: 150,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalCost: 0.03,\n\t\t\t\tmodelsUsed: [],\n\t\t\t\tmodelBreakdowns: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmodelName: 'unknown',\n\t\t\t\t\t\tinputTokens: 300,\n\t\t\t\t\t\toutputTokens: 150,\n\t\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\t\tcost: 0.03,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\t\t});\n\n\t\tit('handles empty data', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {},\n\t\t\t});\n\n\t\t\tconst result = await loadMonthlyUsageData({ claudePath: fixture.path });\n\t\t\texpect(result).toEqual([]);\n\t\t});\n\n\t\tit('handles single month data', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadMonthlyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]).toEqual({\n\t\t\t\tmonth: '2024-01',\n\t\t\t\tinputTokens: 300,\n\t\t\t\toutputTokens: 150,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalCost: 0.03,\n\t\t\t\tmodelsUsed: [],\n\t\t\t\tmodelBreakdowns: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmodelName: 'unknown',\n\t\t\t\t\t\tinputTokens: 300,\n\t\t\t\t\t\toutputTokens: 150,\n\t\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\t\tcost: 0.03,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\t\t});\n\n\t\tit('sorts months in descending order', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-03-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-02-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2023-12-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadMonthlyUsageData({ claudePath: fixture.path });\n\t\t\tconst months = result.map((r) => r.month);\n\n\t\t\texpect(months).toEqual(['2024-03', '2024-02', '2024-01', '2023-12']);\n\t\t});\n\n\t\tit(\"sorts months in ascending order when order is 'asc'\", async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-03-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-02-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2023-12-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadMonthlyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'asc',\n\t\t\t});\n\t\t\tconst months = result.map((r) => r.month);\n\n\t\t\texpect(months).toEqual(['2023-12', '2024-01', '2024-02', '2024-03']);\n\t\t});\n\n\t\tit('handles year boundaries correctly in sorting', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2023-12-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-02-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2023-11-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Descending order (default)\n\t\t\tconst descResult = await loadMonthlyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'desc',\n\t\t\t});\n\t\t\tconst descMonths = descResult.map((r) => r.month);\n\t\t\texpect(descMonths).toEqual(['2024-02', '2024-01', '2023-12', '2023-11']);\n\n\t\t\t// Ascending order\n\t\t\tconst ascResult = await loadMonthlyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'asc',\n\t\t\t});\n\t\t\tconst ascMonths = ascResult.map((r) => r.month);\n\t\t\texpect(ascMonths).toEqual(['2023-11', '2023-12', '2024-01', '2024-02']);\n\t\t});\n\n\t\tit('respects date filters', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-02-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-03-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 150, output_tokens: 75 } },\n\t\t\t\t\tcostUSD: 0.015,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadMonthlyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\tsince: '20240110',\n\t\t\t\tuntil: '20240225',\n\t\t\t});\n\n\t\t\t// Should only include February data\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.month).toBe('2024-02');\n\t\t\texpect(result[0]?.inputTokens).toBe(200);\n\t\t});\n\n\t\tit('handles cache tokens correctly', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 25,\n\t\t\t\t\t\t\tcache_read_input_tokens: 10,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 200,\n\t\t\t\t\t\t\toutput_tokens: 100,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 50,\n\t\t\t\t\t\t\tcache_read_input_tokens: 20,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadMonthlyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.cacheCreationTokens).toBe(75); // 25 + 50\n\t\t\texpect(result[0]?.cacheReadTokens).toBe(30); // 10 + 20\n\t\t});\n\t});\n\n\tdescribe('loadWeeklyUsageData', () => {\n\t\tit('aggregates daily data by week correctly', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-02T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 150, output_tokens: 75 } },\n\t\t\t\t\tcostUSD: 0.015,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadWeeklyUsageData({ claudePath: fixture.path });\n\n\t\t\t// Should be sorted by week descending (2024-01-15 first)\n\t\t\texpect(result).toHaveLength(2);\n\t\t\texpect(result[0]).toEqual({\n\t\t\t\tweek: '2024-01-14',\n\t\t\t\tinputTokens: 150,\n\t\t\t\toutputTokens: 75,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalCost: 0.015,\n\t\t\t\tmodelsUsed: [],\n\t\t\t\tmodelBreakdowns: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmodelName: 'unknown',\n\t\t\t\t\t\tinputTokens: 150,\n\t\t\t\t\t\toutputTokens: 75,\n\t\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\t\tcost: 0.015,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\t\t\texpect(result[1]).toEqual({\n\t\t\t\tweek: '2023-12-31',\n\t\t\t\tinputTokens: 300,\n\t\t\t\toutputTokens: 150,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalCost: 0.03,\n\t\t\t\tmodelsUsed: [],\n\t\t\t\tmodelBreakdowns: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmodelName: 'unknown',\n\t\t\t\t\t\tinputTokens: 300,\n\t\t\t\t\t\toutputTokens: 150,\n\t\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\t\tcost: 0.03,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\t\t});\n\n\t\tit('handles empty data', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {},\n\t\t\t});\n\n\t\t\tconst result = await loadWeeklyUsageData({ claudePath: fixture.path });\n\t\t\texpect(result).toEqual([]);\n\t\t});\n\n\t\tit('handles single week data', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-03T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadWeeklyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]).toEqual({\n\t\t\t\tweek: '2023-12-31',\n\t\t\t\tinputTokens: 300,\n\t\t\t\toutputTokens: 150,\n\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalCost: 0.03,\n\t\t\t\tmodelsUsed: [],\n\t\t\t\tmodelBreakdowns: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmodelName: 'unknown',\n\t\t\t\t\t\tinputTokens: 300,\n\t\t\t\t\t\toutputTokens: 150,\n\t\t\t\t\t\tcacheCreationTokens: 0,\n\t\t\t\t\t\tcacheReadTokens: 0,\n\t\t\t\t\t\tcost: 0.03,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\t\t});\n\n\t\tit('sorts weeks in descending order', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-08T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-22T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadWeeklyUsageData({ claudePath: fixture.path });\n\t\t\tconst weeks = result.map((r) => r.week);\n\n\t\t\texpect(weeks).toEqual(['2024-01-21', '2024-01-14', '2024-01-07', '2023-12-31']);\n\t\t});\n\n\t\tit(\"sorts weeks in ascending order when order is 'asc'\", async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-08T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-22T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadWeeklyUsageData({ claudePath: fixture.path, order: 'asc' });\n\t\t\tconst weeks = result.map((r) => r.week);\n\n\t\t\texpect(weeks).toEqual(['2023-12-31', '2024-01-07', '2024-01-14', '2024-01-21']);\n\t\t});\n\n\t\tit('handles year boundaries correctly in sorting', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2023-12-04T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-02-05T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2023-11-06T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Descending order (default)\n\t\t\tconst descResult = await loadWeeklyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'desc',\n\t\t\t});\n\t\t\tconst descWeeks = descResult.map((r) => r.week);\n\t\t\texpect(descWeeks).toEqual(['2024-02-04', '2023-12-31', '2023-12-03', '2023-11-05']);\n\n\t\t\t// Ascending order\n\t\t\tconst ascResult = await loadWeeklyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'asc',\n\t\t\t});\n\t\t\tconst ascWeeks = ascResult.map((r) => r.week);\n\t\t\texpect(ascWeeks).toEqual(['2023-11-05', '2023-12-03', '2023-12-31', '2024-02-04']);\n\t\t});\n\n\t\tit('respects date filters', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-02T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-02-06T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-03-05T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 150, output_tokens: 75 } },\n\t\t\t\t\tcostUSD: 0.015,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadWeeklyUsageData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\tsince: '20240110',\n\t\t\t\tuntil: '20240225',\n\t\t\t});\n\n\t\t\t// Should only include February data\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.week).toBe('2024-02-04');\n\t\t\texpect(result[0]?.inputTokens).toBe(200);\n\t\t});\n\n\t\tit('handles cache tokens correctly', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-02T12:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 25,\n\t\t\t\t\t\t\tcache_read_input_tokens: 10,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-03T12:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 200,\n\t\t\t\t\t\t\toutput_tokens: 100,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 50,\n\t\t\t\t\t\t\tcache_read_input_tokens: 20,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadWeeklyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.cacheCreationTokens).toBe(75); // 25 + 50\n\t\t\texpect(result[0]?.cacheReadTokens).toBe(30); // 10 + 20\n\t\t});\n\t});\n\n\tdescribe('loadSessionData', () => {\n\t\tit('returns empty array when no files found', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionData({ claudePath: fixture.path });\n\t\t\texpect(result).toEqual([]);\n\t\t});\n\n\t\tit('extracts session info from file paths', async () => {\n\t\t\tconst mockData: UsageData = {\n\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\tcostUSD: 0.01,\n\t\t\t};\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\t'project1/subfolder': {\n\t\t\t\t\t\tsession123: {\n\t\t\t\t\t\t\t'chat.jsonl': JSON.stringify(mockData),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tproject2: {\n\t\t\t\t\t\tsession456: {\n\t\t\t\t\t\t\t'chat.jsonl': JSON.stringify(mockData),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(2);\n\t\t\texpect(result.find((s) => s.sessionId === 'session123')).toBeTruthy();\n\t\t\texpect(result.find((s) => s.projectPath === path.join('project1', 'subfolder'))).toBeTruthy();\n\t\t\texpect(result.find((s) => s.sessionId === 'session456')).toBeTruthy();\n\t\t\texpect(result.find((s) => s.projectPath === 'project2')).toBeTruthy();\n\t\t});\n\n\t\tit('aggregates session usage data', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 10,\n\t\t\t\t\t\t\tcache_read_input_tokens: 5,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 200,\n\t\t\t\t\t\t\toutput_tokens: 100,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 20,\n\t\t\t\t\t\t\tcache_read_input_tokens: 10,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'chat.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\tconst session = result[0];\n\t\t\texpect(session?.sessionId).toBe('session1');\n\t\t\texpect(session?.projectPath).toBe('project1');\n\t\t\texpect(session?.inputTokens).toBe(300); // 100 + 200\n\t\t\texpect(session?.outputTokens).toBe(150); // 50 + 100\n\t\t\texpect(session?.cacheCreationTokens).toBe(30); // 10 + 20\n\t\t\texpect(session?.cacheReadTokens).toBe(15); // 5 + 10\n\t\t\texpect(session?.totalCost).toBe(0.03); // 0.01 + 0.02\n\t\t\texpect(session?.lastActivity).toBe('2024-01-01');\n\t\t});\n\n\t\tit('tracks versions', async () => {\n\t\t\tconst mockData: UsageData[] = [\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\tversion: createVersion('1.1.0'),\n\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T18:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 300, output_tokens: 150 } },\n\t\t\t\t\tversion: createVersion('1.0.0'), // Duplicate version\n\t\t\t\t\tcostUSD: 0.03,\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'chat.jsonl': mockData.map((d) => JSON.stringify(d)).join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionData({ claudePath: fixture.path });\n\n\t\t\tconst session = result[0];\n\t\t\texpect(session?.versions).toEqual(['1.0.0', '1.1.0']); // Sorted and unique\n\t\t});\n\n\t\tit('sorts by last activity descending', async () => {\n\t\t\tconst sessions = [\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session1',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session2',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session3',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: Object.fromEntries(\n\t\t\t\t\t\tsessions.map((s) => [s.sessionId, { 'chat.jsonl': JSON.stringify(s.data) }]),\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionData({ claudePath: fixture.path });\n\n\t\t\texpect(result[0]?.sessionId).toBe('session3');\n\t\t\texpect(result[1]?.sessionId).toBe('session1');\n\t\t\texpect(result[2]?.sessionId).toBe('session2');\n\t\t});\n\n\t\tit(\"sorts by last activity ascending when order is 'asc'\", async () => {\n\t\t\tconst sessions = [\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session1',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session2',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session3',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: Object.fromEntries(\n\t\t\t\t\t\tsessions.map((s) => [s.sessionId, { 'chat.jsonl': JSON.stringify(s.data) }]),\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'asc',\n\t\t\t});\n\n\t\t\texpect(result[0]?.sessionId).toBe('session2'); // oldest first\n\t\t\texpect(result[1]?.sessionId).toBe('session1');\n\t\t\texpect(result[2]?.sessionId).toBe('session3'); // newest last\n\t\t});\n\n\t\tit(\"sorts by last activity descending when order is 'desc'\", async () => {\n\t\t\tconst sessions = [\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session1',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session2',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session3',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: Object.fromEntries(\n\t\t\t\t\t\tsessions.map((s) => [s.sessionId, { 'chat.jsonl': JSON.stringify(s.data) }]),\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'desc',\n\t\t\t});\n\n\t\t\texpect(result[0]?.sessionId).toBe('session3'); // newest first (same as default)\n\t\t\texpect(result[1]?.sessionId).toBe('session1');\n\t\t\texpect(result[2]?.sessionId).toBe('session2'); // oldest last\n\t\t});\n\n\t\tit('filters by date range based on last activity', async () => {\n\t\t\tconst sessions = [\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session1',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session2',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-15T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsessionId: 'session3',\n\t\t\t\t\tdata: {\n\t\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-31T12:00:00Z'),\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: Object.fromEntries(\n\t\t\t\t\t\tsessions.map((s) => [s.sessionId, { 'chat.jsonl': JSON.stringify(s.data) }]),\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\tsince: '20240110',\n\t\t\t\tuntil: '20240125',\n\t\t\t});\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.lastActivity).toBe('2024-01-15');\n\t\t});\n\t});\n\n\tdescribe('loadDailyUsageData with fast mode', () => {\n\t\tit('should separate fast and standard entries into different model breakdowns', async () => {\n\t\t\tconst standardEntry = JSON.stringify({\n\t\t\t\ttimestamp: '2024-01-01T10:00:00Z',\n\t\t\t\tmessage: {\n\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 50, speed: 'standard' },\n\t\t\t\t\tmodel: 'claude-opus-4-6',\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.01,\n\t\t\t});\n\t\t\tconst fastEntry = JSON.stringify({\n\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\tmessage: {\n\t\t\t\t\tusage: { input_tokens: 200, output_tokens: 100, speed: 'fast' },\n\t\t\t\t\tmodel: 'claude-opus-4-6',\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.05,\n\t\t\t});\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file1.jsonl': `${standardEntry}\\n${fastEntry}`,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.modelBreakdowns).toHaveLength(2);\n\n\t\t\tconst standardBreakdown = result[0]?.modelBreakdowns.find(\n\t\t\t\t(b) => b.modelName === 'claude-opus-4-6',\n\t\t\t);\n\t\t\tconst fastBreakdown = result[0]?.modelBreakdowns.find(\n\t\t\t\t(b) => b.modelName === 'claude-opus-4-6-fast',\n\t\t\t);\n\n\t\t\texpect(standardBreakdown).toBeDefined();\n\t\t\texpect(fastBreakdown).toBeDefined();\n\t\t\texpect(standardBreakdown?.inputTokens).toBe(100);\n\t\t\texpect(fastBreakdown?.inputTokens).toBe(200);\n\t\t});\n\n\t\tit('should treat entries without speed field as standard', async () => {\n\t\t\tconst noSpeedEntry = JSON.stringify({\n\t\t\t\ttimestamp: '2024-01-01T10:00:00Z',\n\t\t\t\tmessage: {\n\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 50 },\n\t\t\t\t\tmodel: 'claude-opus-4-6',\n\t\t\t\t},\n\t\t\t\tcostUSD: 0.01,\n\t\t\t});\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'file1.jsonl': noSpeedEntry,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.modelBreakdowns).toHaveLength(1);\n\t\t\texpect(result[0]?.modelBreakdowns[0]?.modelName).toBe('claude-opus-4-6');\n\t\t});\n\t});\n\n\tdescribe('data-loader cost calculation with real pricing', () => {\n\t\tdescribe('loadDailyUsageData with mixed schemas', () => {\n\t\t\tit('should handle old schema with costUSD', async () => {\n\t\t\t\tconst oldData = {\n\t\t\t\t\ttimestamp: '2024-01-15T10:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.05, // Pre-calculated cost\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project-old': {\n\t\t\t\t\t\t\t'session-old': {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(oldData)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.date).toBe('2024-01-15');\n\t\t\t\texpect(results[0]?.inputTokens).toBe(1000);\n\t\t\t\texpect(results[0]?.outputTokens).toBe(500);\n\t\t\t\texpect(results[0]?.totalCost).toBe(0.05);\n\t\t\t});\n\n\t\t\tit('should calculate cost for new schema with claude-sonnet-4-20250514', async () => {\n\t\t\t\t// Use a well-known Claude model\n\t\t\t\tconst modelName = createModelName('claude-sonnet-4-20250514');\n\n\t\t\t\tconst newData = {\n\t\t\t\t\ttimestamp: '2024-01-16T10:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 200,\n\t\t\t\t\t\t\tcache_read_input_tokens: 300,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmodel: modelName,\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project-new': {\n\t\t\t\t\t\t\t'session-new': {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(newData)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.date).toBe('2024-01-16');\n\t\t\t\texpect(results[0]?.inputTokens).toBe(1000);\n\t\t\t\texpect(results[0]?.outputTokens).toBe(500);\n\t\t\t\texpect(results[0]?.cacheCreationTokens).toBe(200);\n\t\t\t\texpect(results[0]?.cacheReadTokens).toBe(300);\n\n\t\t\t\t// Should have calculated some cost\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThan(0);\n\t\t\t});\n\n\t\t\tit('should calculate cost for new schema with claude-opus-4-20250514', async () => {\n\t\t\t\t// Use Claude 4 Opus model\n\t\t\t\tconst modelName = createModelName('claude-opus-4-20250514');\n\n\t\t\t\tconst newData = {\n\t\t\t\t\ttimestamp: '2024-01-16T10:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 200,\n\t\t\t\t\t\t\tcache_read_input_tokens: 300,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmodel: modelName,\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project-opus': {\n\t\t\t\t\t\t\t'session-opus': {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(newData)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.date).toBe('2024-01-16');\n\t\t\t\texpect(results[0]?.inputTokens).toBe(1000);\n\t\t\t\texpect(results[0]?.outputTokens).toBe(500);\n\t\t\t\texpect(results[0]?.cacheCreationTokens).toBe(200);\n\t\t\t\texpect(results[0]?.cacheReadTokens).toBe(300);\n\n\t\t\t\t// Should have calculated some cost\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThan(0);\n\t\t\t});\n\n\t\t\tit('should handle mixed data in same file', async () => {\n\t\t\t\tconst data1 = {\n\t\t\t\t\ttimestamp: '2024-01-17T10:00:00Z',\n\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t};\n\n\t\t\t\tconst data2 = {\n\t\t\t\t\ttimestamp: '2024-01-17T11:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 200, output_tokens: 100 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tconst data3 = {\n\t\t\t\t\ttimestamp: '2024-01-17T12:00:00Z',\n\t\t\t\t\tmessage: { usage: { input_tokens: 300, output_tokens: 150 } },\n\t\t\t\t\t// No costUSD and no model - should be 0 cost\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project-mixed': {\n\t\t\t\t\t\t\t'session-mixed': {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(data1)}\\n${JSON.stringify(data2)}\\n${JSON.stringify(data3)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.date).toBe('2024-01-17');\n\t\t\t\texpect(results[0]?.inputTokens).toBe(600); // 100 + 200 + 300\n\t\t\t\texpect(results[0]?.outputTokens).toBe(300); // 50 + 100 + 150\n\n\t\t\t\t// Total cost should be at least the pre-calculated cost from data1\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThanOrEqual(0.01);\n\t\t\t});\n\n\t\t\tit('should handle data without model or costUSD', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: '2024-01-18T10:00:00Z',\n\t\t\t\t\tmessage: { usage: { input_tokens: 500, output_tokens: 250 } },\n\t\t\t\t\t// No costUSD and no model\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project-no-cost': {\n\t\t\t\t\t\t\t'session-no-cost': {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(data)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.inputTokens).toBe(500);\n\t\t\t\texpect(results[0]?.outputTokens).toBe(250);\n\t\t\t\texpect(results[0]?.totalCost).toBe(0); // 0 cost when no pricing info available\n\t\t\t});\n\t\t});\n\n\t\tdescribe('loadSessionData with mixed schemas', () => {\n\t\t\tit('should handle mixed cost sources in different sessions', async () => {\n\t\t\t\tconst session1Data = {\n\t\t\t\t\ttimestamp: '2024-01-15T10:00:00Z',\n\t\t\t\t\tmessage: { usage: { input_tokens: 1000, output_tokens: 500 } },\n\t\t\t\t\tcostUSD: 0.05,\n\t\t\t\t};\n\n\t\t\t\tconst session2Data = {\n\t\t\t\t\ttimestamp: '2024-01-16T10:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 2000, output_tokens: 1000 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(session1Data),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tsession2: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(session2Data),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadSessionData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(2);\n\n\t\t\t\t// Check session 1\n\t\t\t\tconst session1 = results.find((s) => s.sessionId === 'session1');\n\t\t\t\texpect(session1).toBeTruthy();\n\t\t\t\texpect(session1?.totalCost).toBe(0.05);\n\n\t\t\t\t// Check session 2\n\t\t\t\tconst session2 = results.find((s) => s.sessionId === 'session2');\n\t\t\t\texpect(session2).toBeTruthy();\n\t\t\t\texpect(session2?.totalCost).toBeGreaterThan(0);\n\t\t\t});\n\n\t\t\tit('should handle unknown models gracefully', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: '2024-01-19T10:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: 'unknown-model-xyz',\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project-unknown': {\n\t\t\t\t\t\t\t'session-unknown': {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(data)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadSessionData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.inputTokens).toBe(1000);\n\t\t\t\texpect(results[0]?.outputTokens).toBe(500);\n\t\t\t\texpect(results[0]?.totalCost).toBe(0); // 0 cost for unknown model\n\t\t\t});\n\t\t});\n\n\t\tdescribe('cached tokens cost calculation', () => {\n\t\t\tit('should correctly calculate costs for all token types with claude-sonnet-4-20250514', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: '2024-01-20T10:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 2000,\n\t\t\t\t\t\t\tcache_read_input_tokens: 1500,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project-cache': {\n\t\t\t\t\t\t\t'session-cache': {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(data)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.date).toBe('2024-01-20');\n\t\t\t\texpect(results[0]?.inputTokens).toBe(1000);\n\t\t\t\texpect(results[0]?.outputTokens).toBe(500);\n\t\t\t\texpect(results[0]?.cacheCreationTokens).toBe(2000);\n\t\t\t\texpect(results[0]?.cacheReadTokens).toBe(1500);\n\n\t\t\t\t// Should have calculated cost including cache tokens\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThan(0);\n\t\t\t});\n\n\t\t\tit('should correctly calculate costs for all token types with claude-opus-4-20250514', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: '2024-01-20T10:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 2000,\n\t\t\t\t\t\t\tcache_read_input_tokens: 1500,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmodel: createModelName('claude-opus-4-20250514'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project-opus-cache': {\n\t\t\t\t\t\t\t'session-opus-cache': {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(data)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({ claudePath: fixture.path });\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.date).toBe('2024-01-20');\n\t\t\t\texpect(results[0]?.inputTokens).toBe(1000);\n\t\t\t\texpect(results[0]?.outputTokens).toBe(500);\n\t\t\t\texpect(results[0]?.cacheCreationTokens).toBe(2000);\n\t\t\t\texpect(results[0]?.cacheReadTokens).toBe(1500);\n\n\t\t\t\t// Should have calculated cost including cache tokens\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThan(0);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('cost mode functionality', () => {\n\t\t\tit('auto mode: uses costUSD when available, calculates otherwise', async () => {\n\t\t\t\tconst data1 = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: { usage: { input_tokens: 1000, output_tokens: 500 } },\n\t\t\t\t\tcostUSD: 0.05,\n\t\t\t\t};\n\n\t\t\t\tconst data2 = {\n\t\t\t\t\ttimestamp: '2024-01-01T11:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 2000, output_tokens: 1000 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession: {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(data1)}\\n${JSON.stringify(data2)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'auto',\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThan(0.05); // Should include both costs\n\t\t\t});\n\n\t\t\tit('calculate mode: always calculates from tokens, ignores costUSD', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 99.99, // This should be ignored\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(data),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'calculate',\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThan(0);\n\t\t\t\texpect(results[0]?.totalCost).toBeLessThan(1); // Much less than 99.99\n\t\t\t});\n\n\t\t\tit('display mode: always uses costUSD, even if undefined', async () => {\n\t\t\t\tconst data1 = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.05,\n\t\t\t\t};\n\n\t\t\t\tconst data2 = {\n\t\t\t\t\ttimestamp: '2024-01-01T11:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 2000, output_tokens: 1000 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t\t// No costUSD - should result in 0 cost\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession: {\n\t\t\t\t\t\t\t\t'usage.jsonl': `${JSON.stringify(data1)}\\n${JSON.stringify(data2)}\\n`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst results = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'display',\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.totalCost).toBe(0.05); // Only the costUSD from data1\n\t\t\t});\n\n\t\t\tit('mode works with session data', async () => {\n\t\t\t\tconst sessionData = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 99.99,\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(sessionData),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\t// Test calculate mode\n\t\t\t\tconst calculateResults = await loadSessionData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'calculate',\n\t\t\t\t});\n\t\t\t\texpect(calculateResults[0]?.totalCost).toBeLessThan(1);\n\n\t\t\t\t// Test display mode\n\t\t\t\tconst displayResults = await loadSessionData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'display',\n\t\t\t\t});\n\t\t\t\texpect(displayResults[0]?.totalCost).toBe(99.99);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('pricing data fetching optimization', () => {\n\t\t\tit('should not require model pricing when mode is display', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.05,\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(data),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\t// In display mode, only pre-calculated costUSD should be used\n\t\t\t\tconst results = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'display',\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.totalCost).toBe(0.05);\n\t\t\t});\n\n\t\t\tit('should fetch pricing data when mode is calculate', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.05,\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(data),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\t// This should fetch pricing data (will call real fetch)\n\t\t\t\tconst results = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'calculate',\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThan(0);\n\t\t\t\texpect(results[0]?.totalCost).not.toBe(0.05); // Should calculate, not use costUSD\n\t\t\t});\n\n\t\t\tit('should fetch pricing data when mode is auto', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t\t// No costUSD, so auto mode will need to calculate\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(data),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\t// This should fetch pricing data (will call real fetch)\n\t\t\t\tconst results = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'auto',\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.totalCost).toBeGreaterThan(0);\n\t\t\t});\n\n\t\t\tit('session data should not require model pricing when mode is display', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.05,\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(data),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\t// In display mode, only pre-calculated costUSD should be used\n\t\t\t\tconst results = await loadSessionData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'display',\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.totalCost).toBe(0.05);\n\t\t\t});\n\n\t\t\tit('display mode should work without network access', async () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: 'some-unknown-model',\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.05,\n\t\t\t\t};\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'test-project': {\n\t\t\t\t\t\t\tsession: {\n\t\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify(data),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\t// This test verifies that display mode doesn't try to fetch pricing\n\t\t\t\t// by using an unknown model that would cause pricing lookup to fail\n\t\t\t\t// if it were attempted. Since we're in display mode, it should just\n\t\t\t\t// use the costUSD value.\n\t\t\t\tconst results = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'display',\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(1);\n\t\t\t\texpect(results[0]?.totalCost).toBe(0.05);\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('calculateCostForEntry', () => {\n\t\tconst mockUsageData: UsageData = {\n\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\tmessage: {\n\t\t\t\tusage: {\n\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\tcache_creation_input_tokens: 200,\n\t\t\t\t\tcache_read_input_tokens: 100,\n\t\t\t\t},\n\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t},\n\t\t\tcostUSD: 0.05,\n\t\t};\n\n\t\tdescribe('display mode', () => {\n\t\t\tit('should return costUSD when available', async () => {\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(mockUsageData, 'display', fetcher);\n\t\t\t\texpect(result).toBe(0.05);\n\t\t\t});\n\n\t\t\tit('should return 0 when costUSD is undefined', async () => {\n\t\t\t\tconst dataWithoutCost = { ...mockUsageData };\n\t\t\t\tdataWithoutCost.costUSD = undefined;\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithoutCost, 'display', fetcher);\n\t\t\t\texpect(result).toBe(0);\n\t\t\t});\n\n\t\t\tit('should not use model pricing in display mode', async () => {\n\t\t\t\t// Even with model pricing available, should use costUSD\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(mockUsageData, 'display', fetcher);\n\t\t\t\texpect(result).toBe(0.05);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('calculate mode', () => {\n\t\t\tit('should calculate cost from tokens when model pricing available', async () => {\n\t\t\t\t// Use the exact same structure as working integration tests\n\t\t\t\tconst testData: UsageData = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(testData, 'calculate', fetcher);\n\n\t\t\t\texpect(result).toBeGreaterThan(0);\n\t\t\t});\n\n\t\t\tit('should ignore costUSD in calculate mode', async () => {\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst dataWithHighCost = { ...mockUsageData, costUSD: 99.99 };\n\t\t\t\tconst result = await calculateCostForEntry(dataWithHighCost, 'calculate', fetcher);\n\n\t\t\t\texpect(result).toBeGreaterThan(0);\n\t\t\t\texpect(result).toBeLessThan(1); // Much less than 99.99\n\t\t\t});\n\n\t\t\tit('should return 0 when model not available', async () => {\n\t\t\t\tconst dataWithoutModel = { ...mockUsageData };\n\t\t\t\tdataWithoutModel.message.model = undefined;\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithoutModel, 'calculate', fetcher);\n\t\t\t\texpect(result).toBe(0);\n\t\t\t});\n\n\t\t\tit('should return 0 when model pricing not found', async () => {\n\t\t\t\tconst dataWithUnknownModel = {\n\t\t\t\t\t...mockUsageData,\n\t\t\t\t\tmessage: { ...mockUsageData.message, model: createModelName('unknown-model') },\n\t\t\t\t};\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithUnknownModel, 'calculate', fetcher);\n\t\t\t\texpect(result).toBe(0);\n\t\t\t});\n\n\t\t\tit('should handle missing cache tokens', async () => {\n\t\t\t\tconst dataWithoutCacheTokens: UsageData = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithoutCacheTokens, 'calculate', fetcher);\n\n\t\t\t\texpect(result).toBeGreaterThan(0);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('auto mode', () => {\n\t\t\tit('should use costUSD when available', async () => {\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(mockUsageData, 'auto', fetcher);\n\t\t\t\texpect(result).toBe(0.05);\n\t\t\t});\n\n\t\t\tit('should calculate from tokens when costUSD undefined', async () => {\n\t\t\t\tconst dataWithoutCost: UsageData = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmodel: createModelName('claude-4-sonnet-20250514'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithoutCost, 'auto', fetcher);\n\t\t\t\texpect(result).toBeGreaterThan(0);\n\t\t\t});\n\n\t\t\tit('should return 0 when no costUSD and no model', async () => {\n\t\t\t\tconst dataWithoutCostOrModel = { ...mockUsageData };\n\t\t\t\tdataWithoutCostOrModel.costUSD = undefined;\n\t\t\t\tdataWithoutCostOrModel.message.model = undefined;\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithoutCostOrModel, 'auto', fetcher);\n\t\t\t\texpect(result).toBe(0);\n\t\t\t});\n\n\t\t\tit('should return 0 when no costUSD and model pricing not found', async () => {\n\t\t\t\tconst dataWithoutCost = { ...mockUsageData };\n\t\t\t\tdataWithoutCost.costUSD = undefined;\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithoutCost, 'auto', fetcher);\n\t\t\t\texpect(result).toBe(0);\n\t\t\t});\n\n\t\t\tit('should prefer costUSD over calculation even when both available', async () => {\n\t\t\t\t// Both costUSD and model pricing available, should use costUSD\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(mockUsageData, 'auto', fetcher);\n\t\t\t\texpect(result).toBe(0.05);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('edge cases', () => {\n\t\t\tit('should handle zero token counts', async () => {\n\t\t\t\tconst dataWithZeroTokens = {\n\t\t\t\t\t...mockUsageData,\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\t...mockUsageData.message,\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 0,\n\t\t\t\t\t\t\toutput_tokens: 0,\n\t\t\t\t\t\t\tcache_creation_input_tokens: 0,\n\t\t\t\t\t\t\tcache_read_input_tokens: 0,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t\tdataWithZeroTokens.costUSD = undefined;\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithZeroTokens, 'calculate', fetcher);\n\t\t\t\texpect(result).toBe(0);\n\t\t\t});\n\n\t\t\tit('should handle costUSD of 0', async () => {\n\t\t\t\tconst dataWithZeroCost = { ...mockUsageData, costUSD: 0 };\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithZeroCost, 'display', fetcher);\n\t\t\t\texpect(result).toBe(0);\n\t\t\t});\n\n\t\t\tit('should handle negative costUSD', async () => {\n\t\t\t\tconst dataWithNegativeCost = { ...mockUsageData, costUSD: -0.01 };\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(dataWithNegativeCost, 'display', fetcher);\n\t\t\t\texpect(result).toBe(-0.01);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('fast mode', () => {\n\t\t\tit('should apply fast multiplier in calculate mode', async () => {\n\t\t\t\tconst standardData: UsageData = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: createModelName('claude-opus-4-6'),\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t\tconst fastData: UsageData = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500, speed: 'fast' },\n\t\t\t\t\t\tmodel: createModelName('claude-opus-4-6'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst standardCost = await calculateCostForEntry(standardData, 'calculate', fetcher);\n\t\t\t\tconst fastCost = await calculateCostForEntry(fastData, 'calculate', fetcher);\n\n\t\t\t\texpect(standardCost).toBeGreaterThan(0);\n\t\t\t\texpect(fastCost).toBeGreaterThan(standardCost);\n\t\t\t\texpect(fastCost).toBeCloseTo(standardCost * 6, 5);\n\t\t\t});\n\n\t\t\tit('should apply fast multiplier in auto mode when costUSD is absent', async () => {\n\t\t\t\tconst fastData: UsageData = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500, speed: 'fast' },\n\t\t\t\t\t\tmodel: createModelName('claude-opus-4-6'),\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst fastCost = await calculateCostForEntry(fastData, 'auto', fetcher);\n\t\t\t\texpect(fastCost).toBeGreaterThan(0);\n\t\t\t});\n\n\t\t\tit('should not apply fast multiplier in display mode', async () => {\n\t\t\t\tconst fastData: UsageData = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2024-01-01T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500, speed: 'fast' },\n\t\t\t\t\t\tmodel: createModelName('claude-opus-4-6'),\n\t\t\t\t\t},\n\t\t\t\t\tcostUSD: 0.05,\n\t\t\t\t};\n\n\t\t\t\tusing fetcher = new PricingFetcher();\n\t\t\t\tconst result = await calculateCostForEntry(fastData, 'display', fetcher);\n\t\t\t\texpect(result).toBe(0.05);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('offline mode', () => {\n\t\t\tit('should pass offline flag through loadDailyUsageData', async () => {\n\t\t\t\tawait using fixture = await createFixture({ projects: {} });\n\t\t\t\t// This test verifies that the offline flag is properly passed through\n\t\t\t\t// We can't easily mock the internal behavior, but we can verify it doesn't throw\n\t\t\t\tconst result = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\toffline: true,\n\t\t\t\t\tmode: 'calculate',\n\t\t\t\t});\n\n\t\t\t\t// Should return empty array or valid data without throwing\n\t\t\t\texpect(Array.isArray(result)).toBe(true);\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('loadSessionBlockData', () => {\n\t\tit('returns empty array when no files found', async () => {\n\t\t\tawait using fixture = await createFixture({ projects: {} });\n\t\t\tconst result = await loadSessionBlockData({ claudePath: fixture.path });\n\t\t\texpect(result).toEqual([]);\n\t\t});\n\n\t\tit('loads and identifies five-hour blocks correctly', async () => {\n\t\t\tconst now = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst laterTime = new Date(now.getTime() + 1 * 60 * 60 * 1000); // 1 hour later\n\t\t\tconst muchLaterTime = new Date(now.getTime() + 6 * 60 * 60 * 1000); // 6 hours later\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'conversation1.jsonl': [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: now.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg1',\n\t\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req1',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: laterTime.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg2',\n\t\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\t\tinput_tokens: 2000,\n\t\t\t\t\t\t\t\t\t\t\toutput_tokens: 1000,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req2',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: muchLaterTime.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg3',\n\t\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\t\tinput_tokens: 1500,\n\t\t\t\t\t\t\t\t\t\t\toutput_tokens: 750,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req3',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.015,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t.map((data) => JSON.stringify(data))\n\t\t\t\t\t\t\t\t.join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionBlockData({ claudePath: fixture.path });\n\t\t\texpect(result.length).toBeGreaterThan(0); // Should have blocks\n\t\t\texpect(result[0]?.entries).toHaveLength(1); // First block has one entry\n\t\t\t// Total entries across all blocks should be 3\n\t\t\tconst totalEntries = result.reduce((sum, block) => sum + block.entries.length, 0);\n\t\t\texpect(totalEntries).toBe(3);\n\t\t});\n\n\t\tit('handles cost calculation modes correctly', async () => {\n\t\t\tconst now = new Date('2024-01-01T10:00:00Z');\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'conversation1.jsonl': JSON.stringify({\n\t\t\t\t\t\t\t\ttimestamp: now.toISOString(),\n\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\tid: 'msg1',\n\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\trequest: { id: 'req1' },\n\t\t\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Test display mode\n\t\t\tconst displayResult = await loadSessionBlockData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\tmode: 'display',\n\t\t\t});\n\t\t\texpect(displayResult).toHaveLength(1);\n\t\t\texpect(displayResult[0]?.costUSD).toBe(0.01);\n\n\t\t\t// Test calculate mode\n\t\t\tconst calculateResult = await loadSessionBlockData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\tmode: 'calculate',\n\t\t\t});\n\t\t\texpect(calculateResult).toHaveLength(1);\n\t\t\texpect(calculateResult[0]?.costUSD).toBeGreaterThan(0);\n\t\t});\n\n\t\tit('filters by date range correctly', async () => {\n\t\t\tconst date1 = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst date2 = new Date('2024-01-02T10:00:00Z');\n\t\t\tconst date3 = new Date('2024-01-03T10:00:00Z');\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'conversation1.jsonl': [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: date1.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg1',\n\t\t\t\t\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req1',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: date2.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg2',\n\t\t\t\t\t\t\t\t\t\tusage: { input_tokens: 2000, output_tokens: 1000 },\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req2',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: date3.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg3',\n\t\t\t\t\t\t\t\t\t\tusage: { input_tokens: 1500, output_tokens: 750 },\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req3',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.015,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t.map((data) => JSON.stringify(data))\n\t\t\t\t\t\t\t\t.join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Test filtering with since parameter\n\t\t\tconst sinceResult = await loadSessionBlockData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\tsince: '20240102',\n\t\t\t});\n\t\t\texpect(sinceResult.length).toBeGreaterThan(0);\n\t\t\texpect(sinceResult.every((block) => block.startTime >= date2)).toBe(true);\n\n\t\t\t// Test filtering with until parameter\n\t\t\tconst untilResult = await loadSessionBlockData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\tuntil: '20240102',\n\t\t\t});\n\t\t\texpect(untilResult.length).toBeGreaterThan(0);\n\t\t\t// The filter uses formatDate which converts to YYYYMMDD format for comparison\n\t\t\texpect(\n\t\t\t\tuntilResult.every((block) => {\n\t\t\t\t\tconst blockDateStr = block.startTime.toISOString().slice(0, 10).replace(/-/g, '');\n\t\t\t\t\treturn blockDateStr <= '20240102';\n\t\t\t\t}),\n\t\t\t).toBe(true);\n\t\t});\n\n\t\tit('sorts blocks by order parameter', async () => {\n\t\t\tconst date1 = new Date('2024-01-01T10:00:00Z');\n\t\t\tconst date2 = new Date('2024-01-02T10:00:00Z');\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'conversation1.jsonl': [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: date2.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg2',\n\t\t\t\t\t\t\t\t\t\tusage: { input_tokens: 2000, output_tokens: 1000 },\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req2',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: date1.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg1',\n\t\t\t\t\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req1',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t.map((data) => JSON.stringify(data))\n\t\t\t\t\t\t\t\t.join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Test ascending order\n\t\t\tconst ascResult = await loadSessionBlockData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'asc',\n\t\t\t});\n\t\t\texpect(ascResult[0]?.startTime).toEqual(date1);\n\n\t\t\t// Test descending order\n\t\t\tconst descResult = await loadSessionBlockData({\n\t\t\t\tclaudePath: fixture.path,\n\t\t\t\torder: 'desc',\n\t\t\t});\n\t\t\texpect(descResult[0]?.startTime).toEqual(date2);\n\t\t});\n\n\t\tit('handles deduplication correctly', async () => {\n\t\t\tconst now = new Date('2024-01-01T10:00:00Z');\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'conversation1.jsonl': [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: now.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg1',\n\t\t\t\t\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req1',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t// Duplicate entry - should be filtered out\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttimestamp: now.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg1',\n\t\t\t\t\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req1',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t.map((data) => JSON.stringify(data))\n\t\t\t\t\t\t\t\t.join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionBlockData({ claudePath: fixture.path });\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.entries).toHaveLength(1); // Only one entry after deduplication\n\t\t});\n\n\t\tit('handles invalid JSON lines gracefully', async () => {\n\t\t\tconst now = new Date('2024-01-01T10:00:00Z');\n\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'conversation1.jsonl': [\n\t\t\t\t\t\t\t\t'invalid json line',\n\t\t\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\t\t\ttimestamp: now.toISOString(),\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg1',\n\t\t\t\t\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\t\t\t\t\tmodel: createModelName('claude-sonnet-4-20250514'),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req1',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\t\t\t\tversion: createVersion('1.0.0'),\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t'another invalid line',\n\t\t\t\t\t\t\t].join('\\n'),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = await loadSessionBlockData({ claudePath: fixture.path });\n\t\t\texpect(result).toHaveLength(1);\n\t\t\texpect(result[0]?.entries).toHaveLength(1);\n\t\t});\n\n\t\tdescribe('processJSONLFileByLine', () => {\n\t\t\tit('should process each non-empty line with correct line numbers', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': '{\"line\": 1}\\n{\"line\": 2}\\n{\"line\": 3}\\n',\n\t\t\t\t});\n\n\t\t\t\tconst lines: Array<{ content: string; lineNumber: number }> = [];\n\t\t\t\tawait processJSONLFileByLine(path.join(fixture.path, 'test.jsonl'), (line, lineNumber) => {\n\t\t\t\t\tlines.push({ content: line, lineNumber });\n\t\t\t\t});\n\n\t\t\t\texpect(lines).toHaveLength(3);\n\t\t\t\texpect(lines[0]).toEqual({ content: '{\"line\": 1}', lineNumber: 1 });\n\t\t\t\texpect(lines[1]).toEqual({ content: '{\"line\": 2}', lineNumber: 2 });\n\t\t\t\texpect(lines[2]).toEqual({ content: '{\"line\": 3}', lineNumber: 3 });\n\t\t\t});\n\n\t\t\tit('should skip empty lines', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': '{\"line\": 1}\\n\\n{\"line\": 2}\\n  \\n{\"line\": 3}\\n',\n\t\t\t\t});\n\n\t\t\t\tconst lines: string[] = [];\n\t\t\t\tawait processJSONLFileByLine(path.join(fixture.path, 'test.jsonl'), (line) => {\n\t\t\t\t\tlines.push(line);\n\t\t\t\t});\n\n\t\t\t\texpect(lines).toHaveLength(3);\n\t\t\t\texpect(lines[0]).toBe('{\"line\": 1}');\n\t\t\t\texpect(lines[1]).toBe('{\"line\": 2}');\n\t\t\t\texpect(lines[2]).toBe('{\"line\": 3}');\n\t\t\t});\n\n\t\t\tit('should handle async processLine callback', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': '{\"line\": 1}\\n{\"line\": 2}\\n',\n\t\t\t\t});\n\n\t\t\t\tconst results: string[] = [];\n\t\t\t\tawait processJSONLFileByLine(path.join(fixture.path, 'test.jsonl'), async (line) => {\n\t\t\t\t\t// Simulate async operation\n\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 1));\n\t\t\t\t\tresults.push(line);\n\t\t\t\t});\n\n\t\t\t\texpect(results).toHaveLength(2);\n\t\t\t\texpect(results[0]).toBe('{\"line\": 1}');\n\t\t\t\texpect(results[1]).toBe('{\"line\": 2}');\n\t\t\t});\n\n\t\t\tit('should throw error when file does not exist', async () => {\n\t\t\t\tawait expect(processJSONLFileByLine('/nonexistent/file.jsonl', () => {})).rejects.toThrow();\n\t\t\t});\n\n\t\t\tit('should handle empty file', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'empty.jsonl': '',\n\t\t\t\t});\n\n\t\t\t\tconst lines: string[] = [];\n\t\t\t\tawait processJSONLFileByLine(path.join(fixture.path, 'empty.jsonl'), (line) => {\n\t\t\t\t\tlines.push(line);\n\t\t\t\t});\n\n\t\t\t\texpect(lines).toHaveLength(0);\n\t\t\t});\n\n\t\t\tit('should handle file with only empty lines', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'only-empty.jsonl': '\\n\\n  \\n\\t\\n',\n\t\t\t\t});\n\n\t\t\t\tconst lines: string[] = [];\n\t\t\t\tawait processJSONLFileByLine(path.join(fixture.path, 'only-empty.jsonl'), (line) => {\n\t\t\t\t\tlines.push(line);\n\t\t\t\t});\n\n\t\t\t\texpect(lines).toHaveLength(0);\n\t\t\t});\n\n\t\t\tit('should process large files (600MB+) without RangeError', async () => {\n\t\t\t\t// Create a realistic JSONL entry similar to actual Claude data (~283 bytes per line)\n\t\t\t\tconst sampleEntry = `${JSON.stringify({\n\t\t\t\t\ttimestamp: '2025-01-10T10:00:00Z',\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tid: 'msg_01234567890123456789',\n\t\t\t\t\t\tusage: { input_tokens: 1000, output_tokens: 500 },\n\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t},\n\t\t\t\t\trequestId: 'req_01234567890123456789',\n\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t})}\\n`;\n\n\t\t\t\t// Target 600MB file (this would cause RangeError with readFile in Node.js)\n\t\t\t\tconst targetMB = 600;\n\t\t\t\tconst lineSize = Buffer.byteLength(sampleEntry, 'utf-8');\n\t\t\t\tconst lineCount = Math.ceil((targetMB * 1024 * 1024) / lineSize);\n\n\t\t\t\t// Create fixture directory first\n\t\t\t\tawait using fixture = await createFixture({});\n\t\t\t\tconst filePath = path.join(fixture.path, 'large.jsonl');\n\n\t\t\t\t// Write file using streaming to avoid Node.js string length limit (~512MB)\n\t\t\t\t// Creating a 600MB string directly would cause \"RangeError: Invalid string length\"\n\t\t\t\tconst writeStream = createWriteStream(filePath);\n\n\t\t\t\t// Write lines and handle backpressure\n\t\t\t\tfor (let i = 0; i < lineCount; i++) {\n\t\t\t\t\tconst canContinue = writeStream.write(sampleEntry);\n\t\t\t\t\t// Respect backpressure by waiting for drain event\n\t\t\t\t\tif (!canContinue) {\n\t\t\t\t\t\tawait new Promise<void>((resolve) => writeStream.once('drain', () => resolve()));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Ensure all data is flushed\n\t\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\t\twriteStream.end((err?: Error | null) => (err != null ? reject(err) : resolve()));\n\t\t\t\t});\n\n\t\t\t\t// Test streaming processing\n\t\t\t\tlet processedCount = 0;\n\t\t\t\tawait processJSONLFileByLine(filePath, () => {\n\t\t\t\t\tprocessedCount++;\n\t\t\t\t});\n\n\t\t\t\texpect(processedCount).toBe(lineCount);\n\t\t\t});\n\t\t});\n\t});\n}\n\n// duplication functionality tests\nif (import.meta.vitest != null) {\n\tdescribe('deduplication functionality', () => {\n\t\tdescribe('createUniqueHash', () => {\n\t\t\tit('should create hash from message id and request id', () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2025-01-10T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tid: createMessageId('msg_123'),\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\trequestId: createRequestId('req_456'),\n\t\t\t\t};\n\n\t\t\t\tconst hash = createUniqueHash(data);\n\t\t\t\texpect(hash).toBe('msg_123:req_456');\n\t\t\t});\n\n\t\t\tit('should return null when message id is missing', () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2025-01-10T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\trequestId: createRequestId('req_456'),\n\t\t\t\t};\n\n\t\t\t\tconst hash = createUniqueHash(data);\n\t\t\t\texpect(hash).toBeNull();\n\t\t\t});\n\n\t\t\tit('should return null when request id is missing', () => {\n\t\t\t\tconst data = {\n\t\t\t\t\ttimestamp: createISOTimestamp('2025-01-10T10:00:00Z'),\n\t\t\t\t\tmessage: {\n\t\t\t\t\t\tid: createMessageId('msg_123'),\n\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t};\n\n\t\t\t\tconst hash = createUniqueHash(data);\n\t\t\t\texpect(hash).toBeNull();\n\t\t\t});\n\t\t});\n\n\t\tdescribe('getEarliestTimestamp', () => {\n\t\t\tit('should extract earliest timestamp from JSONL file', async () => {\n\t\t\t\tconst content = [\n\t\t\t\t\tJSON.stringify({ timestamp: '2025-01-15T12:00:00Z', message: { usage: {} } }),\n\t\t\t\t\tJSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { usage: {} } }),\n\t\t\t\t\tJSON.stringify({ timestamp: '2025-01-12T11:00:00Z', message: { usage: {} } }),\n\t\t\t\t].join('\\n');\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': content,\n\t\t\t\t});\n\n\t\t\t\tconst timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl'));\n\t\t\t\texpect(timestamp).toEqual(new Date('2025-01-10T10:00:00Z'));\n\t\t\t});\n\n\t\t\tit('should handle files without timestamps', async () => {\n\t\t\t\tconst content = [\n\t\t\t\t\tJSON.stringify({ message: { usage: {} } }),\n\t\t\t\t\tJSON.stringify({ data: 'no timestamp' }),\n\t\t\t\t].join('\\n');\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': content,\n\t\t\t\t});\n\n\t\t\t\tconst timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl'));\n\t\t\t\texpect(timestamp).toBeNull();\n\t\t\t});\n\n\t\t\tit('should skip invalid JSON lines', async () => {\n\t\t\t\tconst content = [\n\t\t\t\t\t'invalid json',\n\t\t\t\t\tJSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { usage: {} } }),\n\t\t\t\t\t'{ broken: json',\n\t\t\t\t].join('\\n');\n\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': content,\n\t\t\t\t});\n\n\t\t\t\tconst timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl'));\n\t\t\t\texpect(timestamp).toEqual(new Date('2025-01-10T10:00:00Z'));\n\t\t\t});\n\t\t});\n\n\t\tdescribe('sortFilesByTimestamp', () => {\n\t\t\tit('should sort files by earliest timestamp', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'file1.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z' }),\n\t\t\t\t\t'file2.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z' }),\n\t\t\t\t\t'file3.jsonl': JSON.stringify({ timestamp: '2025-01-12T10:00:00Z' }),\n\t\t\t\t});\n\n\t\t\t\tconst file1 = fixture.getPath('file1.jsonl');\n\t\t\t\tconst file2 = fixture.getPath('file2.jsonl');\n\t\t\t\tconst file3 = fixture.getPath('file3.jsonl');\n\n\t\t\t\tconst sorted = await sortFilesByTimestamp([file1, file2, file3]);\n\n\t\t\t\texpect(sorted).toEqual([file2, file3, file1]); // Chronological order\n\t\t\t});\n\n\t\t\tit('should place files without timestamps at the end', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'file1.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z' }),\n\t\t\t\t\t'file2.jsonl': JSON.stringify({ no_timestamp: true }),\n\t\t\t\t\t'file3.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z' }),\n\t\t\t\t});\n\n\t\t\t\tconst file1 = fixture.getPath('file1.jsonl');\n\t\t\t\tconst file2 = fixture.getPath('file2.jsonl');\n\t\t\t\tconst file3 = fixture.getPath('file3.jsonl');\n\n\t\t\t\tconst sorted = await sortFilesByTimestamp([file1, file2, file3]);\n\n\t\t\t\texpect(sorted).toEqual([file3, file1, file2]); // file2 without timestamp goes to end\n\t\t\t});\n\t\t});\n\n\t\tdescribe('loadDailyUsageData with deduplication', () => {\n\t\t\tit('should deduplicate entries with same message and request IDs', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\tproject1: {\n\t\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t\t'file1.jsonl': JSON.stringify({\n\t\t\t\t\t\t\t\t\ttimestamp: '2025-01-10T10:00:00Z',\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg_123',\n\t\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req_456',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tsession2: {\n\t\t\t\t\t\t\t\t'file2.jsonl': JSON.stringify({\n\t\t\t\t\t\t\t\t\ttimestamp: '2025-01-15T10:00:00Z',\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg_123',\n\t\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req_456',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst data = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'display',\n\t\t\t\t});\n\n\t\t\t\t// Should only have one entry for 2025-01-10\n\t\t\t\texpect(data).toHaveLength(1);\n\t\t\t\texpect(data[0]?.date).toBe('2025-01-10');\n\t\t\t\texpect(data[0]?.inputTokens).toBe(100);\n\t\t\t\texpect(data[0]?.outputTokens).toBe(50);\n\t\t\t});\n\n\t\t\tit('should process files in chronological order', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\t'newer.jsonl': JSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2025-01-15T10:00:00Z',\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tid: 'msg_123',\n\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\tinput_tokens: 200,\n\t\t\t\t\t\t\t\t\toutput_tokens: 100,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\trequestId: 'req_456',\n\t\t\t\t\t\t\tcostUSD: 0.002,\n\t\t\t\t\t\t}),\n\t\t\t\t\t\t'older.jsonl': JSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2025-01-10T10:00:00Z',\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tid: 'msg_123',\n\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\trequestId: 'req_456',\n\t\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\t}),\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst data = await loadDailyUsageData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'display',\n\t\t\t\t});\n\n\t\t\t\t// Should keep the older entry (100/50 tokens) not the newer one (200/100)\n\t\t\t\texpect(data).toHaveLength(1);\n\t\t\t\texpect(data[0]?.date).toBe('2025-01-10');\n\t\t\t\texpect(data[0]?.inputTokens).toBe(100);\n\t\t\t\texpect(data[0]?.outputTokens).toBe(50);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('loadSessionData with deduplication', () => {\n\t\t\tit('should deduplicate entries across sessions', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\tprojects: {\n\t\t\t\t\t\tproject1: {\n\t\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t\t'file1.jsonl': JSON.stringify({\n\t\t\t\t\t\t\t\t\ttimestamp: '2025-01-10T10:00:00Z',\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg_123',\n\t\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req_456',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tsession2: {\n\t\t\t\t\t\t\t\t'file2.jsonl': JSON.stringify({\n\t\t\t\t\t\t\t\t\ttimestamp: '2025-01-15T10:00:00Z',\n\t\t\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t\t\tid: 'msg_123',\n\t\t\t\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\trequestId: 'req_456',\n\t\t\t\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tconst sessions = await loadSessionData({\n\t\t\t\t\tclaudePath: fixture.path,\n\t\t\t\t\tmode: 'display',\n\t\t\t\t});\n\n\t\t\t\t// Session 1 should have the entry\n\t\t\t\tconst session1 = sessions.find((s) => s.sessionId === 'session1');\n\t\t\t\texpect(session1).toBeDefined();\n\t\t\t\texpect(session1?.inputTokens).toBe(100);\n\t\t\t\texpect(session1?.outputTokens).toBe(50);\n\n\t\t\t\t// Session 2 should either not exist or have 0 tokens (duplicate was skipped)\n\t\t\t\tconst session2 = sessions.find((s) => s.sessionId === 'session2');\n\t\t\t\tif (session2 != null) {\n\t\t\t\t\texpect(session2.inputTokens).toBe(0);\n\t\t\t\t\texpect(session2.outputTokens).toBe(0);\n\t\t\t\t} else {\n\t\t\t\t\t// It's also valid for session2 to not be included if it has no entries\n\t\t\t\t\texpect(sessions.length).toBe(1);\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('getClaudePaths', () => {\n\t\tafterEach(() => {\n\t\t\tvi.unstubAllEnvs();\n\t\t\tvi.unstubAllGlobals();\n\t\t});\n\n\t\tit('returns paths from environment variable when set', async () => {\n\t\t\tawait using fixture1 = await createFixture({\n\t\t\t\tprojects: {},\n\t\t\t});\n\t\t\tawait using fixture2 = await createFixture({\n\t\t\t\tprojects: {},\n\t\t\t});\n\n\t\t\tvi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture1.path},${fixture2.path}`);\n\n\t\t\tconst paths = getClaudePaths();\n\t\t\tconst normalizedFixture1 = path.resolve(fixture1.path);\n\t\t\tconst normalizedFixture2 = path.resolve(fixture2.path);\n\n\t\t\texpect(paths).toEqual(expect.arrayContaining([normalizedFixture1, normalizedFixture2]));\n\t\t\t// Environment paths should be prioritized\n\t\t\texpect(paths[0]).toBe(normalizedFixture1);\n\t\t\texpect(paths[1]).toBe(normalizedFixture2);\n\t\t});\n\n\t\tit('filters out non-existent paths from environment variable', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {},\n\t\t\t});\n\n\t\t\tvi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture.path},/nonexistent/path`);\n\n\t\t\tconst paths = getClaudePaths();\n\t\t\tconst normalizedFixture = path.resolve(fixture.path);\n\t\t\texpect(paths).toEqual(expect.arrayContaining([normalizedFixture]));\n\t\t\texpect(paths[0]).toBe(normalizedFixture);\n\t\t});\n\n\t\tit('removes duplicates from combined paths', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tprojects: {},\n\t\t\t});\n\n\t\t\tvi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture.path},${fixture.path}`);\n\n\t\t\tconst paths = getClaudePaths();\n\t\t\tconst normalizedFixture = path.resolve(fixture.path);\n\t\t\t// Should only contain the fixture path once (but may include defaults)\n\t\t\tconst fixtureCount = paths.filter((p) => p === normalizedFixture).length;\n\t\t\texpect(fixtureCount).toBe(1);\n\t\t});\n\n\t\tit('returns non-empty array with existing default paths', () => {\n\t\t\t// This test will use real filesystem checks for default paths\n\t\t\tvi.stubEnv('CLAUDE_CONFIG_DIR', '');\n\t\t\tconst paths = getClaudePaths();\n\n\t\t\texpect(Array.isArray(paths)).toBe(true);\n\t\t\t// At least one path should exist in our test environment (CI creates both)\n\t\t\texpect(paths.length).toBeGreaterThanOrEqual(1);\n\t\t});\n\t});\n\n\tdescribe('multiple paths integration', () => {\n\t\tit('loadDailyUsageData aggregates data from multiple paths', async () => {\n\t\t\tawait using fixture1 = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject1: {\n\t\t\t\t\t\tsession1: {\n\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify({\n\t\t\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\t\t\tmessage: { usage: { input_tokens: 100, output_tokens: 50 } },\n\t\t\t\t\t\t\t\tcostUSD: 0.01,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tawait using fixture2 = await createFixture({\n\t\t\t\tprojects: {\n\t\t\t\t\tproject2: {\n\t\t\t\t\t\tsession2: {\n\t\t\t\t\t\t\t'usage.jsonl': JSON.stringify({\n\t\t\t\t\t\t\t\ttimestamp: '2024-01-01T13:00:00Z',\n\t\t\t\t\t\t\t\tmessage: { usage: { input_tokens: 200, output_tokens: 100 } },\n\t\t\t\t\t\t\t\tcostUSD: 0.02,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tvi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture1.path},${fixture2.path}`);\n\n\t\t\tconst result = await loadDailyUsageData();\n\t\t\t// Find the specific date we're testing\n\t\t\tconst targetDate = result.find((day) => day.date === '2024-01-01');\n\t\t\texpect(targetDate).toBeDefined();\n\t\t\texpect(targetDate?.inputTokens).toBe(300);\n\t\t\texpect(targetDate?.outputTokens).toBe(150);\n\t\t\texpect(targetDate?.totalCost).toBe(0.03);\n\t\t}, 30000);\n\t});\n\n\tdescribe('globUsageFiles', () => {\n\t\tit('should glob files from multiple paths in parallel with base directories', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'path1/projects/project1/session1/usage.jsonl': 'data1',\n\t\t\t\t'path2/projects/project2/session2/usage.jsonl': 'data2',\n\t\t\t\t'path3/projects/project3/session3/usage.jsonl': 'data3',\n\t\t\t});\n\n\t\t\tconst paths = [fixture.getPath('path1'), fixture.getPath('path2'), fixture.getPath('path3')];\n\n\t\t\tconst results = await globUsageFiles(paths);\n\n\t\t\texpect(results).toHaveLength(3);\n\t\t\texpect(results.some((r) => r.file.includes('project1'))).toBe(true);\n\t\t\texpect(results.some((r) => r.file.includes('project2'))).toBe(true);\n\t\t\texpect(results.some((r) => r.file.includes('project3'))).toBe(true);\n\n\t\t\t// Check base directories are included\n\t\t\tconst result1 = results.find((r) => r.file.includes('project1'));\n\t\t\texpect(result1?.baseDir).toContain(path.join('path1', 'projects'));\n\t\t});\n\n\t\tit('should handle errors gracefully and return empty array for failed paths', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'valid/projects/project1/session1/usage.jsonl': 'data1',\n\t\t\t});\n\n\t\t\tconst paths = [\n\t\t\t\tfixture.getPath('valid'),\n\t\t\t\tfixture.getPath('nonexistent'), // This path doesn't exist\n\t\t\t];\n\n\t\t\tconst results = await globUsageFiles(paths);\n\n\t\t\texpect(results).toHaveLength(1);\n\t\t\texpect(results.at(0)?.file).toContain('project1');\n\t\t});\n\n\t\tit('should return empty array when no files found', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'empty/projects': {}, // Empty directory\n\t\t\t});\n\n\t\t\tconst paths = [fixture.getPath('empty')];\n\t\t\tconst results = await globUsageFiles(paths);\n\n\t\t\texpect(results).toEqual([]);\n\t\t});\n\n\t\tit('should handle multiple files from same base directory', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'path1/projects/project1/session1/usage.jsonl': 'data1',\n\t\t\t\t'path1/projects/project1/session2/usage.jsonl': 'data2',\n\t\t\t\t'path1/projects/project2/session1/usage.jsonl': 'data3',\n\t\t\t});\n\n\t\t\tconst paths = [fixture.getPath('path1')];\n\t\t\tconst results = await globUsageFiles(paths);\n\n\t\t\texpect(results).toHaveLength(3);\n\t\t\texpect(results.every((r) => r.baseDir.includes(path.join('path1', 'projects')))).toBe(true);\n\t\t});\n\t});\n\n\t// Test for calculateContextTokens\n\tdescribe('calculateContextTokens', async () => {\n\t\tit('returns null when transcript cannot be read', async () => {\n\t\t\tconst result = await calculateContextTokens('/nonexistent/path.jsonl');\n\t\t\texpect(result).toBeNull();\n\t\t});\n\t\tconst { createFixture } = await import('fs-fixture');\n\t\tit('parses latest assistant line and excludes output tokens', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'transcript.jsonl': [\n\t\t\t\t\tJSON.stringify({ type: 'user', message: {} }),\n\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\ttype: 'assistant',\n\t\t\t\t\t\tmessage: { usage: { input_tokens: 1000, output_tokens: 999 } },\n\t\t\t\t\t}),\n\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\ttype: 'assistant',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\tinput_tokens: 2000,\n\t\t\t\t\t\t\t\tcache_creation_input_tokens: 100,\n\t\t\t\t\t\t\t\tcache_read_input_tokens: 50,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t].join('\\n'),\n\t\t\t});\n\t\t\tconst res = await calculateContextTokens(fixture.getPath('transcript.jsonl'));\n\t\t\texpect(res).not.toBeNull();\n\t\t\t// Should pick the last assistant line and exclude output tokens\n\t\t\texpect(res?.inputTokens).toBe(2000 + 100 + 50);\n\t\t\texpect(res?.percentage).toBeGreaterThan(0);\n\t\t});\n\n\t\tit('handles missing cache fields gracefully', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'transcript.jsonl': [\n\t\t\t\t\tJSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 1000 } } }),\n\t\t\t\t].join('\\n'),\n\t\t\t});\n\t\t\tconst res = await calculateContextTokens(fixture.getPath('transcript.jsonl'));\n\t\t\texpect(res).not.toBeNull();\n\t\t\texpect(res?.inputTokens).toBe(1000);\n\t\t\texpect(res?.percentage).toBeGreaterThan(0);\n\t\t});\n\n\t\tit('clamps percentage to 0-100 range', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\t'transcript.jsonl': [\n\t\t\t\t\tJSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 300_000 } } }),\n\t\t\t\t].join('\\n'),\n\t\t\t});\n\t\t\tconst res = await calculateContextTokens(fixture.getPath('transcript.jsonl'));\n\t\t\texpect(res).not.toBeNull();\n\t\t\texpect(res?.percentage).toBe(100); // Should be clamped to 100\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/debug.ts",
    "content": "/**\n * @fileoverview Debug utilities for cost calculation validation\n *\n * This module provides debugging tools for detecting mismatches between\n * pre-calculated costs and calculated costs based on token usage and model pricing.\n *\n * @module debug\n */\n\nimport { readFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport { Result } from '@praha/byethrow';\nimport { createFixture } from 'fs-fixture';\nimport { glob } from 'tinyglobby';\nimport * as v from 'valibot';\nimport {\n\tCLAUDE_PROJECTS_DIR_NAME,\n\tDEBUG_MATCH_THRESHOLD_PERCENT,\n\tUSAGE_DATA_GLOB_PATTERN,\n} from './_consts.ts';\nimport { PricingFetcher } from './_pricing-fetcher.ts';\nimport { getClaudePaths, usageDataSchema } from './data-loader.ts';\nimport { logger } from './logger.ts';\n\n/**\n * Represents a pricing discrepancy between original and calculated costs\n */\ntype Discrepancy = {\n\tfile: string;\n\ttimestamp: string;\n\tmodel: string;\n\toriginalCost: number;\n\tcalculatedCost: number;\n\tdifference: number;\n\tpercentDiff: number;\n\tusage: {\n\t\tinput_tokens: number;\n\t\toutput_tokens: number;\n\t\tcache_creation_input_tokens?: number;\n\t\tcache_read_input_tokens?: number;\n\t};\n};\n\n/**\n * Statistics about pricing mismatches across all usage data\n */\ntype MismatchStats = {\n\ttotalEntries: number;\n\tentriesWithBoth: number;\n\tmatches: number;\n\tmismatches: number;\n\tdiscrepancies: Discrepancy[];\n\tmodelStats: Map<\n\t\tstring,\n\t\t{\n\t\t\ttotal: number;\n\t\t\tmatches: number;\n\t\t\tmismatches: number;\n\t\t\tavgPercentDiff: number;\n\t\t}\n\t>;\n\tversionStats: Map<\n\t\tstring,\n\t\t{\n\t\t\ttotal: number;\n\t\t\tmatches: number;\n\t\t\tmismatches: number;\n\t\t\tavgPercentDiff: number;\n\t\t}\n\t>;\n};\n\n/**\n * Analyzes usage data to detect pricing mismatches between stored and calculated costs\n * Compares pre-calculated costUSD values with costs calculated from token usage\n * @param claudePath - Optional path to Claude data directory\n * @returns Statistics about pricing mismatches found\n */\nexport async function detectMismatches(claudePath?: string): Promise<MismatchStats> {\n\tlet claudeDir: string;\n\tif (claudePath != null && claudePath !== '') {\n\t\tclaudeDir = claudePath;\n\t} else {\n\t\tconst paths = getClaudePaths();\n\t\tif (paths.length === 0) {\n\t\t\tthrow new Error('No valid Claude data directory found');\n\t\t}\n\t\tclaudeDir = path.join(paths[0]!, CLAUDE_PROJECTS_DIR_NAME);\n\t}\n\tconst files = await glob([USAGE_DATA_GLOB_PATTERN], {\n\t\tcwd: claudeDir,\n\t\tabsolute: true,\n\t});\n\n\t// Use PricingFetcher with using statement for automatic cleanup\n\tusing fetcher = new PricingFetcher();\n\n\tconst stats: MismatchStats = {\n\t\ttotalEntries: 0,\n\t\tentriesWithBoth: 0,\n\t\tmatches: 0,\n\t\tmismatches: 0,\n\t\tdiscrepancies: [],\n\t\tmodelStats: new Map(),\n\t\tversionStats: new Map(),\n\t};\n\n\tfor (const file of files) {\n\t\tconst content = await readFile(file, 'utf-8');\n\t\tconst lines = content\n\t\t\t.trim()\n\t\t\t.split('\\n')\n\t\t\t.filter((line) => line.length > 0);\n\n\t\tfor (const line of lines) {\n\t\t\tconst parseParser = Result.try({\n\t\t\t\ttry: () => JSON.parse(line) as unknown,\n\t\t\t\tcatch: () => new Error('Invalid JSON'),\n\t\t\t});\n\n\t\t\tconst parseResult = parseParser();\n\t\t\tif (Result.isFailure(parseResult)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst schemaResult = v.safeParse(usageDataSchema, parseResult.value);\n\n\t\t\tif (!schemaResult.success) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst data = schemaResult.output;\n\t\t\tstats.totalEntries++;\n\n\t\t\t// Check if we have both costUSD and model\n\t\t\tif (\n\t\t\t\tdata.costUSD !== undefined &&\n\t\t\t\tdata.message.model != null &&\n\t\t\t\tdata.message.model !== '<synthetic>'\n\t\t\t) {\n\t\t\t\tstats.entriesWithBoth++;\n\n\t\t\t\tconst model = data.message.model;\n\t\t\t\tconst calculatedCost = await Result.unwrap(\n\t\t\t\t\tfetcher.calculateCostFromTokens(data.message.usage, model),\n\t\t\t\t);\n\n\t\t\t\t// Only compare if we could calculate a cost\n\t\t\t\tconst difference = Math.abs(data.costUSD - calculatedCost);\n\t\t\t\tconst percentDiff = data.costUSD > 0 ? (difference / data.costUSD) * 100 : 0;\n\n\t\t\t\t// Update model statistics\n\t\t\t\tconst modelStat = stats.modelStats.get(model) ?? {\n\t\t\t\t\ttotal: 0,\n\t\t\t\t\tmatches: 0,\n\t\t\t\t\tmismatches: 0,\n\t\t\t\t\tavgPercentDiff: 0,\n\t\t\t\t};\n\t\t\t\tmodelStat.total++;\n\n\t\t\t\t// Update version statistics if version is available\n\t\t\t\tif (data.version != null) {\n\t\t\t\t\tconst versionStat = stats.versionStats.get(data.version) ?? {\n\t\t\t\t\t\ttotal: 0,\n\t\t\t\t\t\tmatches: 0,\n\t\t\t\t\t\tmismatches: 0,\n\t\t\t\t\t\tavgPercentDiff: 0,\n\t\t\t\t\t};\n\t\t\t\t\tversionStat.total++;\n\n\t\t\t\t\t// Consider it a match if within the defined threshold (to account for floating point)\n\t\t\t\t\tif (percentDiff < DEBUG_MATCH_THRESHOLD_PERCENT) {\n\t\t\t\t\t\tversionStat.matches++;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tversionStat.mismatches++;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Update average percent difference for version\n\t\t\t\t\tversionStat.avgPercentDiff =\n\t\t\t\t\t\t(versionStat.avgPercentDiff * (versionStat.total - 1) + percentDiff) /\n\t\t\t\t\t\tversionStat.total;\n\t\t\t\t\tstats.versionStats.set(data.version, versionStat);\n\t\t\t\t}\n\n\t\t\t\t// Consider it a match if within 0.1% difference (to account for floating point)\n\t\t\t\tif (percentDiff < 0.1) {\n\t\t\t\t\tstats.matches++;\n\t\t\t\t\tmodelStat.matches++;\n\t\t\t\t} else {\n\t\t\t\t\tstats.mismatches++;\n\t\t\t\t\tmodelStat.mismatches++;\n\t\t\t\t\tstats.discrepancies.push({\n\t\t\t\t\t\tfile: path.basename(file),\n\t\t\t\t\t\ttimestamp: data.timestamp,\n\t\t\t\t\t\tmodel,\n\t\t\t\t\t\toriginalCost: data.costUSD,\n\t\t\t\t\t\tcalculatedCost,\n\t\t\t\t\t\tdifference,\n\t\t\t\t\t\tpercentDiff,\n\t\t\t\t\t\tusage: data.message.usage,\n\t\t\t\t\t});\n\t\t\t\t}\n\n\t\t\t\t// Update average percent difference\n\t\t\t\tmodelStat.avgPercentDiff =\n\t\t\t\t\t(modelStat.avgPercentDiff * (modelStat.total - 1) + percentDiff) / modelStat.total;\n\t\t\t\tstats.modelStats.set(model, modelStat);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn stats;\n}\n\n/**\n * Prints a detailed report of pricing mismatches to the console\n * @param stats - Mismatch statistics to report\n * @param sampleCount - Number of sample discrepancies to show (default: 5)\n */\nexport function printMismatchReport(stats: MismatchStats, sampleCount = 5): void {\n\tif (stats.entriesWithBoth === 0) {\n\t\tlogger.info('No pricing data found to analyze.');\n\t\treturn;\n\t}\n\n\tconst matchRate = (stats.matches / stats.entriesWithBoth) * 100;\n\n\tlogger.info('\\n=== Pricing Mismatch Debug Report ===');\n\tlogger.info(`Total entries processed: ${stats.totalEntries.toLocaleString()}`);\n\tlogger.info(`Entries with both costUSD and model: ${stats.entriesWithBoth.toLocaleString()}`);\n\tlogger.info(`Matches (within 0.1%): ${stats.matches.toLocaleString()}`);\n\tlogger.info(`Mismatches: ${stats.mismatches.toLocaleString()}`);\n\tlogger.info(`Match rate: ${matchRate.toFixed(2)}%`);\n\n\t// Show model-by-model breakdown if there are mismatches\n\tif (stats.mismatches > 0 && stats.modelStats.size > 0) {\n\t\tlogger.info('\\n=== Model Statistics ===');\n\t\tconst sortedModels = Array.from(stats.modelStats.entries()).sort(\n\t\t\t(a, b) => b[1].mismatches - a[1].mismatches,\n\t\t);\n\n\t\tfor (const [model, modelStat] of sortedModels) {\n\t\t\tif (modelStat.mismatches > 0) {\n\t\t\t\tconst modelMatchRate = (modelStat.matches / modelStat.total) * 100;\n\t\t\t\tlogger.info(`${model}:`);\n\t\t\t\tlogger.info(`  Total entries: ${modelStat.total.toLocaleString()}`);\n\t\t\t\tlogger.info(\n\t\t\t\t\t`  Matches: ${modelStat.matches.toLocaleString()} (${modelMatchRate.toFixed(1)}%)`,\n\t\t\t\t);\n\t\t\t\tlogger.info(`  Mismatches: ${modelStat.mismatches.toLocaleString()}`);\n\t\t\t\tlogger.info(`  Avg % difference: ${modelStat.avgPercentDiff.toFixed(1)}%`);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Show version statistics if there are mismatches\n\tif (stats.mismatches > 0 && stats.versionStats.size > 0) {\n\t\tlogger.info('\\n=== Version Statistics ===');\n\t\tconst sortedVersions = Array.from(stats.versionStats.entries())\n\t\t\t.filter(([_, versionStat]) => versionStat.mismatches > 0)\n\t\t\t.sort((a, b) => b[1].mismatches - a[1].mismatches);\n\n\t\tfor (const [version, versionStat] of sortedVersions) {\n\t\t\tconst versionMatchRate = (versionStat.matches / versionStat.total) * 100;\n\t\t\tlogger.info(`${version}:`);\n\t\t\tlogger.info(`  Total entries: ${versionStat.total.toLocaleString()}`);\n\t\t\tlogger.info(\n\t\t\t\t`  Matches: ${versionStat.matches.toLocaleString()} (${versionMatchRate.toFixed(1)}%)`,\n\t\t\t);\n\t\t\tlogger.info(`  Mismatches: ${versionStat.mismatches.toLocaleString()}`);\n\t\t\tlogger.info(`  Avg % difference: ${versionStat.avgPercentDiff.toFixed(1)}%`);\n\t\t}\n\t}\n\n\t// Show sample discrepancies\n\tif (stats.discrepancies.length > 0 && sampleCount > 0) {\n\t\tlogger.info(`\\n=== Sample Discrepancies (first ${sampleCount}) ===`);\n\t\tconst samples = stats.discrepancies.slice(0, sampleCount);\n\n\t\tfor (const disc of samples) {\n\t\t\tlogger.info(`File: ${disc.file}`);\n\t\t\tlogger.info(`Timestamp: ${disc.timestamp}`);\n\t\t\tlogger.info(`Model: ${disc.model}`);\n\t\t\tlogger.info(`Original cost: $${disc.originalCost.toFixed(6)}`);\n\t\t\tlogger.info(`Calculated cost: $${disc.calculatedCost.toFixed(6)}`);\n\t\t\tlogger.info(`Difference: $${disc.difference.toFixed(6)} (${disc.percentDiff.toFixed(2)}%)`);\n\t\t\tlogger.info(`Tokens: ${JSON.stringify(disc.usage)}`);\n\t\t\tlogger.info('---');\n\t\t}\n\t}\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('debug.ts', () => {\n\t\tdescribe('detectMismatches', () => {\n\t\t\tit('should detect no mismatches when costs match', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.00015, // 50 * 0.000003 = 0.00015 (matches calculated)\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\tinput_tokens: 50,\n\t\t\t\t\t\t\t\toutput_tokens: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst stats = await detectMismatches(fixture.path);\n\n\t\t\t\texpect(stats.totalEntries).toBe(1);\n\t\t\t\texpect(stats.entriesWithBoth).toBe(1);\n\t\t\t\texpect(stats.matches).toBe(1);\n\t\t\t\texpect(stats.mismatches).toBe(0);\n\t\t\t\texpect(stats.discrepancies).toHaveLength(0);\n\t\t\t});\n\n\t\t\tit('should detect mismatches when costs differ significantly', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.1, // Significantly different from calculated cost\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\tinput_tokens: 50,\n\t\t\t\t\t\t\t\toutput_tokens: 10,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst stats = await detectMismatches(fixture.path);\n\n\t\t\t\texpect(stats.totalEntries).toBe(1);\n\t\t\t\texpect(stats.entriesWithBoth).toBe(1);\n\t\t\t\texpect(stats.matches).toBe(0);\n\t\t\t\texpect(stats.mismatches).toBe(1);\n\t\t\t\texpect(stats.discrepancies).toHaveLength(1);\n\n\t\t\t\tconst discrepancy = stats.discrepancies[0];\n\t\t\t\texpect(discrepancy).toBeDefined();\n\t\t\t\texpect(discrepancy?.file).toBe('test.jsonl');\n\t\t\t\texpect(discrepancy?.model).toBe('claude-sonnet-4-20250514');\n\t\t\t\texpect(discrepancy?.originalCost).toBe(0.1);\n\t\t\t\texpect(discrepancy?.percentDiff).toBeGreaterThan(0.1);\n\t\t\t});\n\n\t\t\tit('should handle entries without costUSD or model', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': [\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\t\t// No costUSD\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\t// No model\n\t\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t].join('\\n'),\n\t\t\t\t});\n\n\t\t\t\tconst stats = await detectMismatches(fixture.path);\n\n\t\t\t\texpect(stats.totalEntries).toBe(2);\n\t\t\t\texpect(stats.entriesWithBoth).toBe(0);\n\t\t\t\texpect(stats.matches).toBe(0);\n\t\t\t\texpect(stats.mismatches).toBe(0);\n\t\t\t});\n\n\t\t\tit('should skip synthetic models', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: '<synthetic>',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst stats = await detectMismatches(fixture.path);\n\n\t\t\t\texpect(stats.totalEntries).toBe(1);\n\t\t\t\texpect(stats.entriesWithBoth).toBe(0);\n\t\t\t});\n\n\t\t\tit('should skip invalid JSON lines', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': [\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t\t'invalid json line',\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2024-01-02T12:00:00Z',\n\t\t\t\t\t\t\tcostUSD: 0.002,\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tmodel: 'claude-opus-4-20250514',\n\t\t\t\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 20 },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t].join('\\n'),\n\t\t\t\t});\n\n\t\t\t\tconst stats = await detectMismatches(fixture.path);\n\n\t\t\t\texpect(stats.totalEntries).toBe(2); // Only valid entries counted\n\t\t\t});\n\n\t\t\tit('should detect mismatches for claude-opus-4-20250514', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'opus-test.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.5, // Significantly different from calculated cost\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-opus-4-20250514',\n\t\t\t\t\t\t\tusage: {\n\t\t\t\t\t\t\t\tinput_tokens: 100,\n\t\t\t\t\t\t\t\toutput_tokens: 50,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst stats = await detectMismatches(fixture.path);\n\n\t\t\t\texpect(stats.totalEntries).toBe(1);\n\t\t\t\texpect(stats.entriesWithBoth).toBe(1);\n\t\t\t\texpect(stats.mismatches).toBe(1);\n\t\t\t\texpect(stats.discrepancies).toHaveLength(1);\n\n\t\t\t\tconst discrepancy = stats.discrepancies[0];\n\t\t\t\texpect(discrepancy).toBeDefined();\n\t\t\t\texpect(discrepancy?.file).toBe('opus-test.jsonl');\n\t\t\t\texpect(discrepancy?.model).toBe('claude-opus-4-20250514');\n\t\t\t\texpect(discrepancy?.originalCost).toBe(0.5);\n\t\t\t\texpect(discrepancy?.percentDiff).toBeGreaterThan(0.1);\n\t\t\t});\n\n\t\t\tit('should track model statistics', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': [\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\t\tcostUSD: 0.00015, // 50 * 0.000003 = 0.00015 (matches)\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 0 },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2024-01-02T12:00:00Z',\n\t\t\t\t\t\t\tcostUSD: 0.001, // Mismatch with calculated cost (0.0003)\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t].join('\\n'),\n\t\t\t\t});\n\n\t\t\t\tconst stats = await detectMismatches(fixture.path);\n\n\t\t\t\texpect(stats.modelStats.has('claude-sonnet-4-20250514')).toBe(true);\n\t\t\t\tconst modelStat = stats.modelStats.get('claude-sonnet-4-20250514');\n\t\t\t\texpect(modelStat).toBeDefined();\n\t\t\t\texpect(modelStat?.total).toBe(2);\n\t\t\t\texpect(modelStat?.matches).toBe(1);\n\t\t\t\texpect(modelStat?.mismatches).toBe(1);\n\t\t\t});\n\n\t\t\tit('should track version statistics', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'test.jsonl': [\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\t\tcostUSD: 0.00015, // 50 * 0.000003 = 0.00015 (matches)\n\t\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 0 },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2024-01-02T12:00:00Z',\n\t\t\t\t\t\t\tcostUSD: 0.001, // Mismatch with calculated cost (0.0003)\n\t\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t].join('\\n'),\n\t\t\t\t});\n\n\t\t\t\tconst stats = await detectMismatches(fixture.path);\n\n\t\t\t\texpect(stats.versionStats.has('1.0.0')).toBe(true);\n\t\t\t\tconst versionStat = stats.versionStats.get('1.0.0');\n\t\t\t\texpect(versionStat).toBeDefined();\n\t\t\t\texpect(versionStat?.total).toBe(2);\n\t\t\t\texpect(versionStat?.matches).toBe(1);\n\t\t\t\texpect(versionStat?.mismatches).toBe(1);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('printMismatchReport', () => {\n\t\t\tit('should work without errors for basic cases', () => {\n\t\t\t\tconst stats = {\n\t\t\t\t\ttotalEntries: 10,\n\t\t\t\t\tentriesWithBoth: 0,\n\t\t\t\t\tmatches: 0,\n\t\t\t\t\tmismatches: 0,\n\t\t\t\t\tdiscrepancies: [],\n\t\t\t\t\tmodelStats: new Map(),\n\t\t\t\t\tversionStats: new Map(),\n\t\t\t\t};\n\n\t\t\t\texpect(() => printMismatchReport(stats)).not.toThrow();\n\t\t\t});\n\n\t\t\tit('should work with complex stats without errors', () => {\n\t\t\t\tconst modelStats = new Map();\n\t\t\t\tmodelStats.set('claude-sonnet-4-20250514', {\n\t\t\t\t\ttotal: 10,\n\t\t\t\t\tmatches: 8,\n\t\t\t\t\tmismatches: 2,\n\t\t\t\t\tavgPercentDiff: 5.5,\n\t\t\t\t});\n\n\t\t\t\tconst versionStats = new Map();\n\t\t\t\tversionStats.set('1.0.0', {\n\t\t\t\t\ttotal: 10,\n\t\t\t\t\tmatches: 8,\n\t\t\t\t\tmismatches: 2,\n\t\t\t\t\tavgPercentDiff: 3.2,\n\t\t\t\t});\n\n\t\t\t\tconst discrepancies = [\n\t\t\t\t\t{\n\t\t\t\t\t\tfile: 'test1.jsonl',\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\toriginalCost: 0.001,\n\t\t\t\t\t\tcalculatedCost: 0.0015,\n\t\t\t\t\t\tdifference: 0.0005,\n\t\t\t\t\t\tpercentDiff: 50.0,\n\t\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 20 },\n\t\t\t\t\t},\n\t\t\t\t];\n\n\t\t\t\tconst stats = {\n\t\t\t\t\ttotalEntries: 10,\n\t\t\t\t\tentriesWithBoth: 10,\n\t\t\t\t\tmatches: 8,\n\t\t\t\t\tmismatches: 2,\n\t\t\t\t\tdiscrepancies,\n\t\t\t\t\tmodelStats,\n\t\t\t\t\tversionStats,\n\t\t\t\t};\n\n\t\t\t\texpect(() => printMismatchReport(stats)).not.toThrow();\n\t\t\t});\n\n\t\t\tit('should work with sample count limit', () => {\n\t\t\t\tconst discrepancies = [\n\t\t\t\t\t{\n\t\t\t\t\t\tfile: 'test.jsonl',\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\toriginalCost: 0.001,\n\t\t\t\t\t\tcalculatedCost: 0.0015,\n\t\t\t\t\t\tdifference: 0.0005,\n\t\t\t\t\t\tpercentDiff: 50.0,\n\t\t\t\t\t\tusage: { input_tokens: 100, output_tokens: 20 },\n\t\t\t\t\t},\n\t\t\t\t];\n\n\t\t\t\tconst stats = {\n\t\t\t\t\ttotalEntries: 10,\n\t\t\t\t\tentriesWithBoth: 10,\n\t\t\t\t\tmatches: 9,\n\t\t\t\t\tmismatches: 1,\n\t\t\t\t\tdiscrepancies,\n\t\t\t\t\tmodelStats: new Map(),\n\t\t\t\t\tversionStats: new Map(),\n\t\t\t\t};\n\n\t\t\t\texpect(() => printMismatchReport(stats, 0)).not.toThrow();\n\t\t\t\texpect(() => printMismatchReport(stats, 1)).not.toThrow();\n\t\t\t});\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/ccusage/src/index.ts",
    "content": "#!/usr/bin/env node\n\n/**\n * @fileoverview Main entry point for ccusage CLI tool\n *\n * This is the main entry point for the ccusage command-line interface tool.\n * It provides analysis of Claude Code usage data from local JSONL files.\n *\n * @module index\n */\n\n/* eslint-disable antfu/no-top-level-await */\n\nimport { run } from './commands/index.ts';\n\nawait run();\n"
  },
  {
    "path": "apps/ccusage/src/logger.ts",
    "content": "/**\n * @fileoverview Logging utilities for the ccusage application\n *\n * This module provides configured logger instances using consola for consistent\n * logging throughout the application with package name tagging.\n *\n * @module logger\n */\n\nimport { createLogger, log as internalLog } from '@ccusage/internal/logger';\n\nimport { name } from '../package.json';\n\n/**\n * Application logger instance with package name tag\n */\nexport const logger = createLogger(name);\n\n/**\n * Direct console.log function for cases where logger formatting is not desired\n */\nexport const log = internalLog;\n"
  },
  {
    "path": "apps/ccusage/test/statusline-test-opus4.json",
    "content": "{\n\t\"session_id\": \"test-session-opus4\",\n\t\"transcript_path\": \"test/test-transcript.jsonl\",\n\t\"cwd\": \"/Users/test/project\",\n\t\"model\": {\n\t\t\"id\": \"claude-opus-4-1-20250805\",\n\t\t\"display_name\": \"Opus 4.1\"\n\t},\n\t\"workspace\": {\n\t\t\"current_dir\": \"/Users/test/project\",\n\t\t\"project_dir\": \"/Users/test/project\"\n\t},\n\t\"version\": \"1.0.88\",\n\t\"output_style\": {\n\t\t\"name\": \"default\"\n\t},\n\t\"cost\": {\n\t\t\"total_cost_usd\": 0.0892,\n\t\t\"total_duration_ms\": 180000,\n\t\t\"total_api_duration_ms\": 12000,\n\t\t\"total_lines_added\": 0,\n\t\t\"total_lines_removed\": 0\n\t},\n\t\"context_window\": {\n\t\t\"total_input_tokens\": 85000,\n\t\t\"total_output_tokens\": 5000,\n\t\t\"context_window_size\": 200000\n\t},\n\t\"exceeds_200k_tokens\": false\n}\n"
  },
  {
    "path": "apps/ccusage/test/statusline-test-sonnet4.json",
    "content": "{\n\t\"session_id\": \"test-session-sonnet4\",\n\t\"transcript_path\": \"test/test-transcript.jsonl\",\n\t\"cwd\": \"/Users/test/project\",\n\t\"model\": {\n\t\t\"id\": \"claude-sonnet-4-20250514\",\n\t\t\"display_name\": \"Sonnet 4\"\n\t},\n\t\"workspace\": {\n\t\t\"current_dir\": \"/Users/test/project\",\n\t\t\"project_dir\": \"/Users/test/project\"\n\t},\n\t\"version\": \"1.0.88\",\n\t\"output_style\": {\n\t\t\"name\": \"default\"\n\t},\n\t\"cost\": {\n\t\t\"total_cost_usd\": 0.0245,\n\t\t\"total_duration_ms\": 120000,\n\t\t\"total_api_duration_ms\": 8500,\n\t\t\"total_lines_added\": 0,\n\t\t\"total_lines_removed\": 0\n\t},\n\t\"context_window\": {\n\t\t\"total_input_tokens\": 35000,\n\t\t\"total_output_tokens\": 2500,\n\t\t\"context_window_size\": 200000\n\t},\n\t\"exceeds_200k_tokens\": false\n}\n"
  },
  {
    "path": "apps/ccusage/test/statusline-test-sonnet41.json",
    "content": "{\n\t\"session_id\": \"test-session-sonnet41\",\n\t\"transcript_path\": \"test/test-transcript.jsonl\",\n\t\"cwd\": \"/Users/test/project\",\n\t\"model\": {\n\t\t\"id\": \"claude-sonnet-4-1-20250805\",\n\t\t\"display_name\": \"Sonnet 4.1\"\n\t},\n\t\"workspace\": {\n\t\t\"current_dir\": \"/Users/test/project\",\n\t\t\"project_dir\": \"/Users/test/project\"\n\t},\n\t\"version\": \"1.0.88\",\n\t\"output_style\": {\n\t\t\"name\": \"default\"\n\t},\n\t\"cost\": {\n\t\t\"total_cost_usd\": 0.0356,\n\t\t\"total_duration_ms\": 150000,\n\t\t\"total_api_duration_ms\": 9500,\n\t\t\"total_lines_added\": 0,\n\t\t\"total_lines_removed\": 0\n\t},\n\t\"context_window\": {\n\t\t\"total_input_tokens\": 52000,\n\t\t\"total_output_tokens\": 3800,\n\t\t\"context_window_size\": 200000\n\t},\n\t\"exceeds_200k_tokens\": false\n}\n"
  },
  {
    "path": "apps/ccusage/test/statusline-test.json",
    "content": "{\n\t\"session_id\": \"73cc9f9a-2775-4418-beec-bc36b62a1c6f\",\n\t\"transcript_path\": \"/Users/ryoppippi/.config/claude/projects/-Users-ryoppippi-ghq-github-com-ryoppippi-ccusage/73cc9f9a-2775-4418-beec-bc36b62a1c6f.jsonl\",\n\t\"cwd\": \"/Users/ryoppippi/ghq/github.com/ryoppippi/ccusage\",\n\t\"model\": {\n\t\t\"id\": \"claude-sonnet-4-20250514\",\n\t\t\"display_name\": \"Sonnet 4\"\n\t},\n\t\"workspace\": {\n\t\t\"current_dir\": \"/Users/ryoppippi/ghq/github.com/ryoppippi/ccusage\",\n\t\t\"project_dir\": \"/Users/ryoppippi/ghq/github.com/ryoppippi/ccusage\"\n\t},\n\t\"version\": \"1.0.88\",\n\t\"output_style\": {\n\t\t\"name\": \"default\"\n\t},\n\t\"cost\": {\n\t\t\"total_cost_usd\": 0.056266149999999994,\n\t\t\"total_duration_ms\": 164055,\n\t\t\"total_api_duration_ms\": 13577,\n\t\t\"total_lines_added\": 0,\n\t\t\"total_lines_removed\": 0\n\t},\n\t\"context_window\": {\n\t\t\"total_input_tokens\": 42500,\n\t\t\"total_output_tokens\": 3200,\n\t\t\"context_window_size\": 200000\n\t},\n\t\"exceeds_200k_tokens\": false\n}\n"
  },
  {
    "path": "apps/ccusage/test/test-transcript.jsonl",
    "content": "{\"type\":\"user\",\"message\":{}}\n{\"type\":\"assistant\",\"message\":{\"usage\":{\"input_tokens\":1000,\"output_tokens\":50,\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":500}}}\n{\"type\":\"user\",\"message\":{}}\n{\"type\":\"assistant\",\"message\":{\"usage\":{\"input_tokens\":2000,\"output_tokens\":100,\"cache_creation_input_tokens\":200,\"cache_read_input_tokens\":800}}}"
  },
  {
    "path": "apps/ccusage/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"jsx\": \"react-jsx\",\n\t\t// Environment setup & latest features\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"moduleDetection\": \"force\",\n\t\t\"module\": \"Preserve\",\n\t\t// Bundler mode\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"types\": [\"@types/bun\", \"vitest/globals\", \"vitest/importMeta\"],\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"allowJs\": true,\n\t\t// Best practices\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noPropertyAccessFromIndexSignature\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t// Some stricter flags (disabled by default)\n\t\t\"noUnusedLocals\": false,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noEmit\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"exclude\": [\"dist\"]\n}\n"
  },
  {
    "path": "apps/ccusage/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\nimport Macros from 'unplugin-macros/rolldown';\n\nexport default defineConfig({\n\tentry: [\n\t\t'./src/*.ts',\n\t\t'!./src/**/*.test.ts', // Exclude test files\n\t\t'!./src/_*.ts', // Exclude internal files with underscore prefix\n\t],\n\toutDir: 'dist',\n\tformat: 'esm',\n\tclean: true,\n\tsourcemap: false,\n\tminify: 'dce-only',\n\ttreeshake: true,\n\tfixedExtension: false,\n\tdts: {\n\t\ttsgo: false,\n\t\tresolve: ['type-fest', 'valibot', '@ccusage/internal', '@ccusage/terminal'],\n\t},\n\tpublint: true,\n\tunused: true,\n\texports: {\n\t\tdevExports: true,\n\t},\n\tnodeProtocol: true,\n\tplugins: [\n\t\tMacros({\n\t\t\tinclude: ['src/index.ts', 'src/_pricing-fetcher.ts'],\n\t\t}),\n\t],\n\tdefine: {\n\t\t'import.meta.vitest': 'undefined',\n\t},\n});\n"
  },
  {
    "path": "apps/ccusage/vitest.config.ts",
    "content": "import Macros from 'unplugin-macros/vite';\nimport { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\twatch: false,\n\t\tincludeSource: ['src/**/*.{js,ts}'],\n\t\tglobals: true,\n\t},\n\tplugins: [\n\t\tMacros({\n\t\t\tinclude: ['src/index.ts', 'src/pricing-fetcher.ts'],\n\t\t}) as any, // vitest bundles its own vite types, so relax plugin typing here\n\t],\n});\n"
  },
  {
    "path": "apps/codex/CLAUDE.md",
    "content": "# Codex CLI Notes\n\n## Log Sources\n\n- Codex session usage is recorded under `${CODEX_HOME:-~/.codex}/sessions/` (the CLI resolves `CODEX_HOME` and falls back to `~/.codex`).\n- Each JSONL line is an `event_msg` with `payload.type === \"token_count\"`.\n- `payload.info.total_token_usage` holds cumulative totals; `payload.info.last_token_usage` is the delta for the most recent turn.\n- When only cumulative totals are present, we subtract the previous totals to recover a per-event delta.\n\n## Token Fields\n\n- `input_tokens`: total input tokens sent to the model.\n- `cached_input_tokens`: cached portion of the input (prompt-caching).\n- `output_tokens`: normal output tokens (includes completion text).\n- `reasoning_output_tokens`: structured reasoning tokens counted separately by OpenAI.\n- `total_tokens`: either provided directly or, for legacy entries, recomputed as `input + output` (reasoning is informational and already included in `output`).\n\n-## Cost Calculation\n\n- Pricing is pulled from LiteLLM's public JSON (`model_prices_and_context_window.json`).\n- 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.\n- 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.\n- Cost formula per model/date:\n  - Non-cached input: `(input_tokens - cached_input_tokens) / 1_000_000 * input_cost_per_mtoken`.\n  - Cached input: `cached_input_tokens / 1_000_000 * cached_input_cost_per_mtoken` (falls back to input price when missing).\n  - Output: `output_tokens / 1_000_000 * output_cost_per_mtoken`.\n- Cached token rate for `gpt-5` (2025-08-07 pricing):\n  - Input: $0.00125 per 1K tokens (→ $1.25 per 1M).\n  - Cached input: $0.000125 per 1K tokens (→ $0.125 per 1M).\n  - Output: $0.01 per 1K tokens (→ $10 per 1M).\n- Command flag `--offline` forces use of the embedded pricing snapshot.\n\n### Token mapping & reasoning notes\n\n| Field                                           | Meaning                                     | Billing treatment                                                        |\n| ----------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ |\n| `input_tokens`                                  | Prompt tokens sent this turn                | Priced at `input_cost_per_mtoken` minus the cached share                 |\n| `cached_input_tokens`/`cache_read_input_tokens` | Prompt tokens satisfied from cache          | Priced at `cached_input_cost_per_mtoken` (falls back to input price)     |\n| `output_tokens`                                 | Completion tokens, including reasoning cost | Priced at `output_cost_per_mtoken`                                       |\n| `reasoning_output_tokens`                       | Optional breakdown for reasoning            | Informational only; already included in `output_tokens`                  |\n| `total_tokens`                                  | Cumulative total emitted by Codex           | Used verbatim when present; legacy entries fall back to `input + output` |\n\nParsing 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.\n\n## CLI Usage\n\n- 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.\n- Codex is packaged as a bundled CLI. Keep every runtime dependency in `devDependencies` so the bundle includes the code that ships.\n- Entry point remains Gunshi-based; only `daily` subcommand is wired for now.\n- Session discovery relies solely on `CODEX_HOME`; there is no explicit `--dir` override.\n- `--json` toggles structured output; totals include aggregated tokens and USD cost.\n- Table view lists models per day with their token totals in parentheses.\n\n## Testing Notes\n\n- Tests rely on `fs-fixture` with `using` to ensure cleanup.\n- Pricing tests inject stub offline loaders to avoid network access.\n- All vitest blocks live alongside implementation files via `if (import.meta.vitest != null)`.\n"
  },
  {
    "path": "apps/codex/README.md",
    "content": "<div align=\"center\">\n    <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.svg\" alt=\"ccusage logo\" width=\"256\" height=\"256\">\n    <h1>@ccusage/codex</h1>\n</div>\n\n<p align=\"center\">\n    <a href=\"https://socket.dev/api/npm/package/@ccusage/codex\"><img src=\"https://socket.dev/api/badge/npm/package/@ccusage/codex\" alt=\"Socket Badge\" /></a>\n    <a href=\"https://npmjs.com/package/@ccusage/codex\"><img src=\"https://img.shields.io/npm/v/@ccusage/codex?color=yellow\" alt=\"npm version\" /></a>\n    <a href=\"https://tanstack.com/stats/npm?packageGroups=%5B%7B%22packages%22:%5B%7B%22name%22:%22@ccusage/codex%22%7D%5D%7D%5D&range=30-days&transform=none&binType=daily&showDataMode=all&height=400\"><img src=\"https://img.shields.io/npm/dt/@ccusage/codex\" alt=\"NPM Downloads\" /></a>\n    <a href=\"https://packagephobia.com/result?p=@ccusage/codex\"><img src=\"https://packagephobia.com/badge?p=@ccusage/codex\" alt=\"install size\" /></a>\n    <a href=\"https://deepwiki.com/ryoppippi/ccusage\"><img src=\"https://img.shields.io/badge/DeepWiki-ryoppippi%2Fccusage-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==\" alt=\"DeepWiki\"></a>\n</p>\n\n<div align=\"center\">\n  <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/codex-cli.jpeg\" alt=\"Codex CLI usage screenshot\" width=\"640\">\n</div>\n\n> Analyze <a href=\"https://github.com/openai/codex\">OpenAI Codex CLI</a> usage logs with the same reporting experience as <code>ccusage</code>.\n\n> ⚠️ <strong>Beta:</strong> The Codex CLI support is experimental. Expect breaking changes until the upstream Codex tooling stabilizes.\n\n## Quick Start\n\n```bash\n# Recommended - always include @latest\nnpx @ccusage/codex@latest --help\nbunx @ccusage/codex@latest --help  # ⚠️ MUST include @latest with bunx\n\n# Alternative package runners\npnpm dlx @ccusage/codex\npnpx @ccusage/codex\n\n# Using deno (with security flags)\ndeno run -E -R=$HOME/.codex/ -S=homedir -N='raw.githubusercontent.com:443' npm:@ccusage/codex@latest --help\n```\n\n> ⚠️ **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.\n\n### Recommended: Shell Alias\n\nSince `npx @ccusage/codex@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias:\n\n```bash\n# bash/zsh: alias ccusage-codex='bunx @ccusage/codex@latest'\n# fish:     alias ccusage-codex 'bunx @ccusage/codex@latest'\n\n# Then simply run:\nccusage-codex daily\nccusage-codex monthly --json\n```\n\n> 💡 The CLI looks for Codex session JSONL files under `CODEX_HOME` (defaults to `~/.codex`).\n\n## Common Commands\n\n```bash\n# Daily usage grouped by date (default command)\nnpx @ccusage/codex@latest daily\n\n# Date range filtering\nnpx @ccusage/codex@latest daily --since 20250911 --until 20250917\n\n# JSON output for scripting\nnpx @ccusage/codex@latest daily --json\n\n# Monthly usage grouped by month\nnpx @ccusage/codex@latest monthly\n\n# Monthly JSON report for integrations\nnpx @ccusage/codex@latest monthly --json\n\n# Session-level detailed report\nnpx @ccusage/codex@latest sessions\n```\n\nUseful environment variables:\n\n- `CODEX_HOME` – override the root directory that contains Codex session folders\n- `LOG_LEVEL` – controla consola log verbosity (0 silent … 5 trace)\n\nℹ️ 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.\n📦 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.\n\n## Features\n\n- 📊 Responsive terminal tables shared with the `ccusage` CLI\n- 💵 Offline-first pricing cache with automatic LiteLLM refresh when needed\n- 🤖 Per-model token and cost aggregation, including cached token accounting\n- 📅 Daily and monthly rollups with identical CLI options\n- 📄 JSON output for further processing or scripting\n\n## Documentation\n\nFor detailed guides and examples, visit **[ccusage.com/guide/codex](https://ccusage.com/guide/codex/)**.\n\n## Sponsors\n\n### Featured Sponsor\n\nCheck out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)\n\n<p align=\"center\">\n    <a href=\"https://www.youtube.com/watch?v=Ak6qpQ5qdgk\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/ccusage_thumbnail.png\" alt=\"ccusage: The Claude Code cost scorecard that went viral\" width=\"600\">\n    </a>\n</p>\n\n<p align=\"center\">\n    <a href=\"https://github.com/sponsors/ryoppippi\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/sponsors@main/sponsors.svg\">\n    </a>\n</p>\n\n## License\n\nMIT © [@ryoppippi](https://github.com/ryoppippi)\n"
  },
  {
    "path": "apps/codex/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\n/** @type {import('eslint').Linter.FlatConfig[]} */\nconst config = ryoppippi(\n\t{\n\t\ttype: 'app',\n\t\tstylistic: false,\n\t},\n\t{\n\t\trules: {\n\t\t\t'test/no-importing-vitest-globals': 'error',\n\t\t},\n\t},\n);\n\nexport default config;\n"
  },
  {
    "path": "apps/codex/package.json",
    "content": "{\n\t\"name\": \"@ccusage/codex\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"description\": \"Usage analysis tool for OpenAI Codex sessions\",\n\t\"author\": \"ryoppippi\",\n\t\"license\": \"MIT\",\n\t\"funding\": \"https://github.com/ryoppippi/ccusage?sponsor=1\",\n\t\"homepage\": \"https://github.com/ryoppippi/ccusage#readme\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/ryoppippi/ccusage.git\",\n\t\t\"directory\": \"apps/codex\"\n\t},\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/ryoppippi/ccusage/issues\"\n\t},\n\t\"main\": \"./dist/index.js\",\n\t\"module\": \"./dist/index.js\",\n\t\"bin\": {\n\t\t\"ccusage-codex\": \"./src/index.ts\"\n\t},\n\t\"files\": [\n\t\t\"dist\"\n\t],\n\t\"publishConfig\": {\n\t\t\"bin\": {\n\t\t\t\"ccusage-codex\": \"./dist/index.js\"\n\t\t}\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=20.19.4\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"tsdown\",\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"prepack\": \"pnpm run build && clean-pkg-json\",\n\t\t\"prerelease\": \"pnpm run lint && pnpm run typecheck && pnpm run build\",\n\t\t\"start\": \"bun ./src/index.ts\",\n\t\t\"test\": \"TZ=UTC vitest\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@ccusage/internal\": \"workspace:*\",\n\t\t\"@ccusage/terminal\": \"workspace:*\",\n\t\t\"@praha/byethrow\": \"catalog:runtime\",\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"@typescript/native-preview\": \"catalog:types\",\n\t\t\"clean-pkg-json\": \"catalog:release\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"fast-sort\": \"catalog:runtime\",\n\t\t\"fs-fixture\": \"catalog:testing\",\n\t\t\"gunshi\": \"catalog:runtime\",\n\t\t\"picocolors\": \"catalog:runtime\",\n\t\t\"tinyglobby\": \"catalog:runtime\",\n\t\t\"tsdown\": \"catalog:build\",\n\t\t\"unplugin-macros\": \"catalog:build\",\n\t\t\"unplugin-unused\": \"catalog:build\",\n\t\t\"valibot\": \"catalog:runtime\",\n\t\t\"vitest\": \"catalog:testing\"\n\t}\n}\n"
  },
  {
    "path": "apps/codex/src/_consts.ts",
    "content": "import os from 'node:os';\nimport path from 'node:path';\n\nexport const CODEX_HOME_ENV = 'CODEX_HOME';\nexport const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');\nexport const DEFAULT_SESSION_SUBDIR = 'sessions';\nexport const SESSION_GLOB = '**/*.jsonl';\nexport const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';\nexport const DEFAULT_LOCALE = 'en-CA';\nexport const DEFAULT_PRECISION = 2;\n\nexport const MILLION = 1_000_000;\n\nexport const PRICING_CACHE_TTL_MS = 1000 * 60 * 5; // 5 minutes\n"
  },
  {
    "path": "apps/codex/src/_macro.ts",
    "content": "import type { LiteLLMModelPricing } from '@ccusage/internal/pricing';\nimport {\n\tcreatePricingDataset,\n\tfetchLiteLLMPricingDataset,\n\tfilterPricingDataset,\n} from '@ccusage/internal/pricing-fetch-utils';\n\nconst CODEX_MODEL_PREFIXES = [\n\t'gpt-5',\n\t'gpt-5-',\n\t'openai/gpt-5',\n\t'azure/gpt-5',\n\t'openrouter/openai/gpt-5',\n];\n\nfunction isCodexModel(modelName: string, _pricing: LiteLLMModelPricing): boolean {\n\treturn CODEX_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix));\n}\n\nexport async function prefetchCodexPricing(): Promise<Record<string, LiteLLMModelPricing>> {\n\ttry {\n\t\tconst dataset = await fetchLiteLLMPricingDataset();\n\t\treturn filterPricingDataset(dataset, isCodexModel);\n\t} catch (error) {\n\t\tconsole.warn('Failed to prefetch Codex pricing data, proceeding with empty cache.', error);\n\t\treturn createPricingDataset();\n\t}\n}\n"
  },
  {
    "path": "apps/codex/src/_shared-args.ts",
    "content": "import type { Args } from 'gunshi';\nimport { DEFAULT_LOCALE, DEFAULT_TIMEZONE } from './_consts.ts';\n\nexport const sharedArgs = {\n\tjson: {\n\t\ttype: 'boolean',\n\t\tshort: 'j',\n\t\tdescription: 'Output report as JSON',\n\t\tdefault: false,\n\t},\n\tsince: {\n\t\ttype: 'string',\n\t\tshort: 's',\n\t\tdescription: 'Filter from date (YYYY-MM-DD or YYYYMMDD)',\n\t},\n\tuntil: {\n\t\ttype: 'string',\n\t\tshort: 'u',\n\t\tdescription: 'Filter until date (inclusive)',\n\t},\n\ttimezone: {\n\t\ttype: 'string',\n\t\tshort: 'z',\n\t\tdescription: 'Timezone for date grouping (IANA)',\n\t\tdefault: DEFAULT_TIMEZONE,\n\t},\n\tlocale: {\n\t\ttype: 'string',\n\t\tshort: 'l',\n\t\tdescription: 'Locale for formatting',\n\t\tdefault: DEFAULT_LOCALE,\n\t},\n\toffline: {\n\t\ttype: 'boolean',\n\t\tshort: 'O',\n\t\tdescription: 'Use cached pricing data instead of fetching from LiteLLM',\n\t\tdefault: false,\n\t\tnegatable: true,\n\t},\n\tcompact: {\n\t\ttype: 'boolean',\n\t\tdescription: 'Force compact table layout for narrow terminals',\n\t\tdefault: false,\n\t},\n\tcolor: {\n\t\t// --color and FORCE_COLOR=1 is handled by picocolors\n\t\ttype: 'boolean',\n\t\tdescription: 'Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.',\n\t},\n\tnoColor: {\n\t\t// --no-color and NO_COLOR=1 is handled by picocolors\n\t\ttype: 'boolean',\n\t\tdescription: 'Disable colored output (default: auto). NO_COLOR=1 has the same effect.',\n\t},\n} as const satisfies Args;\n"
  },
  {
    "path": "apps/codex/src/_types.ts",
    "content": "export type TokenUsageDelta = {\n\tinputTokens: number;\n\tcachedInputTokens: number;\n\toutputTokens: number;\n\treasoningOutputTokens: number;\n\ttotalTokens: number;\n};\n\nexport type TokenUsageEvent = TokenUsageDelta & {\n\ttimestamp: string;\n\tsessionId: string;\n\tmodel?: string;\n\tisFallbackModel?: boolean;\n};\n\nexport type ModelUsage = TokenUsageDelta & {\n\tisFallback?: boolean;\n};\n\nexport type DailyUsageSummary = {\n\tdate: string;\n\tfirstTimestamp: string;\n\tcostUSD: number;\n\tmodels: Map<string, ModelUsage>;\n} & TokenUsageDelta;\n\nexport type MonthlyUsageSummary = {\n\tmonth: string;\n\tfirstTimestamp: string;\n\tcostUSD: number;\n\tmodels: Map<string, ModelUsage>;\n} & TokenUsageDelta;\n\nexport type SessionUsageSummary = {\n\tsessionId: string;\n\tfirstTimestamp: string;\n\tlastTimestamp: string;\n\tcostUSD: number;\n\tmodels: Map<string, ModelUsage>;\n} & TokenUsageDelta;\n\nexport type ModelPricing = {\n\tinputCostPerMToken: number;\n\tcachedInputCostPerMToken: number;\n\toutputCostPerMToken: number;\n};\n\nexport type PricingLookupResult = {\n\tmodel: string;\n\tpricing: ModelPricing;\n};\n\nexport type PricingSource = {\n\tgetPricing: (model: string) => Promise<ModelPricing>;\n};\n\nexport type DailyReportRow = {\n\tdate: string;\n\tinputTokens: number;\n\tcachedInputTokens: number;\n\toutputTokens: number;\n\treasoningOutputTokens: number;\n\ttotalTokens: number;\n\tcostUSD: number;\n\tmodels: Record<string, ModelUsage>;\n};\n\nexport type MonthlyReportRow = {\n\tmonth: string;\n\tinputTokens: number;\n\tcachedInputTokens: number;\n\toutputTokens: number;\n\treasoningOutputTokens: number;\n\ttotalTokens: number;\n\tcostUSD: number;\n\tmodels: Record<string, ModelUsage>;\n};\n\nexport type SessionReportRow = {\n\tsessionId: string;\n\tlastActivity: string;\n\tsessionFile: string;\n\tdirectory: string;\n\tinputTokens: number;\n\tcachedInputTokens: number;\n\toutputTokens: number;\n\treasoningOutputTokens: number;\n\ttotalTokens: number;\n\tcostUSD: number;\n\tmodels: Record<string, ModelUsage>;\n};\n"
  },
  {
    "path": "apps/codex/src/command-utils.ts",
    "content": "import { sort } from 'fast-sort';\n\nexport type UsageGroup = {\n\tinputTokens: number;\n\tcachedInputTokens: number;\n\toutputTokens: number;\n\treasoningOutputTokens: number;\n};\n\nexport function splitUsageTokens(usage: UsageGroup): {\n\tinputTokens: number;\n\treasoningTokens: number;\n\tcacheReadTokens: number;\n\toutputTokens: number;\n} {\n\tconst cacheReadTokens = Math.min(usage.cachedInputTokens, usage.inputTokens);\n\tconst inputTokens = Math.max(usage.inputTokens - cacheReadTokens, 0);\n\tconst outputTokens = Math.max(usage.outputTokens, 0);\n\tconst rawReasoning = usage.reasoningOutputTokens ?? 0;\n\tconst reasoningTokens = Math.max(0, Math.min(rawReasoning, outputTokens));\n\n\treturn {\n\t\tinputTokens,\n\t\treasoningTokens,\n\t\tcacheReadTokens,\n\t\toutputTokens,\n\t};\n}\n\nexport function formatModelsList(\n\tmodels: Record<string, { totalTokens: number; isFallback?: boolean }>,\n): string[] {\n\treturn sort(Object.entries(models))\n\t\t.asc(([model]) => model)\n\t\t.map(([model, data]) => (data.isFallback === true ? `${model} (fallback)` : model));\n}\n"
  },
  {
    "path": "apps/codex/src/commands/daily.ts",
    "content": "import process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { DEFAULT_TIMEZONE } from '../_consts.ts';\nimport { sharedArgs } from '../_shared-args.ts';\nimport { formatModelsList, splitUsageTokens } from '../command-utils.ts';\nimport { buildDailyReport } from '../daily-report.ts';\nimport { loadTokenUsageEvents } from '../data-loader.ts';\nimport { normalizeFilterDate } from '../date-utils.ts';\nimport { log, logger } from '../logger.ts';\nimport { CodexPricingSource } from '../pricing.ts';\n\nconst TABLE_COLUMN_COUNT = 8;\n\nexport const dailyCommand = define({\n\tname: 'daily',\n\tdescription: 'Show Codex token usage grouped by day',\n\targs: sharedArgs,\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\t\tif (jsonOutput) {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\tlet since: string | undefined;\n\t\tlet until: string | undefined;\n\n\t\ttry {\n\t\t\tsince = normalizeFilterDate(ctx.values.since);\n\t\t\tuntil = normalizeFilterDate(ctx.values.until);\n\t\t} catch (error) {\n\t\t\tlogger.error(String(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tconst { events, missingDirectories } = await loadTokenUsageEvents();\n\n\t\tfor (const missing of missingDirectories) {\n\t\t\tlogger.warn(`Codex session directory not found: ${missing}`);\n\t\t}\n\n\t\tif (events.length === 0) {\n\t\t\tlog(jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No Codex usage data found.');\n\t\t\treturn;\n\t\t}\n\n\t\tconst pricingSource = new CodexPricingSource({\n\t\t\toffline: ctx.values.offline,\n\t\t});\n\t\ttry {\n\t\t\tconst rows = await buildDailyReport(events, {\n\t\t\t\tpricingSource,\n\t\t\t\ttimezone: ctx.values.timezone,\n\t\t\t\tlocale: ctx.values.locale,\n\t\t\t\tsince,\n\t\t\t\tuntil,\n\t\t\t});\n\n\t\t\tif (rows.length === 0) {\n\t\t\t\tlog(\n\t\t\t\t\tjsonOutput\n\t\t\t\t\t\t? JSON.stringify({ daily: [], totals: null })\n\t\t\t\t\t\t: 'No Codex usage data found for provided filters.',\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst totals = rows.reduce(\n\t\t\t\t(acc, row) => {\n\t\t\t\t\tacc.inputTokens += row.inputTokens;\n\t\t\t\t\tacc.cachedInputTokens += row.cachedInputTokens;\n\t\t\t\t\tacc.outputTokens += row.outputTokens;\n\t\t\t\t\tacc.reasoningOutputTokens += row.reasoningOutputTokens;\n\t\t\t\t\tacc.totalTokens += row.totalTokens;\n\t\t\t\t\tacc.costUSD += row.costUSD;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\tcachedInputTokens: 0,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\tcostUSD: 0,\n\t\t\t\t},\n\t\t\t);\n\n\t\t\tif (jsonOutput) {\n\t\t\t\tlog(\n\t\t\t\t\tJSON.stringify(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tdaily: rows,\n\t\t\t\t\t\t\ttotals,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tnull,\n\t\t\t\t\t\t2,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlogger.box(\n\t\t\t\t`Codex Token Usage Report - Daily (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`,\n\t\t\t);\n\n\t\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\t\thead: [\n\t\t\t\t\t'Date',\n\t\t\t\t\t'Models',\n\t\t\t\t\t'Input',\n\t\t\t\t\t'Output',\n\t\t\t\t\t'Reasoning',\n\t\t\t\t\t'Cache Read',\n\t\t\t\t\t'Total Tokens',\n\t\t\t\t\t'Cost (USD)',\n\t\t\t\t],\n\t\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\t\tcompactHead: ['Date', 'Models', 'Input', 'Output', 'Cost (USD)'],\n\t\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right'],\n\t\t\t\tcompactThreshold: 100,\n\t\t\t\tforceCompact: ctx.values.compact,\n\t\t\t\tstyle: { head: ['cyan'] },\n\t\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t\t});\n\n\t\t\tconst totalsForDisplay = {\n\t\t\t\tinputTokens: 0,\n\t\t\t\toutputTokens: 0,\n\t\t\t\treasoningTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalTokens: 0,\n\t\t\t\tcostUSD: 0,\n\t\t\t};\n\n\t\t\tfor (const row of rows) {\n\t\t\t\tconst split = splitUsageTokens(row);\n\t\t\t\ttotalsForDisplay.inputTokens += split.inputTokens;\n\t\t\t\ttotalsForDisplay.outputTokens += split.outputTokens;\n\t\t\t\ttotalsForDisplay.reasoningTokens += split.reasoningTokens;\n\t\t\t\ttotalsForDisplay.cacheReadTokens += split.cacheReadTokens;\n\t\t\t\ttotalsForDisplay.totalTokens += row.totalTokens;\n\t\t\t\ttotalsForDisplay.costUSD += row.costUSD;\n\n\t\t\t\ttable.push([\n\t\t\t\t\trow.date,\n\t\t\t\t\tformatModelsDisplayMultiline(formatModelsList(row.models)),\n\t\t\t\t\tformatNumber(split.inputTokens),\n\t\t\t\t\tformatNumber(split.outputTokens),\n\t\t\t\t\tformatNumber(split.reasoningTokens),\n\t\t\t\t\tformatNumber(split.cacheReadTokens),\n\t\t\t\t\tformatNumber(row.totalTokens),\n\t\t\t\t\tformatCurrency(row.costUSD),\n\t\t\t\t]);\n\t\t\t}\n\n\t\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\t\ttable.push([\n\t\t\t\tpc.yellow('Total'),\n\t\t\t\t'',\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.inputTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.outputTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.reasoningTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.totalTokens)),\n\t\t\t\tpc.yellow(formatCurrency(totalsForDisplay.costUSD)),\n\t\t\t]);\n\n\t\t\tlog(table.toString());\n\n\t\t\tif (table.isCompactMode()) {\n\t\t\t\tlogger.info('\\nRunning in Compact Mode');\n\t\t\t\tlogger.info('Expand terminal width to see cache metrics and total tokens');\n\t\t\t}\n\t\t} finally {\n\t\t\tpricingSource[Symbol.dispose]();\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/codex/src/commands/monthly.ts",
    "content": "import process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { DEFAULT_TIMEZONE } from '../_consts.ts';\nimport { sharedArgs } from '../_shared-args.ts';\nimport { formatModelsList, splitUsageTokens } from '../command-utils.ts';\nimport { loadTokenUsageEvents } from '../data-loader.ts';\nimport { normalizeFilterDate } from '../date-utils.ts';\nimport { log, logger } from '../logger.ts';\nimport { buildMonthlyReport } from '../monthly-report.ts';\nimport { CodexPricingSource } from '../pricing.ts';\n\nconst TABLE_COLUMN_COUNT = 8;\n\nexport const monthlyCommand = define({\n\tname: 'monthly',\n\tdescription: 'Show Codex token usage grouped by month',\n\targs: sharedArgs,\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\t\tif (jsonOutput) {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\tlet since: string | undefined;\n\t\tlet until: string | undefined;\n\n\t\ttry {\n\t\t\tsince = normalizeFilterDate(ctx.values.since);\n\t\t\tuntil = normalizeFilterDate(ctx.values.until);\n\t\t} catch (error) {\n\t\t\tlogger.error(String(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tconst { events, missingDirectories } = await loadTokenUsageEvents();\n\n\t\tfor (const missing of missingDirectories) {\n\t\t\tlogger.warn(`Codex session directory not found: ${missing}`);\n\t\t}\n\n\t\tif (events.length === 0) {\n\t\t\tlog(\n\t\t\t\tjsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No Codex usage data found.',\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tconst pricingSource = new CodexPricingSource({\n\t\t\toffline: ctx.values.offline,\n\t\t});\n\t\ttry {\n\t\t\tconst rows = await buildMonthlyReport(events, {\n\t\t\t\tpricingSource,\n\t\t\t\ttimezone: ctx.values.timezone,\n\t\t\t\tlocale: ctx.values.locale,\n\t\t\t\tsince,\n\t\t\t\tuntil,\n\t\t\t});\n\n\t\t\tif (rows.length === 0) {\n\t\t\t\tlog(\n\t\t\t\t\tjsonOutput\n\t\t\t\t\t\t? JSON.stringify({ monthly: [], totals: null })\n\t\t\t\t\t\t: 'No Codex usage data found for provided filters.',\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst totals = rows.reduce(\n\t\t\t\t(acc, row) => {\n\t\t\t\t\tacc.inputTokens += row.inputTokens;\n\t\t\t\t\tacc.cachedInputTokens += row.cachedInputTokens;\n\t\t\t\t\tacc.outputTokens += row.outputTokens;\n\t\t\t\t\tacc.reasoningOutputTokens += row.reasoningOutputTokens;\n\t\t\t\t\tacc.totalTokens += row.totalTokens;\n\t\t\t\t\tacc.costUSD += row.costUSD;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\tcachedInputTokens: 0,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\tcostUSD: 0,\n\t\t\t\t},\n\t\t\t);\n\n\t\t\tif (jsonOutput) {\n\t\t\t\tlog(\n\t\t\t\t\tJSON.stringify(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmonthly: rows,\n\t\t\t\t\t\t\ttotals,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tnull,\n\t\t\t\t\t\t2,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlogger.box(\n\t\t\t\t`Codex Token Usage Report - Monthly (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`,\n\t\t\t);\n\n\t\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\t\thead: [\n\t\t\t\t\t'Month',\n\t\t\t\t\t'Models',\n\t\t\t\t\t'Input',\n\t\t\t\t\t'Output',\n\t\t\t\t\t'Reasoning',\n\t\t\t\t\t'Cache Read',\n\t\t\t\t\t'Total Tokens',\n\t\t\t\t\t'Cost (USD)',\n\t\t\t\t],\n\t\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\t\tcompactHead: ['Month', 'Models', 'Input', 'Output', 'Cost (USD)'],\n\t\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right'],\n\t\t\t\tcompactThreshold: 100,\n\t\t\t\tforceCompact: ctx.values.compact,\n\t\t\t\tstyle: { head: ['cyan'] },\n\t\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t\t});\n\n\t\t\tconst totalsForDisplay = {\n\t\t\t\tinputTokens: 0,\n\t\t\t\toutputTokens: 0,\n\t\t\t\treasoningTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalTokens: 0,\n\t\t\t\tcostUSD: 0,\n\t\t\t};\n\n\t\t\tfor (const row of rows) {\n\t\t\t\tconst split = splitUsageTokens(row);\n\t\t\t\ttotalsForDisplay.inputTokens += split.inputTokens;\n\t\t\t\ttotalsForDisplay.outputTokens += split.outputTokens;\n\t\t\t\ttotalsForDisplay.reasoningTokens += split.reasoningTokens;\n\t\t\t\ttotalsForDisplay.cacheReadTokens += split.cacheReadTokens;\n\t\t\t\ttotalsForDisplay.totalTokens += row.totalTokens;\n\t\t\t\ttotalsForDisplay.costUSD += row.costUSD;\n\n\t\t\t\ttable.push([\n\t\t\t\t\trow.month,\n\t\t\t\t\tformatModelsDisplayMultiline(formatModelsList(row.models)),\n\t\t\t\t\tformatNumber(split.inputTokens),\n\t\t\t\t\tformatNumber(split.outputTokens),\n\t\t\t\t\tformatNumber(split.reasoningTokens),\n\t\t\t\t\tformatNumber(split.cacheReadTokens),\n\t\t\t\t\tformatNumber(row.totalTokens),\n\t\t\t\t\tformatCurrency(row.costUSD),\n\t\t\t\t]);\n\t\t\t}\n\n\t\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\t\ttable.push([\n\t\t\t\tpc.yellow('Total'),\n\t\t\t\t'',\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.inputTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.outputTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.reasoningTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.totalTokens)),\n\t\t\t\tpc.yellow(formatCurrency(totalsForDisplay.costUSD)),\n\t\t\t]);\n\n\t\t\tlog(table.toString());\n\n\t\t\tif (table.isCompactMode()) {\n\t\t\t\tlogger.info('\\nRunning in Compact Mode');\n\t\t\t\tlogger.info('Expand terminal width to see cache metrics and total tokens');\n\t\t\t}\n\t\t} finally {\n\t\t\tpricingSource[Symbol.dispose]();\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/codex/src/commands/session.ts",
    "content": "import process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { DEFAULT_TIMEZONE } from '../_consts.ts';\nimport { sharedArgs } from '../_shared-args.ts';\nimport { formatModelsList, splitUsageTokens } from '../command-utils.ts';\nimport { loadTokenUsageEvents } from '../data-loader.ts';\nimport {\n\tformatDisplayDate,\n\tformatDisplayDateTime,\n\tnormalizeFilterDate,\n\ttoDateKey,\n} from '../date-utils.ts';\nimport { log, logger } from '../logger.ts';\nimport { CodexPricingSource } from '../pricing.ts';\nimport { buildSessionReport } from '../session-report.ts';\n\nconst TABLE_COLUMN_COUNT = 11;\n\nexport const sessionCommand = define({\n\tname: 'session',\n\tdescription: 'Show Codex token usage grouped by session',\n\targs: sharedArgs,\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\t\tif (jsonOutput) {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\tlet since: string | undefined;\n\t\tlet until: string | undefined;\n\n\t\ttry {\n\t\t\tsince = normalizeFilterDate(ctx.values.since);\n\t\t\tuntil = normalizeFilterDate(ctx.values.until);\n\t\t} catch (error) {\n\t\t\tlogger.error(String(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tconst { events, missingDirectories } = await loadTokenUsageEvents();\n\n\t\tfor (const missing of missingDirectories) {\n\t\t\tlogger.warn(`Codex session directory not found: ${missing}`);\n\t\t}\n\n\t\tif (events.length === 0) {\n\t\t\tlog(\n\t\t\t\tjsonOutput ? JSON.stringify({ sessions: [], totals: null }) : 'No Codex usage data found.',\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tconst pricingSource = new CodexPricingSource({\n\t\t\toffline: ctx.values.offline,\n\t\t});\n\t\ttry {\n\t\t\tconst rows = await buildSessionReport(events, {\n\t\t\t\tpricingSource,\n\t\t\t\ttimezone: ctx.values.timezone,\n\t\t\t\tlocale: ctx.values.locale,\n\t\t\t\tsince,\n\t\t\t\tuntil,\n\t\t\t});\n\n\t\t\tif (rows.length === 0) {\n\t\t\t\tlog(\n\t\t\t\t\tjsonOutput\n\t\t\t\t\t\t? JSON.stringify({ sessions: [], totals: null })\n\t\t\t\t\t\t: 'No Codex usage data found for provided filters.',\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst totals = rows.reduce(\n\t\t\t\t(acc, row) => {\n\t\t\t\t\tacc.inputTokens += row.inputTokens;\n\t\t\t\t\tacc.cachedInputTokens += row.cachedInputTokens;\n\t\t\t\t\tacc.outputTokens += row.outputTokens;\n\t\t\t\t\tacc.reasoningOutputTokens += row.reasoningOutputTokens;\n\t\t\t\t\tacc.totalTokens += row.totalTokens;\n\t\t\t\t\tacc.costUSD += row.costUSD;\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\tcachedInputTokens: 0,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\tcostUSD: 0,\n\t\t\t\t},\n\t\t\t);\n\n\t\t\tif (jsonOutput) {\n\t\t\t\tlog(\n\t\t\t\t\tJSON.stringify(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tsessions: rows,\n\t\t\t\t\t\t\ttotals,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tnull,\n\t\t\t\t\t\t2,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlogger.box(\n\t\t\t\t`Codex Token Usage Report - Sessions (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`,\n\t\t\t);\n\n\t\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\t\thead: [\n\t\t\t\t\t'Date',\n\t\t\t\t\t'Directory',\n\t\t\t\t\t'Session',\n\t\t\t\t\t'Models',\n\t\t\t\t\t'Input',\n\t\t\t\t\t'Output',\n\t\t\t\t\t'Reasoning',\n\t\t\t\t\t'Cache Read',\n\t\t\t\t\t'Total Tokens',\n\t\t\t\t\t'Cost (USD)',\n\t\t\t\t\t'Last Activity',\n\t\t\t\t],\n\t\t\t\tcolAligns: [\n\t\t\t\t\t'left',\n\t\t\t\t\t'left',\n\t\t\t\t\t'left',\n\t\t\t\t\t'left',\n\t\t\t\t\t'right',\n\t\t\t\t\t'right',\n\t\t\t\t\t'right',\n\t\t\t\t\t'right',\n\t\t\t\t\t'right',\n\t\t\t\t\t'right',\n\t\t\t\t\t'left',\n\t\t\t\t],\n\t\t\t\tcompactHead: ['Date', 'Directory', 'Session', 'Input', 'Output', 'Cost (USD)'],\n\t\t\t\tcompactColAligns: ['left', 'left', 'left', 'right', 'right', 'right'],\n\t\t\t\tcompactThreshold: 100,\n\t\t\t\tforceCompact: ctx.values.compact,\n\t\t\t\tstyle: { head: ['cyan'] },\n\t\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t\t});\n\n\t\t\tconst totalsForDisplay = {\n\t\t\t\tinputTokens: 0,\n\t\t\t\toutputTokens: 0,\n\t\t\t\treasoningTokens: 0,\n\t\t\t\tcacheReadTokens: 0,\n\t\t\t\ttotalTokens: 0,\n\t\t\t\tcostUSD: 0,\n\t\t\t};\n\n\t\t\tfor (const row of rows) {\n\t\t\t\tconst split = splitUsageTokens(row);\n\t\t\t\ttotalsForDisplay.inputTokens += split.inputTokens;\n\t\t\t\ttotalsForDisplay.outputTokens += split.outputTokens;\n\t\t\t\ttotalsForDisplay.reasoningTokens += split.reasoningTokens;\n\t\t\t\ttotalsForDisplay.cacheReadTokens += split.cacheReadTokens;\n\t\t\t\ttotalsForDisplay.totalTokens += row.totalTokens;\n\t\t\t\ttotalsForDisplay.costUSD += row.costUSD;\n\n\t\t\t\tconst dateKey = toDateKey(row.lastActivity, ctx.values.timezone);\n\t\t\t\tconst displayDate = formatDisplayDate(dateKey, ctx.values.locale, ctx.values.timezone);\n\t\t\t\tconst directoryDisplay = row.directory === '' ? '-' : row.directory;\n\t\t\t\tconst sessionFile = row.sessionFile;\n\t\t\t\tconst shortSession = sessionFile.length > 8 ? `…${sessionFile.slice(-8)}` : sessionFile;\n\n\t\t\t\ttable.push([\n\t\t\t\t\tdisplayDate,\n\t\t\t\t\tdirectoryDisplay,\n\t\t\t\t\tshortSession,\n\t\t\t\t\tformatModelsDisplayMultiline(formatModelsList(row.models)),\n\t\t\t\t\tformatNumber(split.inputTokens),\n\t\t\t\t\tformatNumber(split.outputTokens),\n\t\t\t\t\tformatNumber(split.reasoningTokens),\n\t\t\t\t\tformatNumber(split.cacheReadTokens),\n\t\t\t\t\tformatNumber(row.totalTokens),\n\t\t\t\t\tformatCurrency(row.costUSD),\n\t\t\t\t\tformatDisplayDateTime(row.lastActivity, ctx.values.locale, ctx.values.timezone),\n\t\t\t\t]);\n\t\t\t}\n\n\t\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\t\ttable.push([\n\t\t\t\t'',\n\t\t\t\t'',\n\t\t\t\tpc.yellow('Total'),\n\t\t\t\t'',\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.inputTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.outputTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.reasoningTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)),\n\t\t\t\tpc.yellow(formatNumber(totalsForDisplay.totalTokens)),\n\t\t\t\tpc.yellow(formatCurrency(totalsForDisplay.costUSD)),\n\t\t\t\t'',\n\t\t\t]);\n\n\t\t\tlog(table.toString());\n\n\t\t\tif (table.isCompactMode()) {\n\t\t\t\tlogger.info('\\nRunning in Compact Mode');\n\t\t\t\tlogger.info(\n\t\t\t\t\t'Expand terminal width to see directories, cache metrics, total tokens, and last activity',\n\t\t\t\t);\n\t\t\t}\n\t\t} finally {\n\t\t\tpricingSource[Symbol.dispose]();\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/codex/src/daily-report.ts",
    "content": "import type {\n\tDailyReportRow,\n\tDailyUsageSummary,\n\tModelPricing,\n\tModelUsage,\n\tPricingSource,\n\tTokenUsageEvent,\n} from './_types.ts';\nimport { formatDisplayDate, isWithinRange, toDateKey } from './date-utils.ts';\nimport { addUsage, calculateCostUSD, createEmptyUsage } from './token-utils.ts';\n\nexport type DailyReportOptions = {\n\ttimezone?: string;\n\tlocale?: string;\n\tsince?: string;\n\tuntil?: string;\n\tpricingSource: PricingSource;\n};\n\nfunction createSummary(date: string, initialTimestamp: string): DailyUsageSummary {\n\treturn {\n\t\tdate,\n\t\tfirstTimestamp: initialTimestamp,\n\t\tinputTokens: 0,\n\t\tcachedInputTokens: 0,\n\t\toutputTokens: 0,\n\t\treasoningOutputTokens: 0,\n\t\ttotalTokens: 0,\n\t\tcostUSD: 0,\n\t\tmodels: new Map(),\n\t};\n}\n\nexport async function buildDailyReport(\n\tevents: TokenUsageEvent[],\n\toptions: DailyReportOptions,\n): Promise<DailyReportRow[]> {\n\tconst timezone = options.timezone;\n\tconst locale = options.locale;\n\tconst since = options.since;\n\tconst until = options.until;\n\tconst pricingSource = options.pricingSource;\n\n\tconst summaries = new Map<string, DailyUsageSummary>();\n\n\tfor (const event of events) {\n\t\tconst modelName = event.model?.trim();\n\t\tif (modelName == null || modelName === '') {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst dateKey = toDateKey(event.timestamp, timezone);\n\t\tif (!isWithinRange(dateKey, since, until)) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst summary = summaries.get(dateKey) ?? createSummary(dateKey, event.timestamp);\n\t\tif (!summaries.has(dateKey)) {\n\t\t\tsummaries.set(dateKey, summary);\n\t\t}\n\n\t\taddUsage(summary, event);\n\t\tconst modelUsage: ModelUsage = summary.models.get(modelName) ?? {\n\t\t\t...createEmptyUsage(),\n\t\t\tisFallback: false,\n\t\t};\n\t\tif (!summary.models.has(modelName)) {\n\t\t\tsummary.models.set(modelName, modelUsage);\n\t\t}\n\t\taddUsage(modelUsage, event);\n\t\tif (event.isFallbackModel === true) {\n\t\t\tmodelUsage.isFallback = true;\n\t\t}\n\t}\n\n\tconst uniqueModels = new Set<string>();\n\tfor (const summary of summaries.values()) {\n\t\tfor (const modelName of summary.models.keys()) {\n\t\t\tuniqueModels.add(modelName);\n\t\t}\n\t}\n\n\tconst modelPricing = new Map<string, Awaited<ReturnType<PricingSource['getPricing']>>>();\n\tfor (const modelName of uniqueModels) {\n\t\tmodelPricing.set(modelName, await pricingSource.getPricing(modelName));\n\t}\n\n\tconst rows: DailyReportRow[] = [];\n\n\tconst sortedSummaries = Array.from(summaries.values()).sort((a, b) =>\n\t\ta.date.localeCompare(b.date),\n\t);\n\tfor (const summary of sortedSummaries) {\n\t\tlet cost = 0;\n\t\tfor (const [modelName, usage] of summary.models) {\n\t\t\tconst pricing = modelPricing.get(modelName);\n\t\t\tif (pricing == null) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tcost += calculateCostUSD(usage, pricing);\n\t\t}\n\t\tsummary.costUSD = cost;\n\n\t\tconst rowModels: Record<string, ModelUsage> = {};\n\t\tfor (const [modelName, usage] of summary.models) {\n\t\t\trowModels[modelName] = { ...usage };\n\t\t}\n\n\t\trows.push({\n\t\t\tdate: formatDisplayDate(summary.date, locale, timezone),\n\t\t\tinputTokens: summary.inputTokens,\n\t\t\tcachedInputTokens: summary.cachedInputTokens,\n\t\t\toutputTokens: summary.outputTokens,\n\t\t\treasoningOutputTokens: summary.reasoningOutputTokens,\n\t\t\ttotalTokens: summary.totalTokens,\n\t\t\tcostUSD: cost,\n\t\t\tmodels: rowModels,\n\t\t});\n\t}\n\n\treturn rows;\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('buildDailyReport', () => {\n\t\tit('aggregates events by day and calculates costs', async () => {\n\t\t\tconst pricing = new Map([\n\t\t\t\t[\n\t\t\t\t\t'gpt-5',\n\t\t\t\t\t{ inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 },\n\t\t\t\t],\n\t\t\t\t[\n\t\t\t\t\t'gpt-5-mini',\n\t\t\t\t\t{ inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 },\n\t\t\t\t],\n\t\t\t]);\n\t\t\tconst stubPricingSource: PricingSource = {\n\t\t\t\tasync getPricing(model: string): Promise<ModelPricing> {\n\t\t\t\t\tconst value = pricing.get(model);\n\t\t\t\t\tif (value == null) {\n\t\t\t\t\t\tthrow new Error(`Missing pricing for ${model}`);\n\t\t\t\t\t}\n\t\t\t\t\treturn value;\n\t\t\t\t},\n\t\t\t};\n\t\t\tconst report = await buildDailyReport(\n\t\t\t\t[\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-1',\n\t\t\t\t\t\ttimestamp: '2025-09-11T03:00:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\tinputTokens: 1_000,\n\t\t\t\t\t\tcachedInputTokens: 200,\n\t\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\t\ttotalTokens: 1_500,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-1',\n\t\t\t\t\t\ttimestamp: '2025-09-11T05:00:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5-mini',\n\t\t\t\t\t\tinputTokens: 400,\n\t\t\t\t\t\tcachedInputTokens: 100,\n\t\t\t\t\t\toutputTokens: 200,\n\t\t\t\t\t\treasoningOutputTokens: 50,\n\t\t\t\t\t\ttotalTokens: 750,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-2',\n\t\t\t\t\t\ttimestamp: '2025-09-12T01:00:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\tinputTokens: 2_000,\n\t\t\t\t\t\tcachedInputTokens: 0,\n\t\t\t\t\t\toutputTokens: 800,\n\t\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\t\ttotalTokens: 2_800,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\t{\n\t\t\t\t\tpricingSource: stubPricingSource,\n\t\t\t\t\tsince: '2025-09-11',\n\t\t\t\t\tuntil: '2025-09-12',\n\t\t\t\t},\n\t\t\t);\n\n\t\t\texpect(report).toHaveLength(2);\n\t\t\tconst first = report[0]!;\n\t\t\texpect(first.date).toContain('2025');\n\t\t\texpect(first.inputTokens).toBe(1_400);\n\t\t\texpect(first.cachedInputTokens).toBe(300);\n\t\t\texpect(first.outputTokens).toBe(700);\n\t\t\texpect(first.reasoningOutputTokens).toBe(50);\n\t\t\t// gpt-5: 800 non-cached input @ 1.25, 200 cached @ 0.125, 500 output @ 10\n\t\t\t// gpt-5-mini: 300 non-cached input @ 0.6, 100 cached @ 0.06, 200 output @ 2 (reasoning already included)\n\t\t\tconst expectedCost =\n\t\t\t\t(800 / 1_000_000) * 1.25 +\n\t\t\t\t(200 / 1_000_000) * 0.125 +\n\t\t\t\t(500 / 1_000_000) * 10 +\n\t\t\t\t(300 / 1_000_000) * 0.6 +\n\t\t\t\t(100 / 1_000_000) * 0.06 +\n\t\t\t\t(200 / 1_000_000) * 2;\n\t\t\texpect(first.costUSD).toBeCloseTo(expectedCost, 10);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/codex/src/data-loader.ts",
    "content": "import type { TokenUsageDelta, TokenUsageEvent } from './_types.ts';\nimport { readFile, stat } from 'node:fs/promises';\nimport path from 'node:path';\nimport process from 'node:process';\nimport { Result } from '@praha/byethrow';\nimport { createFixture } from 'fs-fixture';\nimport { glob } from 'tinyglobby';\nimport * as v from 'valibot';\nimport {\n\tCODEX_HOME_ENV,\n\tDEFAULT_CODEX_DIR,\n\tDEFAULT_SESSION_SUBDIR,\n\tSESSION_GLOB,\n} from './_consts.ts';\nimport { logger } from './logger.ts';\n\ntype RawUsage = {\n\tinput_tokens: number;\n\tcached_input_tokens: number;\n\toutput_tokens: number;\n\treasoning_output_tokens: number;\n\ttotal_tokens: number;\n};\n\nfunction ensureNumber(value: unknown): number {\n\treturn typeof value === 'number' && Number.isFinite(value) ? value : 0;\n}\n\n/**\n * Normalize Codex `token_count` payloads into a predictable shape.\n *\n * Codex reports four counters:\n *   - input_tokens\n *   - cached_input_tokens (a.k.a cache_read_input_tokens)\n *   - output_tokens (this already includes any reasoning charge)\n *   - reasoning_output_tokens (informational only)\n *\n * Modern JSONL entries also provide `total_tokens`, but legacy ones may omit it.\n * When that happens we mirror Codex' billing behavior and synthesize\n * `input + output` (reasoning is treated as part of output, not an extra charge).\n */\nfunction normalizeRawUsage(value: unknown): RawUsage | null {\n\tif (value == null || typeof value !== 'object') {\n\t\treturn null;\n\t}\n\n\tconst record = value as Record<string, unknown>;\n\tconst input = ensureNumber(record.input_tokens);\n\tconst cached = ensureNumber(record.cached_input_tokens ?? record.cache_read_input_tokens);\n\tconst output = ensureNumber(record.output_tokens);\n\tconst reasoning = ensureNumber(record.reasoning_output_tokens);\n\tconst total = ensureNumber(record.total_tokens);\n\n\treturn {\n\t\tinput_tokens: input,\n\t\tcached_input_tokens: cached,\n\t\toutput_tokens: output,\n\t\treasoning_output_tokens: reasoning,\n\t\t// LiteLLM pricing treats reasoning tokens as part of the normal output price. Codex\n\t\t// includes them as a separate field but does not add them to total_tokens, so when we\n\t\t// have to synthesize a total (legacy logs), we mirror that behavior with input+output.\n\t\ttotal_tokens: total > 0 ? total : input + output,\n\t};\n}\n\nfunction subtractRawUsage(current: RawUsage, previous: RawUsage | null): RawUsage {\n\treturn {\n\t\tinput_tokens: Math.max(current.input_tokens - (previous?.input_tokens ?? 0), 0),\n\t\tcached_input_tokens: Math.max(\n\t\t\tcurrent.cached_input_tokens - (previous?.cached_input_tokens ?? 0),\n\t\t\t0,\n\t\t),\n\t\toutput_tokens: Math.max(current.output_tokens - (previous?.output_tokens ?? 0), 0),\n\t\treasoning_output_tokens: Math.max(\n\t\t\tcurrent.reasoning_output_tokens - (previous?.reasoning_output_tokens ?? 0),\n\t\t\t0,\n\t\t),\n\t\ttotal_tokens: Math.max(current.total_tokens - (previous?.total_tokens ?? 0), 0),\n\t};\n}\n\n/**\n * Convert cumulative usage into a per-event delta.\n *\n * Codex includes the cost of reasoning inside `output_tokens`. The\n * `reasoning_output_tokens` field is useful for display/debug purposes, but we\n * must not add it to the billable output again. For legacy totals we therefore\n * fallback to `input + output`.\n */\nfunction convertToDelta(raw: RawUsage): TokenUsageDelta {\n\tconst total = raw.total_tokens > 0 ? raw.total_tokens : raw.input_tokens + raw.output_tokens;\n\n\tconst cached = Math.min(raw.cached_input_tokens, raw.input_tokens);\n\n\treturn {\n\t\tinputTokens: raw.input_tokens,\n\t\tcachedInputTokens: cached,\n\t\toutputTokens: raw.output_tokens,\n\t\treasoningOutputTokens: raw.reasoning_output_tokens,\n\t\ttotalTokens: total,\n\t};\n}\n\nconst recordSchema = v.record(v.string(), v.unknown());\nconst LEGACY_FALLBACK_MODEL = 'gpt-5';\n\nconst entrySchema = v.object({\n\ttype: v.string(),\n\tpayload: v.optional(v.unknown()),\n\ttimestamp: v.optional(v.string()),\n});\n\nconst tokenCountPayloadSchema = v.object({\n\ttype: v.literal('token_count'),\n\tinfo: v.optional(recordSchema),\n});\n\nfunction extractModel(value: unknown): string | undefined {\n\tconst parsed = v.safeParse(recordSchema, value);\n\tif (!parsed.success) {\n\t\treturn undefined;\n\t}\n\n\tconst payload = parsed.output;\n\n\tconst infoCandidate = payload.info;\n\tif (infoCandidate != null) {\n\t\tconst infoParsed = v.safeParse(recordSchema, infoCandidate);\n\t\tif (infoParsed.success) {\n\t\t\tconst info = infoParsed.output;\n\t\t\tconst directCandidates = [info.model, info.model_name];\n\t\t\tfor (const candidate of directCandidates) {\n\t\t\t\tconst model = asNonEmptyString(candidate);\n\t\t\t\tif (model != null) {\n\t\t\t\t\treturn model;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (info.metadata != null) {\n\t\t\t\tconst metadataParsed = v.safeParse(recordSchema, info.metadata);\n\t\t\t\tif (metadataParsed.success) {\n\t\t\t\t\tconst model = asNonEmptyString(metadataParsed.output.model);\n\t\t\t\t\tif (model != null) {\n\t\t\t\t\t\treturn model;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tconst fallbackModel = asNonEmptyString(payload.model);\n\tif (fallbackModel != null) {\n\t\treturn fallbackModel;\n\t}\n\n\tif (payload.metadata != null) {\n\t\tconst metadataParsed = v.safeParse(recordSchema, payload.metadata);\n\t\tif (metadataParsed.success) {\n\t\t\tconst model = asNonEmptyString(metadataParsed.output.model);\n\t\t\tif (model != null) {\n\t\t\t\treturn model;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn undefined;\n}\n\nfunction asNonEmptyString(value: unknown): string | undefined {\n\tif (typeof value !== 'string') {\n\t\treturn undefined;\n\t}\n\n\tconst trimmed = value.trim();\n\treturn trimmed === '' ? undefined : trimmed;\n}\n\nexport type LoadOptions = {\n\tsessionDirs?: string[];\n};\n\nexport type LoadResult = {\n\tevents: TokenUsageEvent[];\n\tmissingDirectories: string[];\n};\n\nexport async function loadTokenUsageEvents(options: LoadOptions = {}): Promise<LoadResult> {\n\tconst providedDirs =\n\t\toptions.sessionDirs != null && options.sessionDirs.length > 0\n\t\t\t? options.sessionDirs.map((dir) => path.resolve(dir))\n\t\t\t: undefined;\n\n\tconst codexHomeEnv = process.env[CODEX_HOME_ENV]?.trim();\n\tconst codexHome =\n\t\tcodexHomeEnv != null && codexHomeEnv !== '' ? path.resolve(codexHomeEnv) : DEFAULT_CODEX_DIR;\n\tconst defaultSessionsDir = path.join(codexHome, DEFAULT_SESSION_SUBDIR);\n\tconst sessionDirs = providedDirs ?? [defaultSessionsDir];\n\n\tconst events: TokenUsageEvent[] = [];\n\tconst missingDirectories: string[] = [];\n\n\tfor (const dir of sessionDirs) {\n\t\tconst directoryPath = path.resolve(dir);\n\t\tconst statResult = await Result.try({\n\t\t\ttry: stat(directoryPath),\n\t\t\tcatch: (error) => error,\n\t\t});\n\n\t\tif (Result.isFailure(statResult)) {\n\t\t\tmissingDirectories.push(directoryPath);\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (!statResult.value.isDirectory()) {\n\t\t\tmissingDirectories.push(directoryPath);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst files = await glob(SESSION_GLOB, {\n\t\t\tcwd: directoryPath,\n\t\t\tabsolute: true,\n\t\t});\n\n\t\tfor (const file of files) {\n\t\t\tconst relativeSessionPath = path.relative(directoryPath, file);\n\t\t\tconst normalizedSessionPath = relativeSessionPath.split(path.sep).join('/');\n\t\t\tconst sessionId = normalizedSessionPath.replace(/\\.jsonl$/i, '');\n\t\t\tconst fileContentResult = await Result.try({\n\t\t\t\ttry: readFile(file, 'utf8'),\n\t\t\t\tcatch: (error) => error,\n\t\t\t});\n\n\t\t\tif (Result.isFailure(fileContentResult)) {\n\t\t\t\tlogger.debug('Failed to read Codex session file', fileContentResult.error);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tlet previousTotals: RawUsage | null = null;\n\t\t\tlet currentModel: string | undefined;\n\t\t\tlet currentModelIsFallback = false;\n\t\t\tlet legacyFallbackUsed = false;\n\t\t\tconst lines = fileContentResult.value.split(/\\r?\\n/);\n\t\t\tfor (const line of lines) {\n\t\t\t\tconst trimmed = line.trim();\n\t\t\t\tif (trimmed === '') {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst parseLine = Result.try({\n\t\t\t\t\ttry: () => JSON.parse(trimmed) as unknown,\n\t\t\t\t\tcatch: (error) => error,\n\t\t\t\t});\n\t\t\t\tconst parsedResult = parseLine();\n\n\t\t\t\tif (Result.isFailure(parsedResult)) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst entryParse = v.safeParse(entrySchema, parsedResult.value);\n\t\t\t\tif (!entryParse.success) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst { type: entryType, payload, timestamp } = entryParse.output;\n\n\t\t\t\tif (entryType === 'turn_context') {\n\t\t\t\t\tconst contextPayload = v.safeParse(recordSchema, payload ?? null);\n\t\t\t\t\tif (contextPayload.success) {\n\t\t\t\t\t\tconst contextModel = extractModel(contextPayload.output);\n\t\t\t\t\t\tif (contextModel != null) {\n\t\t\t\t\t\t\tcurrentModel = contextModel;\n\t\t\t\t\t\t\tcurrentModelIsFallback = false;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif (entryType !== 'event_msg') {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst tokenPayloadResult = v.safeParse(tokenCountPayloadSchema, payload ?? undefined);\n\t\t\t\tif (!tokenPayloadResult.success) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif (timestamp == null) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst info = tokenPayloadResult.output.info;\n\t\t\t\tconst lastUsage = normalizeRawUsage(info?.last_token_usage);\n\t\t\t\tconst totalUsage = normalizeRawUsage(info?.total_token_usage);\n\n\t\t\t\tlet raw = lastUsage;\n\t\t\t\tif (raw == null && totalUsage != null) {\n\t\t\t\t\traw = subtractRawUsage(totalUsage, previousTotals);\n\t\t\t\t}\n\n\t\t\t\tif (totalUsage != null) {\n\t\t\t\t\tpreviousTotals = totalUsage;\n\t\t\t\t}\n\n\t\t\t\tif (raw == null) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst delta = convertToDelta(raw);\n\t\t\t\tif (\n\t\t\t\t\tdelta.inputTokens === 0 &&\n\t\t\t\t\tdelta.cachedInputTokens === 0 &&\n\t\t\t\t\tdelta.outputTokens === 0 &&\n\t\t\t\t\tdelta.reasoningOutputTokens === 0\n\t\t\t\t) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst payloadRecordResult = v.safeParse(recordSchema, payload ?? undefined);\n\t\t\t\tconst extractionSource = payloadRecordResult.success\n\t\t\t\t\t? Object.assign({}, payloadRecordResult.output, { info })\n\t\t\t\t\t: { info };\n\t\t\t\tconst extractedModel = extractModel(extractionSource);\n\t\t\t\tlet isFallbackModel = false;\n\t\t\t\tif (extractedModel != null) {\n\t\t\t\t\tcurrentModel = extractedModel;\n\t\t\t\t\tcurrentModelIsFallback = false;\n\t\t\t\t}\n\n\t\t\t\tlet model = extractedModel ?? currentModel;\n\t\t\t\tif (model == null) {\n\t\t\t\t\tmodel = LEGACY_FALLBACK_MODEL;\n\t\t\t\t\tisFallbackModel = true;\n\t\t\t\t\tlegacyFallbackUsed = true;\n\t\t\t\t\tcurrentModel = model;\n\t\t\t\t\tcurrentModelIsFallback = true;\n\t\t\t\t} else if (extractedModel == null && currentModelIsFallback) {\n\t\t\t\t\tisFallbackModel = true;\n\t\t\t\t}\n\n\t\t\t\tconst event: TokenUsageEvent = {\n\t\t\t\t\tsessionId,\n\t\t\t\t\ttimestamp,\n\t\t\t\t\tmodel,\n\t\t\t\t\tinputTokens: delta.inputTokens,\n\t\t\t\t\tcachedInputTokens: delta.cachedInputTokens,\n\t\t\t\t\toutputTokens: delta.outputTokens,\n\t\t\t\t\treasoningOutputTokens: delta.reasoningOutputTokens,\n\t\t\t\t\ttotalTokens: delta.totalTokens,\n\t\t\t\t};\n\n\t\t\t\tif (isFallbackModel) {\n\t\t\t\t\t// Surface the fallback so both table + JSON outputs can annotate pricing that was\n\t\t\t\t\t// inferred rather than sourced from the log metadata.\n\t\t\t\t\tevent.isFallbackModel = true;\n\t\t\t\t}\n\n\t\t\t\tevents.push(event);\n\t\t\t}\n\n\t\t\tif (legacyFallbackUsed) {\n\t\t\t\tlogger.debug('Legacy Codex session lacked model metadata; applied fallback', {\n\t\t\t\t\tfile,\n\t\t\t\t\tmodel: LEGACY_FALLBACK_MODEL,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tevents.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());\n\n\treturn { events, missingDirectories };\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('loadTokenUsageEvents', () => {\n\t\tit('parses token_count events and skips entries without model metadata', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tsessions: {\n\t\t\t\t\t'project-1.jsonl': [\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2025-09-11T18:25:30.000Z',\n\t\t\t\t\t\t\ttype: 'turn_context',\n\t\t\t\t\t\t\tpayload: {\n\t\t\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2025-09-11T18:25:40.670Z',\n\t\t\t\t\t\t\ttype: 'event_msg',\n\t\t\t\t\t\t\tpayload: {\n\t\t\t\t\t\t\t\ttype: 'token_count',\n\t\t\t\t\t\t\t\tinfo: {\n\t\t\t\t\t\t\t\t\ttotal_token_usage: {\n\t\t\t\t\t\t\t\t\t\tinput_tokens: 1_200,\n\t\t\t\t\t\t\t\t\t\tcached_input_tokens: 200,\n\t\t\t\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t\t\t\t\treasoning_output_tokens: 0,\n\t\t\t\t\t\t\t\t\t\ttotal_tokens: 1_700,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tlast_token_usage: {\n\t\t\t\t\t\t\t\t\t\tinput_tokens: 1_200,\n\t\t\t\t\t\t\t\t\t\tcached_input_tokens: 200,\n\t\t\t\t\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\t\t\t\t\treasoning_output_tokens: 0,\n\t\t\t\t\t\t\t\t\t\ttotal_tokens: 1_700,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2025-09-11T18:40:00.000Z',\n\t\t\t\t\t\t\ttype: 'turn_context',\n\t\t\t\t\t\t\tpayload: {\n\t\t\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2025-09-12T00:00:00.000Z',\n\t\t\t\t\t\t\ttype: 'event_msg',\n\t\t\t\t\t\t\tpayload: {\n\t\t\t\t\t\t\t\ttype: 'token_count',\n\t\t\t\t\t\t\t\tinfo: {\n\t\t\t\t\t\t\t\t\ttotal_token_usage: {\n\t\t\t\t\t\t\t\t\t\tinput_tokens: 2_000,\n\t\t\t\t\t\t\t\t\t\tcached_input_tokens: 300,\n\t\t\t\t\t\t\t\t\t\toutput_tokens: 800,\n\t\t\t\t\t\t\t\t\t\treasoning_output_tokens: 0,\n\t\t\t\t\t\t\t\t\t\ttotal_tokens: 2_800,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t].join('\\n'),\n\t\t\t\t},\n\t\t\t});\n\n\t\t\texpect(await fixture.exists('sessions/project-1.jsonl')).toBe(true);\n\n\t\t\tconst { events, missingDirectories } = await loadTokenUsageEvents({\n\t\t\t\tsessionDirs: [fixture.getPath('sessions')],\n\t\t\t});\n\t\t\texpect(missingDirectories).toEqual([]);\n\n\t\t\texpect(events).toHaveLength(2);\n\t\t\tconst first = events[0]!;\n\t\t\texpect(first.model).toBe('gpt-5');\n\t\t\texpect(first.inputTokens).toBe(1_200);\n\t\t\texpect(first.cachedInputTokens).toBe(200);\n\t\t\tconst second = events[1]!;\n\t\t\texpect(second.model).toBe('gpt-5');\n\t\t\texpect(second.inputTokens).toBe(800);\n\t\t\texpect(second.cachedInputTokens).toBe(100);\n\t\t});\n\n\t\tit('falls back to legacy model when metadata is missing entirely', async () => {\n\t\t\tawait using fixture = await createFixture({\n\t\t\t\tsessions: {\n\t\t\t\t\t'legacy.jsonl': [\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\ttimestamp: '2025-09-15T13:00:00.000Z',\n\t\t\t\t\t\t\ttype: 'event_msg',\n\t\t\t\t\t\t\tpayload: {\n\t\t\t\t\t\t\t\ttype: 'token_count',\n\t\t\t\t\t\t\t\tinfo: {\n\t\t\t\t\t\t\t\t\ttotal_token_usage: {\n\t\t\t\t\t\t\t\t\t\tinput_tokens: 5_000,\n\t\t\t\t\t\t\t\t\t\tcached_input_tokens: 0,\n\t\t\t\t\t\t\t\t\t\toutput_tokens: 1_000,\n\t\t\t\t\t\t\t\t\t\treasoning_output_tokens: 0,\n\t\t\t\t\t\t\t\t\t\ttotal_tokens: 6_000,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t].join('\\n'),\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst { events } = await loadTokenUsageEvents({\n\t\t\t\tsessionDirs: [fixture.getPath('sessions')],\n\t\t\t});\n\t\t\texpect(events).toHaveLength(1);\n\t\t\texpect(events[0]!.model).toBe('gpt-5');\n\t\t\texpect(events[0]!.isFallbackModel).toBe(true);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/codex/src/date-utils.ts",
    "content": "function safeTimeZone(timezone?: string): string {\n\tif (timezone == null || timezone.trim() === '') {\n\t\treturn Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';\n\t}\n\n\ttry {\n\t\t// Validate timezone by creating a formatter\n\t\tIntl.DateTimeFormat('en-US', { timeZone: timezone });\n\t\treturn timezone;\n\t} catch {\n\t\treturn 'UTC';\n\t}\n}\n\nexport function toDateKey(timestamp: string, timezone?: string): string {\n\tconst tz = safeTimeZone(timezone);\n\tconst date = new Date(timestamp);\n\tconst formatter = new Intl.DateTimeFormat('en-CA', {\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t\ttimeZone: tz,\n\t});\n\treturn formatter.format(date);\n}\n\nexport function normalizeFilterDate(value?: string): string | undefined {\n\tif (value == null) {\n\t\treturn undefined;\n\t}\n\n\tconst compact = value.replaceAll('-', '').trim();\n\tif (!/^\\d{8}$/.test(compact)) {\n\t\tthrow new Error(`Invalid date format: ${value}. Expected YYYYMMDD or YYYY-MM-DD.`);\n\t}\n\n\treturn `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`;\n}\n\nexport function isWithinRange(dateKey: string, since?: string, until?: string): boolean {\n\tconst value = dateKey.replaceAll('-', '');\n\tconst sinceValue = since?.replaceAll('-', '');\n\tconst untilValue = until?.replaceAll('-', '');\n\n\tif (sinceValue != null && value < sinceValue) {\n\t\treturn false;\n\t}\n\n\tif (untilValue != null && value > untilValue) {\n\t\treturn false;\n\t}\n\n\treturn true;\n}\n\nexport function formatDisplayDate(dateKey: string, locale?: string, _timezone?: string): string {\n\t// dateKey is already computed for the target timezone via toDateKey().\n\t// Treat it as a plain calendar date and avoid shifting it by applying a timezone.\n\tconst [yearStr = '0', monthStr = '1', dayStr = '1'] = dateKey.split('-');\n\tconst year = Number.parseInt(yearStr, 10);\n\tconst month = Number.parseInt(monthStr, 10);\n\tconst day = Number.parseInt(dayStr, 10);\n\tconst date = new Date(Date.UTC(year, month - 1, day));\n\tconst formatter = new Intl.DateTimeFormat(locale ?? 'en-US', {\n\t\tyear: 'numeric',\n\t\tmonth: 'short',\n\t\tday: '2-digit',\n\t\ttimeZone: 'UTC',\n\t});\n\treturn formatter.format(date);\n}\n\nexport function toMonthKey(timestamp: string, timezone?: string): string {\n\tconst tz = safeTimeZone(timezone);\n\tconst date = new Date(timestamp);\n\tconst formatter = new Intl.DateTimeFormat('en-CA', {\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\ttimeZone: tz,\n\t});\n\tconst [year, month] = formatter.format(date).split('-');\n\treturn `${year}-${month}`;\n}\n\nexport function formatDisplayMonth(monthKey: string, locale?: string, _timezone?: string): string {\n\t// monthKey is already derived in the target timezone via toMonthKey().\n\t// Render it as a calendar month without shifting by timezone.\n\tconst [yearStr = '0', monthStr = '1'] = monthKey.split('-');\n\tconst year = Number.parseInt(yearStr, 10);\n\tconst month = Number.parseInt(monthStr, 10);\n\tconst date = new Date(Date.UTC(year, month - 1, 1));\n\tconst formatter = new Intl.DateTimeFormat(locale ?? 'en-US', {\n\t\tyear: 'numeric',\n\t\tmonth: 'short',\n\t\ttimeZone: 'UTC',\n\t});\n\treturn formatter.format(date);\n}\n\nexport function formatDisplayDateTime(\n\ttimestamp: string,\n\tlocale?: string,\n\ttimezone?: string,\n): string {\n\tconst tz = safeTimeZone(timezone);\n\tconst date = new Date(timestamp);\n\tconst formatter = new Intl.DateTimeFormat(locale ?? 'en-US', {\n\t\tdateStyle: 'short',\n\t\ttimeStyle: 'short',\n\t\ttimeZone: tz,\n\t});\n\treturn formatter.format(date);\n}\n"
  },
  {
    "path": "apps/codex/src/index.ts",
    "content": "#!/usr/bin/env node\n\nimport { run } from './run.ts';\n\n// eslint-disable-next-line antfu/no-top-level-await\nawait run();\n"
  },
  {
    "path": "apps/codex/src/logger.ts",
    "content": "import { createLogger, log as internalLog } from '@ccusage/internal/logger';\n\nimport { name } from '../package.json';\n\nexport const logger = createLogger(name);\n\nexport const log = internalLog;\n"
  },
  {
    "path": "apps/codex/src/monthly-report.ts",
    "content": "import type {\n\tModelPricing,\n\tModelUsage,\n\tMonthlyReportRow,\n\tMonthlyUsageSummary,\n\tPricingSource,\n\tTokenUsageEvent,\n} from './_types.ts';\nimport { formatDisplayMonth, isWithinRange, toDateKey, toMonthKey } from './date-utils.ts';\nimport { addUsage, calculateCostUSD, createEmptyUsage } from './token-utils.ts';\n\nexport type MonthlyReportOptions = {\n\ttimezone?: string;\n\tlocale?: string;\n\tsince?: string;\n\tuntil?: string;\n\tpricingSource: PricingSource;\n};\n\nfunction createSummary(month: string, initialTimestamp: string): MonthlyUsageSummary {\n\treturn {\n\t\tmonth,\n\t\tfirstTimestamp: initialTimestamp,\n\t\tinputTokens: 0,\n\t\tcachedInputTokens: 0,\n\t\toutputTokens: 0,\n\t\treasoningOutputTokens: 0,\n\t\ttotalTokens: 0,\n\t\tcostUSD: 0,\n\t\tmodels: new Map(),\n\t};\n}\n\nexport async function buildMonthlyReport(\n\tevents: TokenUsageEvent[],\n\toptions: MonthlyReportOptions,\n): Promise<MonthlyReportRow[]> {\n\tconst timezone = options.timezone;\n\tconst locale = options.locale;\n\tconst since = options.since;\n\tconst until = options.until;\n\tconst pricingSource = options.pricingSource;\n\n\tconst summaries = new Map<string, MonthlyUsageSummary>();\n\n\tfor (const event of events) {\n\t\tconst modelName = event.model?.trim();\n\t\tif (modelName == null || modelName === '') {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst dateKey = toDateKey(event.timestamp, timezone);\n\t\tif (!isWithinRange(dateKey, since, until)) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst monthKey = toMonthKey(event.timestamp, timezone);\n\t\tconst summary = summaries.get(monthKey) ?? createSummary(monthKey, event.timestamp);\n\t\tif (!summaries.has(monthKey)) {\n\t\t\tsummaries.set(monthKey, summary);\n\t\t}\n\n\t\taddUsage(summary, event);\n\t\tconst modelUsage: ModelUsage = summary.models.get(modelName) ?? {\n\t\t\t...createEmptyUsage(),\n\t\t\tisFallback: false,\n\t\t};\n\t\tif (!summary.models.has(modelName)) {\n\t\t\tsummary.models.set(modelName, modelUsage);\n\t\t}\n\t\taddUsage(modelUsage, event);\n\t\tif (event.isFallbackModel === true) {\n\t\t\tmodelUsage.isFallback = true;\n\t\t}\n\t}\n\n\tconst uniqueModels = new Set<string>();\n\tfor (const summary of summaries.values()) {\n\t\tfor (const modelName of summary.models.keys()) {\n\t\t\tuniqueModels.add(modelName);\n\t\t}\n\t}\n\n\tconst modelPricing = new Map<string, Awaited<ReturnType<PricingSource['getPricing']>>>();\n\tfor (const modelName of uniqueModels) {\n\t\tmodelPricing.set(modelName, await pricingSource.getPricing(modelName));\n\t}\n\n\tconst rows: MonthlyReportRow[] = [];\n\n\tconst sortedSummaries = Array.from(summaries.values()).sort((a, b) =>\n\t\ta.month.localeCompare(b.month),\n\t);\n\tfor (const summary of sortedSummaries) {\n\t\tlet cost = 0;\n\t\tfor (const [modelName, usage] of summary.models) {\n\t\t\tconst pricing = modelPricing.get(modelName);\n\t\t\tif (pricing == null) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tcost += calculateCostUSD(usage, pricing);\n\t\t}\n\t\tsummary.costUSD = cost;\n\n\t\tconst rowModels: Record<string, ModelUsage> = {};\n\t\tfor (const [modelName, usage] of summary.models) {\n\t\t\trowModels[modelName] = { ...usage };\n\t\t}\n\n\t\trows.push({\n\t\t\tmonth: formatDisplayMonth(summary.month, locale, timezone),\n\t\t\tinputTokens: summary.inputTokens,\n\t\t\tcachedInputTokens: summary.cachedInputTokens,\n\t\t\toutputTokens: summary.outputTokens,\n\t\t\treasoningOutputTokens: summary.reasoningOutputTokens,\n\t\t\ttotalTokens: summary.totalTokens,\n\t\t\tcostUSD: cost,\n\t\t\tmodels: rowModels,\n\t\t});\n\t}\n\n\treturn rows;\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('buildMonthlyReport', () => {\n\t\tit('aggregates events by month and calculates costs', async () => {\n\t\t\tconst pricing = new Map([\n\t\t\t\t[\n\t\t\t\t\t'gpt-5',\n\t\t\t\t\t{ inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 },\n\t\t\t\t],\n\t\t\t\t[\n\t\t\t\t\t'gpt-5-mini',\n\t\t\t\t\t{ inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 },\n\t\t\t\t],\n\t\t\t]);\n\t\t\tconst stubPricingSource: PricingSource = {\n\t\t\t\tasync getPricing(model: string): Promise<ModelPricing> {\n\t\t\t\t\tconst value = pricing.get(model);\n\t\t\t\t\tif (value == null) {\n\t\t\t\t\t\tthrow new Error(`Missing pricing for ${model}`);\n\t\t\t\t\t}\n\t\t\t\t\treturn value;\n\t\t\t\t},\n\t\t\t};\n\t\t\tconst report = await buildMonthlyReport(\n\t\t\t\t[\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-1',\n\t\t\t\t\t\ttimestamp: '2025-08-11T03:00:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\tinputTokens: 1_000,\n\t\t\t\t\t\tcachedInputTokens: 200,\n\t\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\t\ttotalTokens: 1_500,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-1',\n\t\t\t\t\t\ttimestamp: '2025-08-20T05:00:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5-mini',\n\t\t\t\t\t\tinputTokens: 400,\n\t\t\t\t\t\tcachedInputTokens: 100,\n\t\t\t\t\t\toutputTokens: 200,\n\t\t\t\t\t\treasoningOutputTokens: 50,\n\t\t\t\t\t\ttotalTokens: 750,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-2',\n\t\t\t\t\t\ttimestamp: '2025-09-12T01:00:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\tinputTokens: 2_000,\n\t\t\t\t\t\tcachedInputTokens: 0,\n\t\t\t\t\t\toutputTokens: 800,\n\t\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\t\ttotalTokens: 2_800,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\t{\n\t\t\t\t\tpricingSource: stubPricingSource,\n\t\t\t\t\tsince: '2025-08-01',\n\t\t\t\t\tuntil: '2025-09-30',\n\t\t\t\t},\n\t\t\t);\n\n\t\t\texpect(report).toHaveLength(2);\n\t\t\tconst first = report[0]!;\n\t\t\texpect(first.inputTokens).toBe(1_400);\n\t\t\texpect(first.cachedInputTokens).toBe(300);\n\t\t\texpect(first.outputTokens).toBe(700);\n\t\t\texpect(first.reasoningOutputTokens).toBe(50);\n\t\t\t// gpt-5: 800 non-cached input @ 1.25, 200 cached @ 0.125, 500 output @ 10\n\t\t\t// gpt-5-mini: 300 non-cached input @ 0.6, 100 cached @ 0.06, 200 output @ 2 (reasoning already included)\n\t\t\tconst expectedCost =\n\t\t\t\t(800 / 1_000_000) * 1.25 +\n\t\t\t\t(200 / 1_000_000) * 0.125 +\n\t\t\t\t(500 / 1_000_000) * 10 +\n\t\t\t\t(300 / 1_000_000) * 0.6 +\n\t\t\t\t(100 / 1_000_000) * 0.06 +\n\t\t\t\t(200 / 1_000_000) * 2;\n\t\t\texpect(first.costUSD).toBeCloseTo(expectedCost, 10);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/codex/src/pricing.ts",
    "content": "import type { LiteLLMModelPricing } from '@ccusage/internal/pricing';\nimport type { ModelPricing, PricingSource } from './_types.ts';\nimport { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';\nimport { Result } from '@praha/byethrow';\nimport { MILLION } from './_consts.ts';\nimport { prefetchCodexPricing } from './_macro.ts' with { type: 'macro' };\nimport { logger } from './logger.ts';\n\nconst CODEX_PROVIDER_PREFIXES = ['openai/', 'azure/', 'openrouter/openai/'];\nconst CODEX_MODEL_ALIASES_MAP = new Map<string, string>([\n\t['gpt-5-codex', 'gpt-5'],\n\t['gpt-5.3-codex', 'gpt-5.2-codex'],\n]);\nconst FREE_MODEL_PRICING = {\n\tinputCostPerMToken: 0,\n\tcachedInputCostPerMToken: 0,\n\toutputCostPerMToken: 0,\n} as const satisfies ModelPricing;\n\nfunction isOpenRouterFreeModel(model: string): boolean {\n\tconst normalized = model.trim().toLowerCase();\n\tif (normalized === 'openrouter/free') {\n\t\treturn true;\n\t}\n\n\treturn normalized.startsWith('openrouter/') && normalized.endsWith(':free');\n}\n\nfunction hasNonZeroTokenPricing(pricing: LiteLLMModelPricing): boolean {\n\treturn (\n\t\t(pricing.input_cost_per_token ?? 0) > 0 ||\n\t\t(pricing.output_cost_per_token ?? 0) > 0 ||\n\t\t(pricing.cache_read_input_token_cost ?? 0) > 0\n\t);\n}\n\nfunction toPerMillion(value: number | undefined, fallback?: number): number {\n\tconst perToken = value ?? fallback ?? 0;\n\treturn perToken * MILLION;\n}\n\nexport type CodexPricingSourceOptions = {\n\toffline?: boolean;\n\tofflineLoader?: () => Promise<Record<string, LiteLLMModelPricing>>;\n};\n\nconst PREFETCHED_CODEX_PRICING = prefetchCodexPricing();\n\nexport class CodexPricingSource implements PricingSource, Disposable {\n\tprivate readonly fetcher: LiteLLMPricingFetcher;\n\n\tconstructor(options: CodexPricingSourceOptions = {}) {\n\t\tthis.fetcher = new LiteLLMPricingFetcher({\n\t\t\toffline: options.offline ?? false,\n\t\t\tofflineLoader: options.offlineLoader ?? (async () => PREFETCHED_CODEX_PRICING),\n\t\t\tlogger,\n\t\t\tproviderPrefixes: CODEX_PROVIDER_PREFIXES,\n\t\t});\n\t}\n\n\t[Symbol.dispose](): void {\n\t\tthis.fetcher[Symbol.dispose]();\n\t}\n\n\tasync getPricing(model: string): Promise<ModelPricing> {\n\t\tif (isOpenRouterFreeModel(model)) {\n\t\t\treturn FREE_MODEL_PRICING;\n\t\t}\n\n\t\tconst directLookup = await this.fetcher.getModelPricing(model);\n\t\tif (Result.isFailure(directLookup)) {\n\t\t\tthrow directLookup.error;\n\t\t}\n\n\t\tlet pricing = directLookup.value;\n\t\tconst alias = CODEX_MODEL_ALIASES_MAP.get(model);\n\t\tif (alias != null && (pricing == null || !hasNonZeroTokenPricing(pricing))) {\n\t\t\tconst aliasLookup = await this.fetcher.getModelPricing(alias);\n\t\t\tif (Result.isFailure(aliasLookup)) {\n\t\t\t\tthrow aliasLookup.error;\n\t\t\t}\n\t\t\tif (aliasLookup.value != null && hasNonZeroTokenPricing(aliasLookup.value)) {\n\t\t\t\tpricing = aliasLookup.value;\n\t\t\t}\n\t\t}\n\n\t\tif (pricing == null) {\n\t\t\tlogger.warn(`Pricing not found for model ${model}; defaulting to zero-cost pricing.`);\n\t\t\treturn FREE_MODEL_PRICING;\n\t\t}\n\n\t\treturn {\n\t\t\tinputCostPerMToken: toPerMillion(pricing.input_cost_per_token),\n\t\t\tcachedInputCostPerMToken: toPerMillion(\n\t\t\t\tpricing.cache_read_input_token_cost,\n\t\t\t\tpricing.input_cost_per_token,\n\t\t\t),\n\t\t\toutputCostPerMToken: toPerMillion(pricing.output_cost_per_token),\n\t\t};\n\t}\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('CodexPricingSource', () => {\n\t\tit('converts LiteLLM pricing to per-million costs', async () => {\n\t\t\tusing source = new CodexPricingSource({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'gpt-5': {\n\t\t\t\t\t\tinput_cost_per_token: 1.25e-6,\n\t\t\t\t\t\toutput_cost_per_token: 1e-5,\n\t\t\t\t\t\tcache_read_input_token_cost: 1.25e-7,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst pricing = await source.getPricing('gpt-5-codex');\n\t\t\texpect(pricing.inputCostPerMToken).toBeCloseTo(1.25);\n\t\t\texpect(pricing.outputCostPerMToken).toBeCloseTo(10);\n\t\t\texpect(pricing.cachedInputCostPerMToken).toBeCloseTo(0.125);\n\t\t});\n\n\t\tit('returns zero pricing for OpenRouter free routes', async () => {\n\t\t\tusing source = new CodexPricingSource({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({}),\n\t\t\t});\n\n\t\t\tconst directFree = await source.getPricing('openrouter/free');\n\t\t\texpect(directFree).toEqual(FREE_MODEL_PRICING);\n\n\t\t\tconst modelFree = await source.getPricing('openrouter/openai/gpt-5:free');\n\t\t\texpect(modelFree).toEqual(FREE_MODEL_PRICING);\n\t\t});\n\n\t\tit('falls back to zero pricing for unknown non-free models', async () => {\n\t\t\tusing source = new CodexPricingSource({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({}),\n\t\t\t});\n\n\t\t\tconst pricing = await source.getPricing('openrouter/unknown');\n\t\t\texpect(pricing).toEqual(FREE_MODEL_PRICING);\n\t\t});\n\n\t\tit('falls back to alias pricing when direct model pricing is all zeros', async () => {\n\t\t\tusing source = new CodexPricingSource({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'gpt-5.3-codex': {\n\t\t\t\t\t\tinput_cost_per_token: 0,\n\t\t\t\t\t\toutput_cost_per_token: 0,\n\t\t\t\t\t\tcache_read_input_token_cost: 0,\n\t\t\t\t\t},\n\t\t\t\t\t'gpt-5.2-codex': {\n\t\t\t\t\t\tinput_cost_per_token: 1.75e-6,\n\t\t\t\t\t\toutput_cost_per_token: 1.4e-5,\n\t\t\t\t\t\tcache_read_input_token_cost: 1.75e-7,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst pricing = await source.getPricing('gpt-5.3-codex');\n\t\t\texpect(pricing.inputCostPerMToken).toBeCloseTo(1.75);\n\t\t\texpect(pricing.outputCostPerMToken).toBeCloseTo(14);\n\t\t\texpect(pricing.cachedInputCostPerMToken).toBeCloseTo(0.175);\n\t\t});\n\n\t\tit('prefers direct pricing when non-zero pricing is available', async () => {\n\t\t\tusing source = new CodexPricingSource({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'gpt-5.3-codex': {\n\t\t\t\t\t\tinput_cost_per_token: 1.9e-6,\n\t\t\t\t\t\toutput_cost_per_token: 1.5e-5,\n\t\t\t\t\t\tcache_read_input_token_cost: 1.9e-7,\n\t\t\t\t\t},\n\t\t\t\t\t'gpt-5.2-codex': {\n\t\t\t\t\t\tinput_cost_per_token: 1.75e-6,\n\t\t\t\t\t\toutput_cost_per_token: 1.4e-5,\n\t\t\t\t\t\tcache_read_input_token_cost: 1.75e-7,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst pricing = await source.getPricing('gpt-5.3-codex');\n\t\t\texpect(pricing.inputCostPerMToken).toBeCloseTo(1.9);\n\t\t\texpect(pricing.outputCostPerMToken).toBeCloseTo(15);\n\t\t\texpect(pricing.cachedInputCostPerMToken).toBeCloseTo(0.19);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/codex/src/run.ts",
    "content": "import process from 'node:process';\nimport { cli } from 'gunshi';\nimport { description, name, version } from '../package.json';\nimport { dailyCommand } from './commands/daily.ts';\nimport { monthlyCommand } from './commands/monthly.ts';\nimport { sessionCommand } from './commands/session.ts';\n\nconst subCommands = new Map([\n\t['daily', dailyCommand],\n\t['monthly', monthlyCommand],\n\t['session', sessionCommand],\n]);\n\nconst mainCommand = dailyCommand;\n\nexport async function run(): Promise<void> {\n\t// When invoked through npx, the binary name might be passed as the first argument\n\t// Filter it out if it matches the expected binary name\n\tlet args = process.argv.slice(2);\n\tif (args[0] === 'ccusage-codex') {\n\t\targs = args.slice(1);\n\t}\n\n\tawait cli(args, mainCommand, {\n\t\tname,\n\t\tversion,\n\t\tdescription,\n\t\tsubCommands,\n\t\trenderHeader: null,\n\t});\n}\n"
  },
  {
    "path": "apps/codex/src/session-report.ts",
    "content": "import type {\n\tModelPricing,\n\tModelUsage,\n\tPricingSource,\n\tSessionReportRow,\n\tSessionUsageSummary,\n\tTokenUsageEvent,\n} from './_types.ts';\nimport { isWithinRange, toDateKey } from './date-utils.ts';\nimport { addUsage, calculateCostUSD, createEmptyUsage } from './token-utils.ts';\n\nexport type SessionReportOptions = {\n\ttimezone?: string;\n\tlocale?: string;\n\tsince?: string;\n\tuntil?: string;\n\tpricingSource: PricingSource;\n};\n\nfunction createSummary(sessionId: string, initialTimestamp: string): SessionUsageSummary {\n\treturn {\n\t\tsessionId,\n\t\tfirstTimestamp: initialTimestamp,\n\t\tlastTimestamp: initialTimestamp,\n\t\tinputTokens: 0,\n\t\tcachedInputTokens: 0,\n\t\toutputTokens: 0,\n\t\treasoningOutputTokens: 0,\n\t\ttotalTokens: 0,\n\t\tcostUSD: 0,\n\t\tmodels: new Map(),\n\t};\n}\n\nexport async function buildSessionReport(\n\tevents: TokenUsageEvent[],\n\toptions: SessionReportOptions,\n): Promise<SessionReportRow[]> {\n\tconst timezone = options.timezone;\n\tconst since = options.since;\n\tconst until = options.until;\n\tconst pricingSource = options.pricingSource;\n\n\tconst summaries = new Map<string, SessionUsageSummary>();\n\n\tfor (const event of events) {\n\t\tconst rawSessionId = event.sessionId;\n\t\tif (rawSessionId == null) {\n\t\t\tcontinue;\n\t\t}\n\t\tconst sessionId = rawSessionId.trim();\n\t\tif (sessionId === '') {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst rawModelName = event.model;\n\t\tif (rawModelName == null) {\n\t\t\tcontinue;\n\t\t}\n\t\tconst modelName = rawModelName.trim();\n\t\tif (modelName === '') {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst dateKey = toDateKey(event.timestamp, timezone);\n\t\tif (!isWithinRange(dateKey, since, until)) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst summary = summaries.get(sessionId) ?? createSummary(sessionId, event.timestamp);\n\t\tif (!summaries.has(sessionId)) {\n\t\t\tsummaries.set(sessionId, summary);\n\t\t}\n\n\t\taddUsage(summary, event);\n\t\tif (event.timestamp > summary.lastTimestamp) {\n\t\t\tsummary.lastTimestamp = event.timestamp;\n\t\t}\n\n\t\tconst modelUsage: ModelUsage = summary.models.get(modelName) ?? {\n\t\t\t...createEmptyUsage(),\n\t\t\tisFallback: false,\n\t\t};\n\t\tif (!summary.models.has(modelName)) {\n\t\t\tsummary.models.set(modelName, modelUsage);\n\t\t}\n\t\taddUsage(modelUsage, event);\n\t\tif (event.isFallbackModel === true) {\n\t\t\tmodelUsage.isFallback = true;\n\t\t}\n\t}\n\n\tif (summaries.size === 0) {\n\t\treturn [];\n\t}\n\n\tconst uniqueModels = new Set<string>();\n\tfor (const summary of summaries.values()) {\n\t\tfor (const modelName of summary.models.keys()) {\n\t\t\tuniqueModels.add(modelName);\n\t\t}\n\t}\n\n\tconst modelPricing = new Map<string, Awaited<ReturnType<PricingSource['getPricing']>>>();\n\tfor (const modelName of uniqueModels) {\n\t\tmodelPricing.set(modelName, await pricingSource.getPricing(modelName));\n\t}\n\n\tconst sortedSummaries = Array.from(summaries.values()).sort((a, b) =>\n\t\ta.lastTimestamp.localeCompare(b.lastTimestamp),\n\t);\n\n\tconst rows: SessionReportRow[] = [];\n\tfor (const summary of sortedSummaries) {\n\t\tlet cost = 0;\n\t\tfor (const [modelName, usage] of summary.models) {\n\t\t\tconst pricing = modelPricing.get(modelName);\n\t\t\tif (pricing == null) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tcost += calculateCostUSD(usage, pricing);\n\t\t}\n\t\tsummary.costUSD = cost;\n\n\t\tconst rowModels: Record<string, ModelUsage> = {};\n\t\tfor (const [modelName, usage] of summary.models) {\n\t\t\trowModels[modelName] = { ...usage };\n\t\t}\n\n\t\tconst separatorIndex = summary.sessionId.lastIndexOf('/');\n\t\tconst directory = separatorIndex >= 0 ? summary.sessionId.slice(0, separatorIndex) : '';\n\t\tconst sessionFile =\n\t\t\tseparatorIndex >= 0 ? summary.sessionId.slice(separatorIndex + 1) : summary.sessionId;\n\n\t\trows.push({\n\t\t\tsessionId: summary.sessionId,\n\t\t\tlastActivity: summary.lastTimestamp,\n\t\t\tsessionFile,\n\t\t\tdirectory,\n\t\t\tinputTokens: summary.inputTokens,\n\t\t\tcachedInputTokens: summary.cachedInputTokens,\n\t\t\toutputTokens: summary.outputTokens,\n\t\t\treasoningOutputTokens: summary.reasoningOutputTokens,\n\t\t\ttotalTokens: summary.totalTokens,\n\t\t\tcostUSD: cost,\n\t\t\tmodels: rowModels,\n\t\t});\n\t}\n\n\treturn rows;\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('buildSessionReport', () => {\n\t\tit('groups events by session and calculates costs', async () => {\n\t\t\tconst pricing = new Map([\n\t\t\t\t[\n\t\t\t\t\t'gpt-5',\n\t\t\t\t\t{ inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 },\n\t\t\t\t],\n\t\t\t\t[\n\t\t\t\t\t'gpt-5-mini',\n\t\t\t\t\t{ inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 },\n\t\t\t\t],\n\t\t\t]);\n\t\t\tconst stubPricingSource: PricingSource = {\n\t\t\t\tasync getPricing(model: string): Promise<ModelPricing> {\n\t\t\t\t\tconst value = pricing.get(model);\n\t\t\t\t\tif (value == null) {\n\t\t\t\t\t\tthrow new Error(`Missing pricing for ${model}`);\n\t\t\t\t\t}\n\t\t\t\t\treturn value;\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst report = await buildSessionReport(\n\t\t\t\t[\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-a',\n\t\t\t\t\t\ttimestamp: '2025-09-12T01:00:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\tinputTokens: 1_000,\n\t\t\t\t\t\tcachedInputTokens: 100,\n\t\t\t\t\t\toutputTokens: 500,\n\t\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\t\ttotalTokens: 1_500,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-a',\n\t\t\t\t\t\ttimestamp: '2025-09-12T02:00:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5-mini',\n\t\t\t\t\t\tinputTokens: 400,\n\t\t\t\t\t\tcachedInputTokens: 100,\n\t\t\t\t\t\toutputTokens: 200,\n\t\t\t\t\t\treasoningOutputTokens: 30,\n\t\t\t\t\t\ttotalTokens: 630,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tsessionId: 'session-b',\n\t\t\t\t\t\ttimestamp: '2025-09-11T23:30:00.000Z',\n\t\t\t\t\t\tmodel: 'gpt-5',\n\t\t\t\t\t\tinputTokens: 800,\n\t\t\t\t\t\tcachedInputTokens: 0,\n\t\t\t\t\t\toutputTokens: 300,\n\t\t\t\t\t\treasoningOutputTokens: 0,\n\t\t\t\t\t\ttotalTokens: 1_100,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\t{\n\t\t\t\t\tpricingSource: stubPricingSource,\n\t\t\t\t},\n\t\t\t);\n\n\t\t\texpect(report).toHaveLength(2);\n\t\t\tconst first = report[0]!;\n\t\t\texpect(first.sessionId).toBe('session-b');\n\t\t\texpect(first.sessionFile).toBe('session-b');\n\t\t\texpect(first.directory).toBe('');\n\t\t\texpect(first.totalTokens).toBe(1_100);\n\n\t\t\tconst second = report[1]!;\n\t\t\texpect(second.sessionId).toBe('session-a');\n\t\t\texpect(second.sessionFile).toBe('session-a');\n\t\t\texpect(second.directory).toBe('');\n\t\t\texpect(second.totalTokens).toBe(2_130);\n\t\t\texpect(second.models['gpt-5']?.totalTokens).toBe(1_500);\n\t\t\tconst expectedCost =\n\t\t\t\t(900 / 1_000_000) * 1.25 +\n\t\t\t\t(100 / 1_000_000) * 0.125 +\n\t\t\t\t(500 / 1_000_000) * 10 +\n\t\t\t\t(300 / 1_000_000) * 0.6 +\n\t\t\t\t(100 / 1_000_000) * 0.06 +\n\t\t\t\t(200 / 1_000_000) * 2;\n\t\t\texpect(second.costUSD).toBeCloseTo(expectedCost, 10);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/codex/src/token-utils.ts",
    "content": "import type { ModelPricing, TokenUsageDelta } from './_types.ts';\nimport { formatCurrency, formatTokens } from '@ccusage/internal/format';\nimport { MILLION } from './_consts.ts';\n\nexport function createEmptyUsage(): TokenUsageDelta {\n\treturn {\n\t\tinputTokens: 0,\n\t\tcachedInputTokens: 0,\n\t\toutputTokens: 0,\n\t\treasoningOutputTokens: 0,\n\t\ttotalTokens: 0,\n\t};\n}\n\nexport function addUsage(target: TokenUsageDelta, delta: TokenUsageDelta): void {\n\ttarget.inputTokens += delta.inputTokens;\n\ttarget.cachedInputTokens += delta.cachedInputTokens;\n\ttarget.outputTokens += delta.outputTokens;\n\ttarget.reasoningOutputTokens += delta.reasoningOutputTokens;\n\ttarget.totalTokens += delta.totalTokens;\n}\n\nfunction nonCachedInputTokens(usage: TokenUsageDelta): number {\n\tconst nonCached = usage.inputTokens - usage.cachedInputTokens;\n\treturn nonCached > 0 ? nonCached : 0;\n}\n\n/**\n * Calculate the cost in USD for token usage based on model pricing\n *\n * @param usage - Token usage data including input, output, cached, and reasoning tokens\n * @param pricing - Model-specific pricing rates per million tokens\n * @returns Cost in USD\n *\n * @remarks\n * - Cached input tokens receive a 50% discount from OpenAI\n * @see {@link https://platform.openai.com/docs/guides/prompt-caching}\n *\n * - Reasoning tokens are already included in output_tokens, so they are not added separately\n * to avoid double-counting\n */\nexport function calculateCostUSD(usage: TokenUsageDelta, pricing: ModelPricing): number {\n\tconst nonCachedInput = nonCachedInputTokens(usage);\n\tconst cachedInput =\n\t\tusage.cachedInputTokens > usage.inputTokens ? usage.inputTokens : usage.cachedInputTokens;\n\tconst outputTokens = usage.outputTokens;\n\n\tconst inputCost = (nonCachedInput / MILLION) * pricing.inputCostPerMToken;\n\tconst cachedCost = (cachedInput / MILLION) * pricing.cachedInputCostPerMToken;\n\tconst outputCost = (outputTokens / MILLION) * pricing.outputCostPerMToken;\n\n\treturn inputCost + cachedCost + outputCost;\n}\n\nexport { formatCurrency, formatTokens };\n"
  },
  {
    "path": "apps/codex/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"moduleDetection\": \"force\",\n\t\t\"module\": \"Preserve\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"types\": [\"vitest/globals\", \"vitest/importMeta\"],\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"allowJs\": false,\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noPropertyAccessFromIndexSignature\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t\"noUnusedLocals\": false,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noEmit\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"exclude\": [\"dist\"]\n}\n"
  },
  {
    "path": "apps/codex/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\nimport Macros from 'unplugin-macros/rolldown';\n\nexport default defineConfig({\n\tentry: ['src/index.ts'],\n\toutDir: 'dist',\n\tformat: 'esm',\n\tclean: true,\n\tsourcemap: false,\n\tminify: 'dce-only',\n\ttreeshake: true,\n\tdts: false,\n\tpublint: true,\n\tunused: true,\n\tfixedExtension: false,\n\tnodeProtocol: true,\n\tplugins: [\n\t\tMacros({\n\t\t\tinclude: ['src/index.ts', 'src/pricing.ts'],\n\t\t}),\n\t],\n\tdefine: {\n\t\t'import.meta.vitest': 'undefined',\n\t},\n});\n"
  },
  {
    "path": "apps/codex/vitest.config.ts",
    "content": "import Macros from 'unplugin-macros/vite';\nimport { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\twatch: false,\n\t\tincludeSource: ['src/**/*.{js,ts}'],\n\t\tglobals: true,\n\t},\n\tplugins: [\n\t\tMacros({\n\t\t\tinclude: ['src/index.ts', 'src/pricing.ts'],\n\t\t}) as any,\n\t],\n});\n"
  },
  {
    "path": "apps/mcp/CLAUDE.md",
    "content": "# CLAUDE.md - MCP Package\n\nThis package provides the MCP (Model Context Protocol) server implementation for ccusage data.\n\n## Package Overview\n\n**Name**: `@ccusage/mcp`\n**Description**: MCP server implementation for ccusage data\n**Type**: MCP server with CLI and library exports\n\n## Development Commands\n\n**Testing and Quality:**\n\n- `pnpm run test` - Run all tests using vitest\n- `pnpm run lint` - Lint code using ESLint\n- `pnpm run format` - Format and auto-fix code with ESLint\n- `pnpm typecheck` - Type check with TypeScript\n\n**Build and Release:**\n\n- `pnpm run build` - Build distribution files with tsdown\n- `pnpm run prerelease` - Full release workflow (lint + typecheck + build)\n\n## Usage\n\n**As MCP Server:**\n\n```bash\n# Install and run as MCP server\npnpm dlx @ccusage/mcp@latest -- --help\npnpm dlx @ccusage/mcp@latest -- --type http --port 8080\n```\n\n**Direct Usage:**\n\n```bash\n# Run the CLI directly\nccusage-mcp --help\n```\n\n## Architecture\n\nThis package implements an MCP server that exposes ccusage functionality through the Model Context Protocol:\n\n**Key Modules:**\n\n- `src/index.ts` - Main MCP server implementation\n- `src/cli.ts` - CLI entry point for the MCP server\n- `src/command.ts` - Command handling and routing\n\n**MCP Tools Provided:**\n\n- `daily` - Daily usage reports\n- `session` - Session-based usage reports\n- `monthly` - Monthly usage reports\n- `blocks` - 5-hour billing blocks usage reports\n\n**Transport Support:**\n\n- HTTP transport for web-based integration\n- Configurable port and host settings\n\n## Dependencies\n\n**Key Runtime Dependencies:**\n\n- `@hono/mcp` - MCP implementation for Hono\n- `@hono/node-server` - Node.js server adapter for Hono\n- `@modelcontextprotocol/sdk` - Official MCP SDK\n- `ccusage` - Main ccusage package (workspace dependency)\n- `gunshi` - CLI framework\n- `hono` - Web framework\n- `zod` - Schema validation\n\n**Key Dev Dependencies:**\n\n- `vitest` - Testing framework\n- `tsdown` - TypeScript build tool\n- `eslint` - Linting and formatting\n- `fs-fixture` - Test fixture creation\n\n## Integration with Claude Desktop\n\nThis 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.\n\n## Testing\n\n- **In-Source Testing**: Uses the same testing pattern as the main package\n- **Vitest Globals Enabled**: Use `describe`, `it`, `expect` directly without imports\n- **Mock Data**: Uses `fs-fixture` for testing MCP server functionality\n- **CRITICAL**: NEVER use `await import()` dynamic imports anywhere\n\n## Code Style\n\nFollow the same code style guidelines as the main ccusage package:\n\n- **Error Handling**: Prefer `@praha/byethrow Result` type over try-catch\n- **Imports**: Use `.ts` extensions for local imports\n- **Exports**: Only export what's actually used\n- **Dependencies**: Add as `devDependencies` unless explicitly requested\n\n**Post-Change Workflow:**\nAlways run these commands in parallel after code changes:\n\n- `pnpm run format` - Auto-fix and format\n- `pnpm typecheck` - Type checking\n- `pnpm run test` - Run tests\n\n## Package Exports\n\nThe package provides multiple exports:\n\n- `.` - Main MCP server\n- `./cli` - CLI entry point\n- `./command` - Command handling utilities\n\n## Binary\n\nThe package includes a binary `ccusage-mcp` that can be used to start the MCP server from the command line.\n"
  },
  {
    "path": "apps/mcp/README.md",
    "content": "<div align=\"center\">\n    <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.svg\" alt=\"ccusage logo\" width=\"256\" height=\"256\">\n    <h1>@ccusage/mcp</h1>\n</div>\n\n<p align=\"center\">\n    <a href=\"https://socket.dev/api/npm/package/@ccusage/mcp\"><img src=\"https://socket.dev/api/badge/npm/package/@ccusage/mcp\" alt=\"Socket Badge\" /></a>\n    <a href=\"https://npmjs.com/package/@ccusage/mcp\"><img src=\"https://img.shields.io/npm/v/@ccusage/mcp?color=yellow\" alt=\"npm version\" /></a>\n    <a href=\"https://tanstack.com/stats/npm?packageGroups=%5B%7B%22packages%22:%5B%7B%22name%22:%22@ccusage/mcp%22%7D%5D%7D%5D&range=30-days&transform=none&binType=daily&showDataMode=all&height=400\"><img src=\"https://img.shields.io/npm/dt/@ccusage/mcp\" alt=\"NPM Downloads\" /></a>\n    <a href=\"https://packagephobia.com/result?p=@ccusage/mcp\"><img src=\"https://packagephobia.com/badge?p=@ccusage/mcp\" alt=\"install size\" /></a>\n    <a href=\"https://deepwiki.com/ryoppippi/ccusage\"><img src=\"https://img.shields.io/badge/DeepWiki-ryoppippi%2Fccusage-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXCGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==\" alt=\"DeepWiki\"></a>\n    <!-- DeepWiki badge generated by https://deepwiki.ryoppippi.com/ -->\n</p>\n\n<div align=\"center\">\n    <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/mcp-claude-desktop.avif\" alt=\"Claude Desktop MCP integration screenshot\" width=\"640\">\n</div>\n\n> MCP (Model Context Protocol) server implementation for ccusage - provides Claude Code usage data through the MCP protocol.\n\n## Quick Start\n\n```bash\n# Using bunx (recommended for speed)\nbunx @ccusage/mcp@latest\n\n# Using npx\nnpx @ccusage/mcp@latest\n\n# Start with HTTP transport\nbunx @ccusage/mcp@latest -- --type http --port 8080\n```\n\n## Integrations\n\n### Claude Desktop Integration\n\nAdd to your Claude Desktop MCP configuration:\n\n```json\n{\n\t\"mcpServers\": {\n\t\t\"ccusage\": {\n\t\t\t\"command\": \"npx\",\n\t\t\t\"args\": [\"@ccusage/mcp@latest\"],\n\t\t\t\"type\": \"stdio\"\n\t\t}\n\t}\n}\n```\n\n### Claude Code\n\n```sh\nclaude mcp add ccusage npx -- @ccusage/mcp@latest\n```\n\n## Documentation\n\nFor full documentation, visit **[ccusage.com/guide/mcp-server](https://ccusage.com/guide/mcp-server)**\n\n## Sponsors\n\n### Featured Sponsor\n\nCheck out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)\n\n<p align=\"center\">\n    <a href=\"https://www.youtube.com/watch?v=Ak6qpQ5qdgk\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/ccusage_thumbnail.png\" alt=\"ccusage: The Claude Code cost scorecard that went viral\" width=\"600\">\n    </a>\n</p>\n\n<p align=\"center\">\n    <a href=\"https://github.com/sponsors/ryoppippi\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/sponsors@main/sponsors.svg\">\n    </a>\n</p>\n\n## License\n\nMIT © [@ryoppippi](https://github.com/ryoppippi)\n"
  },
  {
    "path": "apps/mcp/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\n/** @type {import('eslint').Linter.FlatConfig[]} */\nconst config = ryoppippi(\n\t{\n\t\ttype: 'app',\n\t\tstylistic: false,\n\t},\n\t{\n\t\trules: {\n\t\t\t'test/no-importing-vitest-globals': 'error',\n\t\t},\n\t},\n);\n\nexport default config;\n"
  },
  {
    "path": "apps/mcp/package.json",
    "content": "{\n\t\"name\": \"@ccusage/mcp\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"description\": \"MCP server implementation for ccusage data\",\n\t\"author\": \"ryoppippi\",\n\t\"license\": \"MIT\",\n\t\"funding\": \"https://github.com/ryoppippi/ccusage?sponsor=1\",\n\t\"homepage\": \"https://github.com/ryoppippi/ccusage#readme\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/ryoppippi/ccusage.git\",\n\t\t\"directory\": \"apps/mcp\"\n\t},\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/ryoppippi/ccusage/issues\"\n\t},\n\t\"exports\": {\n\t\t\".\": \"./src/index.ts\",\n\t\t\"./package.json\": \"./package.json\"\n\t},\n\t\"main\": \"./dist/index.js\",\n\t\"module\": \"./dist/index.js\",\n\t\"types\": \"./dist/index.d.ts\",\n\t\"bin\": {\n\t\t\"ccusage-mcp\": \"./src/index.ts\"\n\t},\n\t\"files\": [\n\t\t\"README.md\",\n\t\t\"dist\"\n\t],\n\t\"publishConfig\": {\n\t\t\"bin\": {\n\t\t\t\"ccusage-mcp\": \"./dist/index.js\"\n\t\t},\n\t\t\"exports\": {\n\t\t\t\".\": \"./dist/index.js\",\n\t\t\t\"./package.json\": \"./package.json\"\n\t\t}\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=20.19.4\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"tsdown\",\n\t\t\"dev\": \"bun -b --watch ./src/index.ts\",\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"prepack\": \"pnpm run build && clean-pkg-json\",\n\t\t\"prerelease\": \"pnpm run lint && pnpm run typecheck && pnpm run build\",\n\t\t\"start\": \"bun ./src/index.ts\",\n\t\t\"test\": \"TZ=UTC vitest\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"dependencies\": {\n\t\t\"@ccusage/codex\": \"workspace:*\",\n\t\t\"@hono/mcp\": \"catalog:runtime\",\n\t\t\"@hono/node-server\": \"catalog:runtime\",\n\t\t\"@modelcontextprotocol/sdk\": \"catalog:runtime\",\n\t\t\"ccusage\": \"workspace:*\",\n\t\t\"gunshi\": \"catalog:runtime\",\n\t\t\"hono\": \"catalog:runtime\",\n\t\t\"nano-spawn\": \"catalog:runtime\",\n\t\t\"zod\": \"catalog:runtime\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@ccusage/internal\": \"workspace:*\",\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"@typescript/native-preview\": \"catalog:types\",\n\t\t\"clean-pkg-json\": \"catalog:release\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"fs-fixture\": \"catalog:testing\",\n\t\t\"publint\": \"catalog:lint\",\n\t\t\"tsdown\": \"catalog:build\",\n\t\t\"vitest\": \"catalog:testing\"\n\t}\n}\n"
  },
  {
    "path": "apps/mcp/src/ccusage.ts",
    "content": "import type { CliInvocation } from './cli-utils.ts';\nimport { z } from 'zod';\nimport { createCliInvocation, executeCliCommand, resolveBinaryPath } from './cli-utils.ts';\nimport { DATE_FILTER_REGEX } from './consts.ts';\n\nexport const filterDateSchema = z\n\t.string()\n\t.regex(DATE_FILTER_REGEX, 'Date must be in YYYYMMDD format');\n\nexport const ccusageParametersShape = {\n\tsince: filterDateSchema.optional(),\n\tuntil: filterDateSchema.optional(),\n\tmode: z.enum(['auto', 'calculate', 'display']).default('auto').optional(),\n\ttimezone: z.string().optional(),\n\tlocale: z.string().optional(),\n} as const satisfies Record<string, z.ZodTypeAny>;\n\nexport const ccusageParametersSchema = z.object(ccusageParametersShape);\n\nlet cachedCcusageInvocation: CliInvocation | null = null;\n\nfunction getCcusageInvocation(): CliInvocation {\n\tif (cachedCcusageInvocation != null) {\n\t\treturn cachedCcusageInvocation;\n\t}\n\n\tconst entryPath = resolveBinaryPath('ccusage', 'ccusage');\n\tcachedCcusageInvocation = createCliInvocation(entryPath);\n\treturn cachedCcusageInvocation;\n}\n\nasync function runCcusageCliJson(\n\tcommand: 'daily' | 'monthly' | 'session' | 'blocks',\n\tparameters: z.infer<typeof ccusageParametersSchema>,\n\tclaudePath: string,\n): Promise<string> {\n\tconst { executable, prefixArgs } = getCcusageInvocation();\n\tconst cliArgs: string[] = [...prefixArgs, command, '--json'];\n\n\tconst since = parameters.since;\n\tif (since != null && since !== '') {\n\t\tcliArgs.push('--since', since);\n\t}\n\tconst until = parameters.until;\n\tif (until != null && until !== '') {\n\t\tcliArgs.push('--until', until);\n\t}\n\tconst mode = parameters.mode;\n\tif (mode != null && mode !== 'auto') {\n\t\tcliArgs.push('--mode', mode);\n\t}\n\tconst timezone = parameters.timezone;\n\tif (timezone != null && timezone !== '') {\n\t\tcliArgs.push('--timezone', timezone);\n\t}\n\tconst locale = parameters.locale;\n\tif (locale != null && locale !== '') {\n\t\tcliArgs.push('--locale', locale);\n\t}\n\n\treturn executeCliCommand(executable, cliArgs, {\n\t\t// Set Claude path for ccusage\n\t\tCLAUDE_CONFIG_DIR: claudePath,\n\t});\n}\n\nexport async function getCcusageDaily(\n\tparameters: z.infer<typeof ccusageParametersSchema>,\n\tclaudePath: string,\n): Promise<unknown> {\n\ttry {\n\t\tconst raw = await runCcusageCliJson('daily', parameters, claudePath);\n\t\tconst parsed = JSON.parse(raw) as unknown;\n\t\t// If the parsed result is an empty array, convert to expected structure\n\t\tif (Array.isArray(parsed) && parsed.length === 0) {\n\t\t\treturn { daily: [], totals: {} };\n\t\t}\n\t\treturn parsed;\n\t} catch {\n\t\t// Return empty result on error\n\t\treturn { daily: [], totals: {} };\n\t}\n}\n\nexport async function getCcusageMonthly(\n\tparameters: z.infer<typeof ccusageParametersSchema>,\n\tclaudePath: string,\n): Promise<unknown> {\n\ttry {\n\t\tconst raw = await runCcusageCliJson('monthly', parameters, claudePath);\n\t\tconst parsed = JSON.parse(raw) as unknown;\n\t\t// If the parsed result is an empty array, convert to expected structure\n\t\tif (Array.isArray(parsed) && parsed.length === 0) {\n\t\t\treturn { monthly: [], totals: {} };\n\t\t}\n\t\treturn parsed;\n\t} catch {\n\t\t// Return empty result on error\n\t\treturn { monthly: [], totals: {} };\n\t}\n}\n\nexport async function getCcusageSession(\n\tparameters: z.infer<typeof ccusageParametersSchema>,\n\tclaudePath: string,\n): Promise<unknown> {\n\ttry {\n\t\tconst raw = await runCcusageCliJson('session', parameters, claudePath);\n\t\tconst parsed = JSON.parse(raw) as unknown;\n\t\t// If the parsed result is an empty array, convert to expected structure\n\t\tif (Array.isArray(parsed) && parsed.length === 0) {\n\t\t\treturn { sessions: [], totals: {} };\n\t\t}\n\t\treturn parsed;\n\t} catch {\n\t\t// Return empty result on error\n\t\treturn { sessions: [], totals: {} };\n\t}\n}\n\nexport async function getCcusageBlocks(\n\tparameters: z.infer<typeof ccusageParametersSchema>,\n\tclaudePath: string,\n): Promise<unknown> {\n\ttry {\n\t\tconst raw = await runCcusageCliJson('blocks', parameters, claudePath);\n\t\tconst parsed = JSON.parse(raw) as unknown;\n\t\t// If the parsed result is an empty array, convert to expected structure\n\t\tif (Array.isArray(parsed) && parsed.length === 0) {\n\t\t\treturn { blocks: [] };\n\t\t}\n\t\treturn parsed;\n\t} catch {\n\t\t// Return empty result on error\n\t\treturn { blocks: [] };\n\t}\n}\n"
  },
  {
    "path": "apps/mcp/src/cli-utils.ts",
    "content": "import { createRequire } from 'node:module';\nimport path from 'node:path';\nimport process from 'node:process';\nimport spawn, { SubprocessError } from 'nano-spawn';\n\nconst nodeRequire = createRequire(import.meta.url);\n\nexport type BinField = string | Record<string, string> | undefined;\n\nexport type CliInvocation = {\n\texecutable: string;\n\tprefixArgs: string[];\n};\n\n/**\n * Resolves the binary path for a package\n */\nexport function resolveBinaryPath(packageName: string, binName?: string): string {\n\tlet packageJsonPath: string;\n\ttry {\n\t\tpackageJsonPath = nodeRequire.resolve(`${packageName}/package.json`);\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`Unable to resolve ${packageName}. Install the package alongside @ccusage/mcp to enable ${packageName} tools.`,\n\t\t\t{ cause: error },\n\t\t);\n\t}\n\n\tconst packageJson = nodeRequire(packageJsonPath) as {\n\t\tbin?: BinField;\n\t\tpublishConfig?: { bin?: BinField };\n\t};\n\tconst binField: BinField = packageJson.bin ?? packageJson.publishConfig?.bin;\n\n\tlet binRelative: string | undefined;\n\tif (typeof binField === 'string') {\n\t\tbinRelative = binField;\n\t} else if (binField != null && typeof binField === 'object') {\n\t\tbinRelative =\n\t\t\tbinName != null && binName !== '' ? binField[binName] : Object.values(binField)[0];\n\t}\n\n\tif (binRelative == null) {\n\t\tthrow new Error(\n\t\t\t`Unable to locate ${binName ?? packageName} binary entry in ${packageName}/package.json`,\n\t\t);\n\t}\n\n\tconst packageDir = path.dirname(packageJsonPath);\n\treturn path.resolve(packageDir, binRelative);\n}\n\n/**\n * Creates invocation config for CLI execution\n */\nexport function createCliInvocation(entryPath: string): CliInvocation {\n\t// Use bun for TypeScript files in development\n\tif (entryPath.endsWith('.ts')) {\n\t\treturn {\n\t\t\texecutable: 'bun',\n\t\t\tprefixArgs: [entryPath],\n\t\t};\n\t}\n\t// Use node for built JavaScript files in production\n\treturn {\n\t\texecutable: process.execPath,\n\t\tprefixArgs: [entryPath],\n\t};\n}\n\n/**\n * Executes a CLI command and returns the output\n */\nexport async function executeCliCommand(\n\texecutable: string,\n\targs: string[],\n\tenv?: Record<string, string>,\n): Promise<string> {\n\ttry {\n\t\tconst result = await spawn(executable, args, {\n\t\t\tenv: {\n\t\t\t\t...process.env,\n\t\t\t\t// Suppress color output\n\t\t\t\tFORCE_COLOR: '0',\n\t\t\t\t// nano-spawn captures stdout, so it won't leak to terminal\n\t\t\t\t...env,\n\t\t\t},\n\t\t});\n\t\tconst output = (result.stdout ?? result.output ?? '').trim();\n\t\tif (output === '') {\n\t\t\tthrow new Error('CLI command returned empty output');\n\t\t}\n\t\treturn output;\n\t} catch (error: unknown) {\n\t\tif (error instanceof SubprocessError) {\n\t\t\tconst message = (error.stderr ?? error.stdout ?? error.output ?? error.message).trim();\n\t\t\tthrow new Error(message);\n\t\t}\n\t\tthrow error;\n\t}\n}\n"
  },
  {
    "path": "apps/mcp/src/codex.ts",
    "content": "import type { CliInvocation } from './cli-utils.ts';\nimport { z } from 'zod';\nimport { createCliInvocation, executeCliCommand, resolveBinaryPath } from './cli-utils.ts';\n\nconst codexModelUsageSchema = z.object({\n\tinputTokens: z.number(),\n\tcachedInputTokens: z.number(),\n\toutputTokens: z.number(),\n\treasoningOutputTokens: z.number(),\n\ttotalTokens: z.number(),\n\tisFallback: z.boolean().optional(),\n});\n\nconst codexTotalsSchema = z.object({\n\tinputTokens: z.number(),\n\tcachedInputTokens: z.number(),\n\toutputTokens: z.number(),\n\treasoningOutputTokens: z.number(),\n\ttotalTokens: z.number(),\n\tcostUSD: z.number(),\n});\n\nconst codexDailyRowSchema = z.object({\n\tdate: z.string(),\n\tinputTokens: z.number(),\n\tcachedInputTokens: z.number(),\n\toutputTokens: z.number(),\n\treasoningOutputTokens: z.number(),\n\ttotalTokens: z.number(),\n\tcostUSD: z.number(),\n\tmodels: z.record(z.string(), codexModelUsageSchema),\n});\n\nconst codexMonthlyRowSchema = z.object({\n\tmonth: z.string(),\n\tinputTokens: z.number(),\n\tcachedInputTokens: z.number(),\n\toutputTokens: z.number(),\n\treasoningOutputTokens: z.number(),\n\ttotalTokens: z.number(),\n\tcostUSD: z.number(),\n\tmodels: z.record(z.string(), codexModelUsageSchema),\n});\n\n// Response schemas for internal parsing only - not exported\nconst codexDailyResponseSchema = z.object({\n\tdaily: z.array(codexDailyRowSchema),\n\ttotals: codexTotalsSchema.nullable(),\n});\n\nconst codexMonthlyResponseSchema = z.object({\n\tmonthly: z.array(codexMonthlyRowSchema),\n\ttotals: codexTotalsSchema.nullable(),\n});\n\nexport const codexParametersShape = {\n\tsince: z.string().optional(),\n\tuntil: z.string().optional(),\n\ttimezone: z.string().optional(),\n\tlocale: z.string().optional(),\n\toffline: z.boolean().optional(),\n} as const satisfies Record<string, z.ZodTypeAny>;\n\nexport const codexParametersSchema = z.object(codexParametersShape);\n\nlet cachedCodexInvocation: CliInvocation | null = null;\n\nfunction getCodexInvocation(): CliInvocation {\n\tif (cachedCodexInvocation != null) {\n\t\treturn cachedCodexInvocation;\n\t}\n\n\tconst entryPath = resolveBinaryPath('@ccusage/codex', 'ccusage-codex');\n\tcachedCodexInvocation = createCliInvocation(entryPath);\n\treturn cachedCodexInvocation;\n}\n\nasync function runCodexCliJson(\n\tcommand: 'daily' | 'monthly',\n\tparameters: z.infer<typeof codexParametersSchema>,\n): Promise<string> {\n\tconst { executable, prefixArgs } = getCodexInvocation();\n\tconst cliArgs: string[] = [...prefixArgs, command, '--json'];\n\n\tconst since = parameters.since;\n\tif (since != null && since !== '') {\n\t\tcliArgs.push('--since', since);\n\t}\n\tconst until = parameters.until;\n\tif (until != null && until !== '') {\n\t\tcliArgs.push('--until', until);\n\t}\n\tconst timezone = parameters.timezone;\n\tif (timezone != null && timezone !== '') {\n\t\tcliArgs.push('--timezone', timezone);\n\t}\n\tconst locale = parameters.locale;\n\tif (locale != null && locale !== '') {\n\t\tcliArgs.push('--locale', locale);\n\t}\n\tif (parameters.offline === true) {\n\t\tcliArgs.push('--offline');\n\t} else if (parameters.offline === false) {\n\t\tcliArgs.push('--no-offline');\n\t}\n\n\treturn executeCliCommand(executable, cliArgs, {\n\t\t// Keep default log level to allow JSON output\n\t});\n}\n\nexport async function getCodexDaily(parameters: z.infer<typeof codexParametersSchema>) {\n\tconst raw = await runCodexCliJson('daily', parameters);\n\treturn codexDailyResponseSchema.parse(JSON.parse(raw));\n}\n\nexport async function getCodexMonthly(parameters: z.infer<typeof codexParametersSchema>) {\n\tconst raw = await runCodexCliJson('monthly', parameters);\n\treturn codexMonthlyResponseSchema.parse(JSON.parse(raw));\n}\n"
  },
  {
    "path": "apps/mcp/src/command.ts",
    "content": "import type { LoadOptions } from 'ccusage/data-loader';\nimport process from 'node:process';\nimport { serve } from '@hono/node-server';\nimport { getClaudePaths } from 'ccusage/data-loader';\nimport { logger } from 'ccusage/logger';\nimport { cli, define } from 'gunshi';\nimport { description, name, version } from '../package.json';\nimport { createMcpHttpApp, createMcpServer, startMcpServerStdio } from './mcp.ts';\n\ntype McpType = (typeof MCP_TYPE_CHOICES)[number];\ntype Mode = LoadOptions['mode'];\n\nconst MCP_DEFAULT_PORT = 8080;\nconst MODE_CHOICES = ['auto', 'calculate', 'display'] as const satisfies readonly Mode[];\nconst MCP_TYPE_CHOICES = ['stdio', 'http'] as const satisfies readonly string[];\n\ntype CommandOptions = LoadOptions & {\n\tport?: number;\n\ttype?: McpType;\n};\n\nexport const mcpCommand = define({\n\tname: 'mcp',\n\tdescription: 'Start MCP server with usage reporting tools',\n\targs: {\n\t\tmode: {\n\t\t\ttype: 'enum',\n\t\t\tshort: 'm',\n\t\t\tdescription: 'Cost calculation mode for usage reports',\n\t\t\tchoices: MODE_CHOICES,\n\t\t\tdefault: 'auto' satisfies Mode,\n\t\t},\n\t\ttype: {\n\t\t\ttype: 'enum',\n\t\t\tshort: 't',\n\t\t\tdescription: 'Transport type for MCP server',\n\t\t\tchoices: MCP_TYPE_CHOICES,\n\t\t\tdefault: 'stdio' satisfies McpType,\n\t\t},\n\t\tport: {\n\t\t\ttype: 'number',\n\t\t\tshort: 'p',\n\t\t\tdescription: `Port for HTTP transport (default: ${MCP_DEFAULT_PORT})`,\n\t\t\tdefault: MCP_DEFAULT_PORT,\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst { type: mcpType, mode, port } = ctx.values;\n\n\t\tif (mcpType === 'stdio') {\n\t\t\tlogger.level = 0;\n\t\t}\n\n\t\tconst paths = getClaudePaths();\n\t\tif (paths.length === 0) {\n\t\t\tlogger.error('No valid Claude data directory found');\n\t\t\tthrow new Error('No valid Claude data directory found');\n\t\t}\n\n\t\tconst options: CommandOptions = {\n\t\t\tclaudePath: paths.at(0),\n\t\t\tmode,\n\t\t};\n\n\t\tswitch (mcpType) {\n\t\t\tcase 'stdio': {\n\t\t\t\tconst server = createMcpServer(options);\n\t\t\t\tawait startMcpServerStdio(server);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcase 'http': {\n\t\t\t\tconst app = createMcpHttpApp(options);\n\t\t\t\tserve({\n\t\t\t\t\tfetch: app.fetch,\n\t\t\t\t\tport,\n\t\t\t\t});\n\t\t\t\tlogger.info(`MCP server is running on http://localhost:${port}`);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\tmcpType satisfies never;\n\t\t\t\tthrow new Error(`Unsupported MCP type: ${mcpType as string}`);\n\t\t\t}\n\t\t}\n\t},\n});\n\nexport async function run(argv: string[] = process.argv.slice(2)): Promise<void> {\n\t// When invoked through npx/bunx, the binary name might be passed as the first argument\n\t// Filter it out if it matches the expected binary name\n\tlet args = argv;\n\tif (args[0] === 'ccusage-mcp') {\n\t\targs = args.slice(1);\n\t}\n\n\tawait cli(args, mcpCommand, {\n\t\tname,\n\t\tversion,\n\t\tdescription,\n\t\tsubCommands: new Map(),\n\t});\n}\n"
  },
  {
    "path": "apps/mcp/src/consts.ts",
    "content": "export const DEFAULT_LOCALE = 'en-CA';\nexport const DATE_FILTER_REGEX = /^\\d{8}$/;\n"
  },
  {
    "path": "apps/mcp/src/index.ts",
    "content": "#!/usr/bin/env node\n\nimport { run } from './command.ts';\n\n// eslint-disable-next-line antfu/no-top-level-await\nawait run();\n"
  },
  {
    "path": "apps/mcp/src/mcp-utils.ts",
    "content": "import type { LoadOptions } from 'ccusage/data-loader';\nimport { getClaudePaths } from 'ccusage/data-loader';\n\nexport function defaultOptions(): LoadOptions {\n\tconst paths = getClaudePaths();\n\tif (paths.length === 0) {\n\t\tthrow new Error(\n\t\t\t'No valid Claude path found. Ensure getClaudePaths() returns at least one valid path.',\n\t\t);\n\t}\n\treturn { claudePath: paths[0] } as const satisfies LoadOptions;\n}\n"
  },
  {
    "path": "apps/mcp/src/mcp.ts",
    "content": "/**\n * @fileoverview MCP (Model Context Protocol) server implementation\n *\n * This module provides MCP server functionality for exposing ccusage data\n * through the Model Context Protocol. It includes both stdio and HTTP transport\n * options for integration with various MCP clients.\n *\n * @module mcp\n */\n\nimport type { LoadOptions } from 'ccusage/data-loader';\nimport { StreamableHTTPTransport } from '@hono/mcp';\nimport { Client } from '@modelcontextprotocol/sdk/client/index.js';\nimport { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { createFixture } from 'fs-fixture';\n\nimport { Hono } from 'hono/tiny';\nimport { name, version } from '../package.json';\n\nimport {\n\tccusageParametersSchema,\n\tccusageParametersShape,\n\tgetCcusageBlocks,\n\tgetCcusageDaily,\n\tgetCcusageMonthly,\n\tgetCcusageSession,\n} from './ccusage.ts';\nimport {\n\tcodexParametersSchema,\n\tcodexParametersShape,\n\tgetCodexDaily,\n\tgetCodexMonthly,\n} from './codex.ts';\nimport { defaultOptions } from './mcp-utils.ts';\n\n/**\n * Creates an MCP server with tools for showing usage reports.\n * Registers tools for daily, session, monthly, and blocks usage data.\n *\n * @param options - Configuration options for the MCP server\n * @param options.claudePath - Path to Claude's data directory\n * @returns Configured MCP server instance with registered tools\n */\nexport function createMcpServer(options?: LoadOptions): McpServer {\n\tconst server = new McpServer({\n\t\tname,\n\t\tversion,\n\t});\n\n\tconst { claudePath = '' } = options ?? defaultOptions();\n\tif (claudePath === '') {\n\t\tthrow new Error('Claude path is required');\n\t}\n\n\t// Register daily tool\n\tserver.registerTool(\n\t\t'daily',\n\t\t{\n\t\t\tdescription: 'Show usage report grouped by date',\n\t\t\tinputSchema: ccusageParametersShape,\n\t\t},\n\t\tasync (args) => {\n\t\t\tconst parameters = ccusageParametersSchema.parse(args);\n\t\t\tconst jsonOutput = await getCcusageDaily(parameters, claudePath);\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: 'text',\n\t\t\t\t\t\ttext: JSON.stringify(jsonOutput, null, 2),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t};\n\t\t},\n\t);\n\n\t// Register session tool\n\tserver.registerTool(\n\t\t'session',\n\t\t{\n\t\t\tdescription: 'Show usage report grouped by conversation session',\n\t\t\tinputSchema: ccusageParametersShape,\n\t\t},\n\t\tasync (args) => {\n\t\t\tconst parameters = ccusageParametersSchema.parse(args);\n\t\t\tconst jsonOutput = await getCcusageSession(parameters, claudePath);\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: 'text',\n\t\t\t\t\t\ttext: JSON.stringify(jsonOutput, null, 2),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t};\n\t\t},\n\t);\n\n\t// Register monthly tool\n\tserver.registerTool(\n\t\t'monthly',\n\t\t{\n\t\t\tdescription: 'Show usage report grouped by month',\n\t\t\tinputSchema: ccusageParametersShape,\n\t\t},\n\t\tasync (args) => {\n\t\t\tconst parameters = ccusageParametersSchema.parse(args);\n\t\t\tconst jsonOutput = await getCcusageMonthly(parameters, claudePath);\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: 'text',\n\t\t\t\t\t\ttext: JSON.stringify(jsonOutput, null, 2),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t};\n\t\t},\n\t);\n\n\t// Register blocks tool\n\tserver.registerTool(\n\t\t'blocks',\n\t\t{\n\t\t\tdescription: 'Show usage report grouped by session billing blocks',\n\t\t\tinputSchema: ccusageParametersShape,\n\t\t},\n\t\tasync (args) => {\n\t\t\tconst parameters = ccusageParametersSchema.parse(args);\n\t\t\tconst jsonOutput = await getCcusageBlocks(parameters, claudePath);\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: 'text',\n\t\t\t\t\t\ttext: JSON.stringify(jsonOutput, null, 2),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t};\n\t\t},\n\t);\n\n\t// Register Codex daily tool\n\tserver.registerTool(\n\t\t'codex-daily',\n\t\t{\n\t\t\tdescription: 'Show Codex usage grouped by day',\n\t\t\tinputSchema: codexParametersShape,\n\t\t},\n\t\tasync (args) => {\n\t\t\tconst parameters = codexParametersSchema.parse(args);\n\t\t\tconst codexDaily = await getCodexDaily(parameters);\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: 'text',\n\t\t\t\t\t\ttext: JSON.stringify(codexDaily, null, 2),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t};\n\t\t},\n\t);\n\n\t// Register Codex monthly tool\n\tserver.registerTool(\n\t\t'codex-monthly',\n\t\t{\n\t\t\tdescription: 'Show Codex usage grouped by month',\n\t\t\tinputSchema: codexParametersShape,\n\t\t},\n\t\tasync (args) => {\n\t\t\tconst parameters = codexParametersSchema.parse(args);\n\t\t\tconst codexMonthly = await getCodexMonthly(parameters);\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: 'text',\n\t\t\t\t\t\ttext: JSON.stringify(codexMonthly, null, 2),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t};\n\t\t},\n\t);\n\n\treturn server;\n}\n\n/**\n * Start the MCP server with stdio transport.\n * Used for traditional MCP client connections via standard input/output.\n *\n * @param server - The MCP server instance to start\n */\nexport async function startMcpServerStdio(server: McpServer): Promise<void> {\n\tconst transport = new StdioServerTransport();\n\tawait server.connect(transport);\n}\n\n/**\n * Create Hono app for MCP HTTP server.\n * Provides HTTP transport support for MCP protocol using Hono framework.\n * Handles POST requests for MCP communication and returns appropriate errors for other methods.\n *\n * @param options - Configuration options for the MCP server\n * @param options.claudePath - Path to Claude's data directory\n * @returns Configured Hono application for HTTP MCP transport\n */\nexport function createMcpHttpApp(options?: LoadOptions): Hono {\n\tconst app = new Hono();\n\n\tconst mcpServer = createMcpServer(options ?? defaultOptions());\n\n\tapp.all('/', async (c) => {\n\t\tconst transport = new StreamableHTTPTransport();\n\t\tawait mcpServer.connect(transport);\n\t\treturn transport.handleRequest(c);\n\t});\n\n\treturn app;\n}\n\nif (import.meta.vitest != null) {\n\t/* eslint-disable ts/no-unsafe-assignment, ts/no-unsafe-member-access, ts/no-unsafe-call */\n\tdescribe('MCP Server', () => {\n\t\tdescribe('createMcpServer', () => {\n\t\t\tit('should create MCP server with default options', () => {\n\t\t\t\tconst server = createMcpServer();\n\t\t\t\texpect(server).toBeDefined();\n\t\t\t});\n\n\t\t\tit('should create MCP server with custom options', () => {\n\t\t\t\tconst server = createMcpServer({ claudePath: '/custom/path' });\n\t\t\t\texpect(server).toBeDefined();\n\t\t\t});\n\t\t});\n\n\t\tdescribe('stdio transport', () => {\n\t\t\tit('should connect via stdio transport and list tools', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\tconst result = await client.listTools();\n\t\t\t\texpect(result.tools).toHaveLength(6);\n\n\t\t\t\tconst toolNames = result.tools.map((tool) => tool.name);\n\t\t\t\texpect(toolNames).toContain('daily');\n\t\t\t\texpect(toolNames).toContain('session');\n\t\t\t\texpect(toolNames).toContain('monthly');\n\t\t\t\texpect(toolNames).toContain('blocks');\n\t\t\t\texpect(toolNames).toContain('codex-daily');\n\t\t\t\texpect(toolNames).toContain('codex-monthly');\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should call daily tool successfully', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'daily',\n\t\t\t\t\targuments: { mode: 'auto' },\n\t\t\t\t});\n\n\t\t\t\texpect(result).toHaveProperty('content');\n\t\t\t\texpect(Array.isArray(result.content)).toBe(true);\n\t\t\t\texpect(result.content).toHaveLength(1);\n\n\t\t\t\texpect((result.content as any).at(0)).toHaveProperty('type', 'text');\n\t\t\t\texpect((result.content as any).at(0)).toHaveProperty('text');\n\n\t\t\t\tconst data = JSON.parse((result.content as any).at(0).text as string);\n\t\t\t\texpect(data).toHaveProperty('daily');\n\t\t\t\texpect(data).toHaveProperty('totals');\n\t\t\t\texpect(Array.isArray(data.daily)).toBe(true);\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should call session tool successfully', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'session',\n\t\t\t\t\targuments: { mode: 'auto' },\n\t\t\t\t});\n\n\t\t\t\texpect(result).toHaveProperty('content');\n\t\t\t\texpect(result.content).toHaveLength(1);\n\t\t\t\texpect((result.content as any)[0]).toHaveProperty('type', 'text');\n\t\t\t\texpect((result.content as any)[0]).toHaveProperty('text');\n\n\t\t\t\tconst data = JSON.parse((result.content as any)[0].text as string);\n\t\t\t\texpect(data).toHaveProperty('sessions');\n\t\t\t\texpect(data).toHaveProperty('totals');\n\t\t\t\texpect(Array.isArray(data.sessions)).toBe(true);\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should call monthly tool successfully', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'monthly',\n\t\t\t\t\targuments: { mode: 'auto' },\n\t\t\t\t});\n\n\t\t\t\texpect(result).toHaveProperty('content');\n\t\t\t\texpect(result.content).toHaveLength(1);\n\t\t\t\texpect((result.content as any)[0]).toHaveProperty('type', 'text');\n\t\t\t\texpect((result.content as any)[0]).toHaveProperty('text');\n\n\t\t\t\tconst data = JSON.parse((result.content as any)[0].text as string);\n\t\t\t\texpect(data).toHaveProperty('monthly');\n\t\t\t\texpect(data).toHaveProperty('totals');\n\t\t\t\texpect(Array.isArray(data.monthly)).toBe(true);\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should call blocks tool successfully', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'blocks',\n\t\t\t\t\targuments: { mode: 'auto' },\n\t\t\t\t});\n\n\t\t\t\texpect(result).toHaveProperty('content');\n\t\t\t\texpect(result.content).toHaveLength(1);\n\t\t\t\texpect((result.content as any)[0]).toHaveProperty('type', 'text');\n\t\t\t\texpect((result.content as any)[0]).toHaveProperty('text');\n\n\t\t\t\tconst data = JSON.parse((result.content as any)[0].text as string);\n\t\t\t\texpect(data).toHaveProperty('blocks');\n\t\t\t\texpect(Array.isArray(data.blocks)).toBe(true);\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\t\t});\n\n\t\tdescribe('HTTP transport', () => {\n\t\t\tit('should create HTTP app', () => {\n\t\t\t\tconst app = createMcpHttpApp();\n\t\t\t\texpect(app).toBeDefined();\n\t\t\t});\n\n\t\t\tit('should handle invalid JSON in POST request', async () => {\n\t\t\t\tconst app = createMcpHttpApp();\n\n\t\t\t\tconst response = await app.request('/', {\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\t\tbody: 'invalid json',\n\t\t\t\t});\n\n\t\t\t\texpect(response.status).toBe(406);\n\t\t\t\tconst data = await response.json();\n\t\t\t\texpect(data).toMatchObject({\n\t\t\t\t\tjsonrpc: '2.0',\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: -32000,\n\t\t\t\t\t\tmessage:\n\t\t\t\t\t\t\t'Not Acceptable: Client must accept both application/json and text/event-stream',\n\t\t\t\t\t},\n\t\t\t\t\tid: null,\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tit('should handle MCP initialize request', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst app = createMcpHttpApp({ claudePath: fixture.path });\n\n\t\t\t\tconst response = await app.request('/', {\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t\t\tAccept: 'application/json, text/event-stream',\n\t\t\t\t\t},\n\t\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t\tjsonrpc: '2.0',\n\t\t\t\t\t\tmethod: 'initialize',\n\t\t\t\t\t\tparams: {\n\t\t\t\t\t\t\tprotocolVersion: '1.0.0',\n\t\t\t\t\t\t\tcapabilities: {},\n\t\t\t\t\t\t\tclientInfo: { name: 'test-client', version: '1.0.0' },\n\t\t\t\t\t\t},\n\t\t\t\t\t\tid: 1,\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\texpect(response.status).toBe(200);\n\t\t\t\texpect(response.headers.get('content-type')).toBe('text/event-stream');\n\n\t\t\t\tconst text = await response.text();\n\t\t\t\texpect(text).toContain('event: message');\n\t\t\t\texpect(text).toContain('data: ');\n\n\t\t\t\t// Extract the JSON data from the SSE response\n\t\t\t\tconst dataLine = text.split('\\n').find((line) => line.startsWith('data: '));\n\t\t\t\texpect(dataLine).toBeDefined();\n\t\t\t\tconst data = JSON.parse(dataLine!.replace('data: ', ''));\n\n\t\t\t\texpect(data.jsonrpc).toBe('2.0');\n\t\t\t\texpect(data.id).toBe(1);\n\t\t\t\texpect(data.result).toHaveProperty('protocolVersion');\n\t\t\t\texpect(data.result).toHaveProperty('capabilities');\n\t\t\t\texpect(data.result.serverInfo).toEqual({ name, version });\n\t\t\t});\n\n\t\t\tit('should handle MCP callTool request for daily tool', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst app = createMcpHttpApp({ claudePath: fixture.path });\n\n\t\t\t\t// First initialize\n\t\t\t\tawait app.request('/', {\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t\t\tAccept: 'application/json, text/event-stream',\n\t\t\t\t\t},\n\t\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t\tjsonrpc: '2.0',\n\t\t\t\t\t\tmethod: 'initialize',\n\t\t\t\t\t\tparams: {\n\t\t\t\t\t\t\tprotocolVersion: '1.0.0',\n\t\t\t\t\t\t\tcapabilities: {},\n\t\t\t\t\t\t\tclientInfo: { name: 'test-client', version: '1.0.0' },\n\t\t\t\t\t\t},\n\t\t\t\t\t\tid: 1,\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\t// Then call tool\n\t\t\t\tconst response = await app.request('/', {\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t\t\tAccept: 'application/json, text/event-stream',\n\t\t\t\t\t},\n\t\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t\tjsonrpc: '2.0',\n\t\t\t\t\t\tmethod: 'tools/call',\n\t\t\t\t\t\tparams: {\n\t\t\t\t\t\t\tname: 'daily',\n\t\t\t\t\t\t\targuments: { mode: 'auto' },\n\t\t\t\t\t\t},\n\t\t\t\t\t\tid: 2,\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\texpect(response.status).toBe(200);\n\t\t\t\tconst text = await response.text();\n\n\t\t\t\texpect(text).toContain('event: message');\n\t\t\t\texpect(text).toContain('data: ');\n\n\t\t\t\t// Extract the JSON data from the SSE response\n\t\t\t\tconst dataLine = text.split('\\n').find((line) => line.startsWith('data: '));\n\t\t\t\texpect(dataLine).toBeDefined();\n\t\t\t\tconst data = JSON.parse(dataLine!.replace('data: ', ''));\n\n\t\t\t\texpect(data.jsonrpc).toBe('2.0');\n\t\t\t\texpect(data.id).toBe(2);\n\t\t\t\texpect(data.result).toHaveProperty('content');\n\t\t\t\texpect(Array.isArray(data.result.content)).toBe(true);\n\t\t\t});\n\t\t});\n\n\t\tdescribe('error handling', () => {\n\t\t\tit('should handle tool call with invalid arguments', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\t// Test with invalid mode enum value - MCP SDK returns isError response\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'daily',\n\t\t\t\t\targuments: { mode: 'invalid_mode' },\n\t\t\t\t});\n\n\t\t\t\texpect(result.isError).toBe(true);\n\t\t\t\texpect(result.content).toBeDefined();\n\t\t\t\tassert(Array.isArray(result.content));\n\t\t\t\tconst textContent = result.content[0] as { type: string; text: string };\n\t\t\t\texpect(textContent.type).toBe('text');\n\t\t\t\texpect(textContent.text).toContain('Invalid');\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should handle tool call with invalid date format', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\t// Test with invalid date format - MCP SDK returns isError response\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'daily',\n\t\t\t\t\targuments: { since: 'not-a-date', until: '2024-invalid' },\n\t\t\t\t});\n\n\t\t\t\texpect(result.isError).toBe(true);\n\t\t\t\texpect(result.content).toBeDefined();\n\t\t\t\tassert(Array.isArray(result.content));\n\t\t\t\tconst textContent = result.content[0] as { type: string; text: string };\n\t\t\t\texpect(textContent.type).toBe('text');\n\t\t\t\texpect(textContent.text).toContain('Date must be in YYYYMMDD format');\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should handle tool call with unknown tool name', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\t// Test with unknown tool name - MCP SDK returns isError response\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'unknown-tool',\n\t\t\t\t\targuments: {},\n\t\t\t\t});\n\n\t\t\t\texpect(result.isError).toBe(true);\n\t\t\t\texpect(result.content).toBeDefined();\n\t\t\t\tassert(Array.isArray(result.content));\n\t\t\t\tconst textContent = result.content[0] as { type: string; text: string };\n\t\t\t\texpect(textContent.type).toBe('text');\n\t\t\t\texpect(textContent.text).toContain('Tool unknown-tool not found');\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\t\t});\n\n\t\tdescribe('edge cases', () => {\n\t\t\tit('should handle empty data directory', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/.keep': '',\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'daily',\n\t\t\t\t\targuments: { mode: 'auto' },\n\t\t\t\t});\n\n\t\t\t\texpect(result).toHaveProperty('content');\n\t\t\t\texpect(Array.isArray(result.content)).toBe(true);\n\t\t\t\texpect(result.content).toHaveLength(1);\n\t\t\t\texpect((result.content as any)[0]).toHaveProperty('type', 'text');\n\n\t\t\t\tconst data = JSON.parse((result.content as any)[0].text as string);\n\t\t\t\texpect(data).toHaveProperty('daily');\n\t\t\t\texpect(data).toHaveProperty('totals');\n\t\t\t\texpect(Array.isArray(data.daily)).toBe(true);\n\t\t\t\texpect(data.daily).toHaveLength(0);\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should handle malformed JSONL files', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': 'invalid json\\n{\"valid\": \"json\"}',\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'daily',\n\t\t\t\t\targuments: { mode: 'auto' },\n\t\t\t\t});\n\n\t\t\t\texpect(result).toHaveProperty('content');\n\t\t\t\texpect(Array.isArray(result.content)).toBe(true);\n\t\t\t\texpect(result.content).toHaveLength(1);\n\n\t\t\t\tconst data = JSON.parse((result.content as any)[0].text as string);\n\t\t\t\texpect(data).toHaveProperty('daily');\n\t\t\t\texpect(data).toHaveProperty('totals');\n\t\t\t\texpect(Array.isArray(data.daily)).toBe(true);\n\t\t\t\t// Should still return data, as malformed lines are silently skipped\n\t\t\t\texpect(data.daily).toHaveLength(0);\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should handle missing Claude directory', async () => {\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: '/nonexistent/path' });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\tconst result = await client.callTool({\n\t\t\t\t\tname: 'daily',\n\t\t\t\t\targuments: { mode: 'auto' },\n\t\t\t\t});\n\n\t\t\t\texpect(result).toHaveProperty('content');\n\t\t\t\texpect(Array.isArray(result.content)).toBe(true);\n\t\t\t\texpect(result.content).toHaveLength(1);\n\n\t\t\t\tconst data = JSON.parse((result.content as any)[0].text as string);\n\t\t\t\texpect(data).toHaveProperty('daily');\n\t\t\t\texpect(data).toHaveProperty('totals');\n\t\t\t\texpect(Array.isArray(data.daily)).toBe(true);\n\t\t\t\texpect(data.daily).toHaveLength(0);\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\n\t\t\tit('should handle concurrent tool calls', async () => {\n\t\t\t\tawait using fixture = await createFixture({\n\t\t\t\t\t'projects/test-project/session1/usage.jsonl': JSON.stringify({\n\t\t\t\t\t\ttimestamp: '2024-01-01T12:00:00Z',\n\t\t\t\t\t\tcostUSD: 0.001,\n\t\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\t\tmessage: {\n\t\t\t\t\t\t\tmodel: 'claude-sonnet-4-20250514',\n\t\t\t\t\t\t\tusage: { input_tokens: 50, output_tokens: 10 },\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t});\n\n\t\t\t\tconst client = new Client({ name: 'test-client', version: '1.0.0' });\n\t\t\t\tconst server = createMcpServer({ claudePath: fixture.path });\n\n\t\t\t\tconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\n\n\t\t\t\tawait Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);\n\n\t\t\t\t// Call multiple tools concurrently\n\t\t\t\tconst [dailyResult, sessionResult, monthlyResult, blocksResult] = await Promise.all([\n\t\t\t\t\tclient.callTool({ name: 'daily', arguments: { mode: 'auto' } }),\n\t\t\t\t\tclient.callTool({ name: 'session', arguments: { mode: 'auto' } }),\n\t\t\t\t\tclient.callTool({ name: 'monthly', arguments: { mode: 'auto' } }),\n\t\t\t\t\tclient.callTool({ name: 'blocks', arguments: { mode: 'auto' } }),\n\t\t\t\t]);\n\n\t\t\t\texpect(dailyResult).toHaveProperty('content');\n\t\t\t\texpect(sessionResult).toHaveProperty('content');\n\t\t\t\texpect(monthlyResult).toHaveProperty('content');\n\t\t\t\texpect(blocksResult).toHaveProperty('content');\n\n\t\t\t\t// Verify all responses are valid JSON objects with expected structure\n\t\t\t\tconst dailyData = JSON.parse((dailyResult.content as any)[0].text as string);\n\t\t\t\tconst sessionData = JSON.parse((sessionResult.content as any)[0].text as string);\n\t\t\t\tconst monthlyData = JSON.parse((monthlyResult.content as any)[0].text as string);\n\t\t\t\tconst blocksData = JSON.parse((blocksResult.content as any)[0].text as string);\n\n\t\t\t\texpect(dailyData).toHaveProperty('daily');\n\t\t\t\texpect(Array.isArray(dailyData.daily)).toBe(true);\n\t\t\t\texpect(sessionData).toHaveProperty('sessions');\n\t\t\t\texpect(Array.isArray(sessionData.sessions)).toBe(true);\n\t\t\t\texpect(monthlyData).toHaveProperty('monthly');\n\t\t\t\texpect(Array.isArray(monthlyData.monthly)).toBe(true);\n\t\t\t\texpect(blocksData).toHaveProperty('blocks');\n\t\t\t\texpect(Array.isArray(blocksData.blocks)).toBe(true);\n\n\t\t\t\tawait client.close();\n\t\t\t\tawait server.close();\n\t\t\t});\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/mcp/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"jsx\": \"react-jsx\",\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"moduleDetection\": \"force\",\n\t\t\"module\": \"Preserve\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"types\": [\"vitest/globals\", \"vitest/importMeta\"],\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"allowJs\": true,\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noPropertyAccessFromIndexSignature\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t\"noUnusedLocals\": false,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noEmit\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"exclude\": [\"dist\"]\n}\n"
  },
  {
    "path": "apps/mcp/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n\tentry: ['src/index.ts'],\n\toutDir: 'dist',\n\tformat: 'esm',\n\tclean: true,\n\tsourcemap: false,\n\tminify: 'dce-only',\n\ttreeshake: true,\n\tfixedExtension: false,\n\tdts: {\n\t\ttsgo: true,\n\t},\n\tpublint: true,\n\tunused: true,\n\texports: {\n\t\tdevExports: true,\n\t},\n\tnodeProtocol: true,\n\tdefine: {\n\t\t'import.meta.vitest': 'undefined',\n\t},\n});\n"
  },
  {
    "path": "apps/mcp/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\twatch: false,\n\t\tincludeSource: ['src/**/*.{js,ts}'],\n\t\tglobals: true,\n\t},\n});\n"
  },
  {
    "path": "apps/opencode/CLAUDE.md",
    "content": "# OpenCode CLI Notes\n\n## Log Sources\n\n- 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`).\n- Each message is stored as an individual JSON file (not JSONL like Claude or Codex).\n- Message structure includes `tokens.input`, `tokens.output`, `tokens.cache.read`, and `tokens.cache.write`.\n\n## Token Fields\n\n- `input`: total input tokens sent to the model.\n- `output`: output tokens (completion text).\n- `cache.read`: cached portion of the input (prompt-caching).\n- `cache.write`: cache creation tokens.\n- Pre-calculated `cost` field may be present in OpenCode messages.\n\n## Cost Calculation\n\n- OpenCode messages may include pre-calculated `cost` field in USD.\n- When `cost` is not present, costs should be calculated using model pricing data.\n- Token mapping:\n  - `inputTokens` ← `tokens.input`\n  - `outputTokens` ← `tokens.output`\n  - `cacheReadInputTokens` ← `tokens.cache.read`\n  - `cacheCreationInputTokens` ← `tokens.cache.write`\n\n## CLI Usage\n\n- Treat OpenCode as a sibling to `apps/ccusage` and `apps/codex`.\n- Reuse shared packages (`@ccusage/terminal`, `@ccusage/internal`) wherever possible.\n- OpenCode is packaged as a bundled CLI. Keep every runtime dependency in `devDependencies`.\n- Entry point uses Gunshi framework.\n- Data discovery relies on `OPENCODE_DATA_DIR` environment variable.\n- Default path: `~/.local/share/opencode`.\n\n## Testing Notes\n\n- Tests rely on `fs-fixture` with `using` to ensure cleanup.\n- All vitest blocks live alongside implementation files via `if (import.meta.vitest != null)`.\n- Vitest globals are enabled - use `describe`, `it`, `expect` directly without imports.\n"
  },
  {
    "path": "apps/opencode/README.md",
    "content": "<div align=\"center\">\n    <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.svg\" alt=\"ccusage logo\" width=\"256\" height=\"256\">\n    <h1>@ccusage/opencode</h1>\n</div>\n\n<p align=\"center\">\n    <a href=\"https://socket.dev/api/npm/package/@ccusage/opencode\"><img src=\"https://socket.dev/api/badge/npm/package/@ccusage/opencode\" alt=\"Socket Badge\" /></a>\n    <a href=\"https://npmjs.com/package/@ccusage/opencode\"><img src=\"https://img.shields.io/npm/v/@ccusage/opencode?color=yellow\" alt=\"npm version\" /></a>\n    <a href=\"https://tanstack.com/stats/npm?packageGroups=%5B%7B%22packages%22:%5B%7B%22name%22:%22@ccusage/opencode%22%7D%5D%7D%5D&range=30-days&transform=none&binType=daily&showDataMode=all&height=400\"><img src=\"https://img.shields.io/npm/dt/@ccusage/opencode\" alt=\"NPM Downloads\" /></a>\n    <a href=\"https://packagephobia.com/result?p=@ccusage/opencode\"><img src=\"https://packagephobia.com/badge?p=@ccusage/opencode\" alt=\"install size\" /></a>\n    <a href=\"https://deepwiki.com/ryoppippi/ccusage\"><img src=\"https://img.shields.io/badge/DeepWiki-ryoppippi%2Fccusage-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==\" alt=\"DeepWiki\"></a>\n</p>\n\n> Analyze [OpenCode](https://github.com/opencode-ai/opencode) usage logs with the same reporting experience as <code>ccusage</code>.\n\n## Quick Start\n\n```bash\n# Recommended - always include @latest\nnpx @ccusage/opencode@latest --help\nbunx @ccusage/opencode@latest --help\n\n# Alternative package runners\npnpm dlx @ccusage/opencode\npnpx @ccusage/opencode\n\n# Using deno (with security flags)\ndeno run -E -R=$HOME/.local/share/opencode/ -S=homedir -N='raw.githubusercontent.com:443' npm:@ccusage/opencode@latest --help\n```\n\n### Recommended: Shell Alias\n\nSince `npx @ccusage/opencode@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias:\n\n```bash\n# bash/zsh: alias ccusage-opencode='bunx @ccusage/opencode@latest'\n# fish:     alias ccusage-opencode 'bunx @ccusage/opencode@latest'\n\n# Then simply run:\nccusage-opencode daily\nccusage-opencode monthly --json\n```\n\n> 💡 The CLI looks for OpenCode usage data under `OPENCODE_DATA_DIR` (defaults to `~/.local/share/opencode`).\n\n## Common Commands\n\n```bash\n# Daily usage grouped by date (default command)\nnpx @ccusage/opencode@latest daily\n\n# Weekly usage grouped by ISO week\nnpx @ccusage/opencode@latest weekly\n\n# Monthly usage grouped by month\nnpx @ccusage/opencode@latest monthly\n\n# Session-level detailed report\nnpx @ccusage/opencode@latest session\n\n# JSON output for scripting\nnpx @ccusage/opencode@latest daily --json\n\n# Compact mode for screenshots/sharing\nnpx @ccusage/opencode@latest daily --compact\n```\n\nUseful environment variables:\n\n- `OPENCODE_DATA_DIR` – override the OpenCode data directory (defaults to `~/.local/share/opencode`)\n- `LOG_LEVEL` – control consola log verbosity (0 silent … 5 trace)\n\n## Features\n\n- 📊 **Daily Reports**: View token usage and costs aggregated by date\n- 📅 **Weekly Reports**: View usage grouped by ISO week (YYYY-Www)\n- 🗓️ **Monthly Reports**: View usage aggregated by month (YYYY-MM)\n- 💬 **Session Reports**: View usage grouped by conversation sessions\n- 📈 **Responsive Tables**: Automatic layout adjustment for terminal width\n- 🤖 **Model Tracking**: See which Claude models you're using (Opus, Sonnet, Haiku, etc.)\n- 💵 **Accurate Cost Calculation**: Uses LiteLLM pricing database to calculate costs from token data\n- 🔄 **Cache Token Support**: Tracks and displays cache creation and cache read tokens separately\n- 📄 **JSON Output**: Export data in structured JSON format with `--json`\n- 📱 **Compact Mode**: Use `--compact` flag for narrow terminals, perfect for screenshots\n\n## Cost Calculation\n\nOpenCode 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.\n\n## Data Location\n\nOpenCode stores usage data in:\n\n- **Messages**: `~/.local/share/opencode/storage/message/{sessionID}/msg_{messageID}.json`\n- **Sessions**: `~/.local/share/opencode/storage/session/{projectHash}/{sessionID}.json`\n\nEach message file contains token counts (`input`, `output`, `cache.read`, `cache.write`) and model information.\n\n## Documentation\n\nFor detailed guides and examples, visit **[ccusage.com](https://ccusage.com/)**.\n\n## Sponsors\n\n### Featured Sponsor\n\nCheck out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)\n\n<p align=\"center\">\n    <a href=\"https://www.youtube.com/watch?v=Ak6qpQ5qdgk\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/ccusage_thumbnail.png\" alt=\"ccusage: The Claude Code cost scorecard that went viral\" width=\"600\">\n    </a>\n</p>\n\n<p align=\"center\">\n    <a href=\"https://github.com/sponsors/ryoppippi\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/sponsors@main/sponsors.svg\">\n    </a>\n</p>\n\n## License\n\nMIT © [@ryoppippi](https://github.com/ryoppippi)\n"
  },
  {
    "path": "apps/opencode/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\n/** @type {import('eslint').Linter.FlatConfig[]} */\nconst config = ryoppippi(\n\t{\n\t\ttype: 'app',\n\t\tstylistic: false,\n\t},\n\t{\n\t\trules: {\n\t\t\t'test/no-importing-vitest-globals': 'error',\n\t\t},\n\t},\n);\n\nexport default config;\n"
  },
  {
    "path": "apps/opencode/package.json",
    "content": "{\n\t\"name\": \"@ccusage/opencode\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"description\": \"Usage analysis tool for OpenCode sessions\",\n\t\"contributors\": [\n\t\t\"ryoppippi\",\n\t\t\"AnishDe12020\"\n\t],\n\t\"license\": \"MIT\",\n\t\"funding\": \"https://github.com/ryoppippi/ccusage?sponsor=1\",\n\t\"homepage\": \"https://github.com/ryoppippi/ccusage#readme\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/ryoppippi/ccusage.git\",\n\t\t\"directory\": \"apps/opencode\"\n\t},\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/ryoppippi/ccusage/issues\"\n\t},\n\t\"main\": \"./dist/index.js\",\n\t\"module\": \"./dist/index.js\",\n\t\"bin\": {\n\t\t\"ccusage-opencode\": \"./src/index.ts\"\n\t},\n\t\"files\": [\n\t\t\"dist\"\n\t],\n\t\"publishConfig\": {\n\t\t\"bin\": {\n\t\t\t\"ccusage-opencode\": \"./dist/index.js\"\n\t\t}\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=20.19.4\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"tsdown\",\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"prepack\": \"pnpm run build && clean-pkg-json\",\n\t\t\"prerelease\": \"pnpm run lint && pnpm run typecheck && pnpm run build\",\n\t\t\"start\": \"bun ./src/index.ts\",\n\t\t\"test\": \"TZ=UTC vitest\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@ccusage/internal\": \"workspace:*\",\n\t\t\"@ccusage/terminal\": \"workspace:*\",\n\t\t\"@praha/byethrow\": \"catalog:runtime\",\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"@typescript/native-preview\": \"catalog:types\",\n\t\t\"clean-pkg-json\": \"catalog:release\",\n\t\t\"es-toolkit\": \"catalog:runtime\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"fast-sort\": \"catalog:runtime\",\n\t\t\"fs-fixture\": \"catalog:testing\",\n\t\t\"gunshi\": \"catalog:runtime\",\n\t\t\"path-type\": \"catalog:runtime\",\n\t\t\"picocolors\": \"catalog:runtime\",\n\t\t\"tinyglobby\": \"catalog:runtime\",\n\t\t\"tsdown\": \"catalog:build\",\n\t\t\"unplugin-macros\": \"catalog:build\",\n\t\t\"unplugin-unused\": \"catalog:build\",\n\t\t\"valibot\": \"catalog:runtime\",\n\t\t\"vitest\": \"catalog:testing\"\n\t}\n}\n"
  },
  {
    "path": "apps/opencode/src/commands/daily.ts",
    "content": "import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { groupBy } from 'es-toolkit';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { calculateCostForEntry } from '../cost-utils.ts';\nimport { loadOpenCodeMessages } from '../data-loader.ts';\nimport { logger } from '../logger.ts';\n\nconst TABLE_COLUMN_COUNT = 8;\n\nexport const dailyCommand = define({\n\tname: 'daily',\n\tdescription: 'Show OpenCode token usage grouped by day',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'j',\n\t\t\tdescription: 'Output in JSON format',\n\t\t},\n\t\tcompact: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Force compact table mode',\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\n\t\tconst entries = await loadOpenCodeMessages();\n\n\t\tif (entries.length === 0) {\n\t\t\tconst output = jsonOutput\n\t\t\t\t? JSON.stringify({ daily: [], totals: null })\n\t\t\t\t: 'No OpenCode usage data found.';\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(output);\n\t\t\treturn;\n\t\t}\n\n\t\tusing fetcher = new LiteLLMPricingFetcher({ offline: false, logger });\n\n\t\tconst entriesByDate = groupBy(entries, (entry) => entry.timestamp.toISOString().split('T')[0]!);\n\n\t\tconst dailyData: Array<{\n\t\t\tdate: string;\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\ttotalTokens: number;\n\t\t\ttotalCost: number;\n\t\t\tmodelsUsed: string[];\n\t\t}> = [];\n\n\t\tfor (const [date, dayEntries] of Object.entries(entriesByDate)) {\n\t\t\tlet inputTokens = 0;\n\t\t\tlet outputTokens = 0;\n\t\t\tlet cacheCreationTokens = 0;\n\t\t\tlet cacheReadTokens = 0;\n\t\t\tlet totalCost = 0;\n\t\t\tconst modelsSet = new Set<string>();\n\n\t\t\tfor (const entry of dayEntries) {\n\t\t\t\tinputTokens += entry.usage.inputTokens;\n\t\t\t\toutputTokens += entry.usage.outputTokens;\n\t\t\t\tcacheCreationTokens += entry.usage.cacheCreationInputTokens;\n\t\t\t\tcacheReadTokens += entry.usage.cacheReadInputTokens;\n\t\t\t\ttotalCost += await calculateCostForEntry(entry, fetcher);\n\t\t\t\tmodelsSet.add(entry.model);\n\t\t\t}\n\n\t\t\tconst totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;\n\n\t\t\tdailyData.push({\n\t\t\t\tdate,\n\t\t\t\tinputTokens,\n\t\t\t\toutputTokens,\n\t\t\t\tcacheCreationTokens,\n\t\t\t\tcacheReadTokens,\n\t\t\t\ttotalTokens,\n\t\t\t\ttotalCost,\n\t\t\t\tmodelsUsed: Array.from(modelsSet),\n\t\t\t});\n\t\t}\n\n\t\tdailyData.sort((a, b) => a.date.localeCompare(b.date));\n\n\t\tconst totals = {\n\t\t\tinputTokens: dailyData.reduce((sum, d) => sum + d.inputTokens, 0),\n\t\t\toutputTokens: dailyData.reduce((sum, d) => sum + d.outputTokens, 0),\n\t\t\tcacheCreationTokens: dailyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0),\n\t\t\tcacheReadTokens: dailyData.reduce((sum, d) => sum + d.cacheReadTokens, 0),\n\t\t\ttotalTokens: dailyData.reduce((sum, d) => sum + d.totalTokens, 0),\n\t\t\ttotalCost: dailyData.reduce((sum, d) => sum + d.totalCost, 0),\n\t\t};\n\n\t\tif (jsonOutput) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tdaily: dailyData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log('\\n📊 OpenCode Token Usage Report - Daily\\n');\n\n\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\thead: [\n\t\t\t\t'Date',\n\t\t\t\t'Models',\n\t\t\t\t'Input',\n\t\t\t\t'Output',\n\t\t\t\t'Cache Create',\n\t\t\t\t'Cache Read',\n\t\t\t\t'Total Tokens',\n\t\t\t\t'Cost (USD)',\n\t\t\t],\n\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\tcompactHead: ['Date', 'Models', 'Input', 'Output', 'Cost (USD)'],\n\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right'],\n\t\t\tcompactThreshold: 100,\n\t\t\tforceCompact: Boolean(ctx.values.compact),\n\t\t\tstyle: { head: ['cyan'] },\n\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t});\n\n\t\tfor (const data of dailyData) {\n\t\t\ttable.push([\n\t\t\t\tdata.date,\n\t\t\t\tformatModelsDisplayMultiline(data.modelsUsed),\n\t\t\t\tformatNumber(data.inputTokens),\n\t\t\t\tformatNumber(data.outputTokens),\n\t\t\t\tformatNumber(data.cacheCreationTokens),\n\t\t\t\tformatNumber(data.cacheReadTokens),\n\t\t\t\tformatNumber(data.totalTokens),\n\t\t\t\tformatCurrency(data.totalCost),\n\t\t\t]);\n\t\t}\n\n\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\ttable.push([\n\t\t\tpc.yellow('Total'),\n\t\t\t'',\n\t\t\tpc.yellow(formatNumber(totals.inputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.outputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheCreationTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheReadTokens)),\n\t\t\tpc.yellow(formatNumber(totals.totalTokens)),\n\t\t\tpc.yellow(formatCurrency(totals.totalCost)),\n\t\t]);\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(table.toString());\n\n\t\tif (table.isCompactMode()) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('\\nRunning in Compact Mode');\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('Expand terminal width to see cache metrics and total tokens');\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/opencode/src/commands/index.ts",
    "content": "export { dailyCommand } from './daily';\nexport { monthlyCommand } from './monthly';\nexport { sessionCommand } from './session';\nexport { weeklyCommand } from './weekly';\n"
  },
  {
    "path": "apps/opencode/src/commands/monthly.ts",
    "content": "import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { groupBy } from 'es-toolkit';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { calculateCostForEntry } from '../cost-utils.ts';\nimport { loadOpenCodeMessages } from '../data-loader.ts';\nimport { logger } from '../logger.ts';\n\nconst TABLE_COLUMN_COUNT = 8;\n\nexport const monthlyCommand = define({\n\tname: 'monthly',\n\tdescription: 'Show OpenCode token usage grouped by month',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'j',\n\t\t\tdescription: 'Output in JSON format',\n\t\t},\n\t\tcompact: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Force compact table mode',\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\n\t\tconst entries = await loadOpenCodeMessages();\n\n\t\tif (entries.length === 0) {\n\t\t\tconst output = jsonOutput\n\t\t\t\t? JSON.stringify({ monthly: [], totals: null })\n\t\t\t\t: 'No OpenCode usage data found.';\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(output);\n\t\t\treturn;\n\t\t}\n\n\t\tusing fetcher = new LiteLLMPricingFetcher({ offline: false, logger });\n\n\t\tconst entriesByMonth = groupBy(entries, (entry) => entry.timestamp.toISOString().slice(0, 7));\n\n\t\tconst monthlyData: Array<{\n\t\t\tmonth: string;\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\ttotalTokens: number;\n\t\t\ttotalCost: number;\n\t\t\tmodelsUsed: string[];\n\t\t}> = [];\n\n\t\tfor (const [month, monthEntries] of Object.entries(entriesByMonth)) {\n\t\t\tlet inputTokens = 0;\n\t\t\tlet outputTokens = 0;\n\t\t\tlet cacheCreationTokens = 0;\n\t\t\tlet cacheReadTokens = 0;\n\t\t\tlet totalCost = 0;\n\t\t\tconst modelsSet = new Set<string>();\n\n\t\t\tfor (const entry of monthEntries) {\n\t\t\t\tinputTokens += entry.usage.inputTokens;\n\t\t\t\toutputTokens += entry.usage.outputTokens;\n\t\t\t\tcacheCreationTokens += entry.usage.cacheCreationInputTokens;\n\t\t\t\tcacheReadTokens += entry.usage.cacheReadInputTokens;\n\t\t\t\ttotalCost += await calculateCostForEntry(entry, fetcher);\n\t\t\t\tmodelsSet.add(entry.model);\n\t\t\t}\n\n\t\t\tconst totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;\n\n\t\t\tmonthlyData.push({\n\t\t\t\tmonth,\n\t\t\t\tinputTokens,\n\t\t\t\toutputTokens,\n\t\t\t\tcacheCreationTokens,\n\t\t\t\tcacheReadTokens,\n\t\t\t\ttotalTokens,\n\t\t\t\ttotalCost,\n\t\t\t\tmodelsUsed: Array.from(modelsSet),\n\t\t\t});\n\t\t}\n\n\t\tmonthlyData.sort((a, b) => a.month.localeCompare(b.month));\n\n\t\tconst totals = {\n\t\t\tinputTokens: monthlyData.reduce((sum, d) => sum + d.inputTokens, 0),\n\t\t\toutputTokens: monthlyData.reduce((sum, d) => sum + d.outputTokens, 0),\n\t\t\tcacheCreationTokens: monthlyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0),\n\t\t\tcacheReadTokens: monthlyData.reduce((sum, d) => sum + d.cacheReadTokens, 0),\n\t\t\ttotalTokens: monthlyData.reduce((sum, d) => sum + d.totalTokens, 0),\n\t\t\ttotalCost: monthlyData.reduce((sum, d) => sum + d.totalCost, 0),\n\t\t};\n\n\t\tif (jsonOutput) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tmonthly: monthlyData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log('\\n📊 OpenCode Token Usage Report - Monthly\\n');\n\n\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\thead: [\n\t\t\t\t'Month',\n\t\t\t\t'Models',\n\t\t\t\t'Input',\n\t\t\t\t'Output',\n\t\t\t\t'Cache Create',\n\t\t\t\t'Cache Read',\n\t\t\t\t'Total Tokens',\n\t\t\t\t'Cost (USD)',\n\t\t\t],\n\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\tcompactHead: ['Month', 'Models', 'Input', 'Output', 'Cost (USD)'],\n\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right'],\n\t\t\tcompactThreshold: 100,\n\t\t\tforceCompact: Boolean(ctx.values.compact),\n\t\t\tstyle: { head: ['cyan'] },\n\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t});\n\n\t\tfor (const data of monthlyData) {\n\t\t\ttable.push([\n\t\t\t\tdata.month,\n\t\t\t\tformatModelsDisplayMultiline(data.modelsUsed),\n\t\t\t\tformatNumber(data.inputTokens),\n\t\t\t\tformatNumber(data.outputTokens),\n\t\t\t\tformatNumber(data.cacheCreationTokens),\n\t\t\t\tformatNumber(data.cacheReadTokens),\n\t\t\t\tformatNumber(data.totalTokens),\n\t\t\t\tformatCurrency(data.totalCost),\n\t\t\t]);\n\t\t}\n\n\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\ttable.push([\n\t\t\tpc.yellow('Total'),\n\t\t\t'',\n\t\t\tpc.yellow(formatNumber(totals.inputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.outputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheCreationTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheReadTokens)),\n\t\t\tpc.yellow(formatNumber(totals.totalTokens)),\n\t\t\tpc.yellow(formatCurrency(totals.totalCost)),\n\t\t]);\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(table.toString());\n\n\t\tif (table.isCompactMode()) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('\\nRunning in Compact Mode');\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('Expand terminal width to see cache metrics and total tokens');\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/opencode/src/commands/session.ts",
    "content": "import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { groupBy } from 'es-toolkit';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { calculateCostForEntry } from '../cost-utils.ts';\nimport { loadOpenCodeMessages, loadOpenCodeSessions } from '../data-loader.ts';\nimport { logger } from '../logger.ts';\n\nconst TABLE_COLUMN_COUNT = 8;\n\nexport const sessionCommand = define({\n\tname: 'session',\n\tdescription: 'Show OpenCode token usage grouped by session',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'j',\n\t\t\tdescription: 'Output in JSON format',\n\t\t},\n\t\tcompact: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Force compact table mode',\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\n\t\tconst [entries, sessionMetadataMap] = await Promise.all([\n\t\t\tloadOpenCodeMessages(),\n\t\t\tloadOpenCodeSessions(),\n\t\t]);\n\n\t\tif (entries.length === 0) {\n\t\t\tconst output = jsonOutput\n\t\t\t\t? JSON.stringify({ sessions: [], totals: null })\n\t\t\t\t: 'No OpenCode usage data found.';\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(output);\n\t\t\treturn;\n\t\t}\n\n\t\tusing fetcher = new LiteLLMPricingFetcher({ offline: false, logger });\n\n\t\tconst entriesBySession = groupBy(entries, (entry) => entry.sessionID);\n\n\t\ttype SessionData = {\n\t\t\tsessionID: string;\n\t\t\tsessionTitle: string;\n\t\t\tparentID: string | null;\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\ttotalTokens: number;\n\t\t\ttotalCost: number;\n\t\t\tmodelsUsed: string[];\n\t\t\tlastActivity: Date;\n\t\t};\n\n\t\tconst sessionData: SessionData[] = [];\n\n\t\tfor (const [sessionID, sessionEntries] of Object.entries(entriesBySession)) {\n\t\t\tlet inputTokens = 0;\n\t\t\tlet outputTokens = 0;\n\t\t\tlet cacheCreationTokens = 0;\n\t\t\tlet cacheReadTokens = 0;\n\t\t\tlet totalCost = 0;\n\t\t\tconst modelsSet = new Set<string>();\n\t\t\tlet lastActivity = sessionEntries[0]!.timestamp;\n\n\t\t\tfor (const entry of sessionEntries) {\n\t\t\t\tinputTokens += entry.usage.inputTokens;\n\t\t\t\toutputTokens += entry.usage.outputTokens;\n\t\t\t\tcacheCreationTokens += entry.usage.cacheCreationInputTokens;\n\t\t\t\tcacheReadTokens += entry.usage.cacheReadInputTokens;\n\t\t\t\ttotalCost += await calculateCostForEntry(entry, fetcher);\n\t\t\t\tmodelsSet.add(entry.model);\n\n\t\t\t\tif (entry.timestamp > lastActivity) {\n\t\t\t\t\tlastActivity = entry.timestamp;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;\n\n\t\t\tconst metadata = sessionMetadataMap.get(sessionID);\n\n\t\t\tsessionData.push({\n\t\t\t\tsessionID,\n\t\t\t\tsessionTitle: metadata?.title ?? sessionID,\n\t\t\t\tparentID: metadata?.parentID ?? null,\n\t\t\t\tinputTokens,\n\t\t\t\toutputTokens,\n\t\t\t\tcacheCreationTokens,\n\t\t\t\tcacheReadTokens,\n\t\t\t\ttotalTokens,\n\t\t\t\ttotalCost,\n\t\t\t\tmodelsUsed: Array.from(modelsSet),\n\t\t\t\tlastActivity,\n\t\t\t});\n\t\t}\n\n\t\tsessionData.sort((a, b) => a.lastActivity.getTime() - b.lastActivity.getTime());\n\n\t\tconst totals = {\n\t\t\tinputTokens: sessionData.reduce((sum, s) => sum + s.inputTokens, 0),\n\t\t\toutputTokens: sessionData.reduce((sum, s) => sum + s.outputTokens, 0),\n\t\t\tcacheCreationTokens: sessionData.reduce((sum, s) => sum + s.cacheCreationTokens, 0),\n\t\t\tcacheReadTokens: sessionData.reduce((sum, s) => sum + s.cacheReadTokens, 0),\n\t\t\ttotalTokens: sessionData.reduce((sum, s) => sum + s.totalTokens, 0),\n\t\t\ttotalCost: sessionData.reduce((sum, s) => sum + s.totalCost, 0),\n\t\t};\n\n\t\tif (jsonOutput) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tsessions: sessionData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log('\\n📊 OpenCode Token Usage Report - Sessions\\n');\n\n\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\thead: [\n\t\t\t\t'Session',\n\t\t\t\t'Models',\n\t\t\t\t'Input',\n\t\t\t\t'Output',\n\t\t\t\t'Cache Create',\n\t\t\t\t'Cache Read',\n\t\t\t\t'Total Tokens',\n\t\t\t\t'Cost (USD)',\n\t\t\t],\n\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\tcompactHead: ['Session', 'Models', 'Input', 'Output', 'Cost (USD)'],\n\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right'],\n\t\t\tcompactThreshold: 100,\n\t\t\tforceCompact: Boolean(ctx.values.compact),\n\t\t\tstyle: { head: ['cyan'] },\n\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t});\n\n\t\tconst sessionsByParent = groupBy(sessionData, (s) => s.parentID ?? 'root');\n\t\tconst parentSessions = sessionsByParent.root ?? [];\n\t\tdelete sessionsByParent.root;\n\n\t\tfor (const parentSession of parentSessions) {\n\t\t\tconst isParent = sessionsByParent[parentSession.sessionID] != null;\n\t\t\tconst displayTitle = isParent\n\t\t\t\t? pc.bold(parentSession.sessionTitle)\n\t\t\t\t: parentSession.sessionTitle;\n\n\t\t\ttable.push([\n\t\t\t\tdisplayTitle,\n\t\t\t\tformatModelsDisplayMultiline(parentSession.modelsUsed),\n\t\t\t\tformatNumber(parentSession.inputTokens),\n\t\t\t\tformatNumber(parentSession.outputTokens),\n\t\t\t\tformatNumber(parentSession.cacheCreationTokens),\n\t\t\t\tformatNumber(parentSession.cacheReadTokens),\n\t\t\t\tformatNumber(parentSession.totalTokens),\n\t\t\t\tformatCurrency(parentSession.totalCost),\n\t\t\t]);\n\n\t\t\tconst subSessions = sessionsByParent[parentSession.sessionID];\n\t\t\tif (subSessions != null && subSessions.length > 0) {\n\t\t\t\tfor (const subSession of subSessions) {\n\t\t\t\t\ttable.push([\n\t\t\t\t\t\t`  ↳ ${subSession.sessionTitle}`,\n\t\t\t\t\t\tformatModelsDisplayMultiline(subSession.modelsUsed),\n\t\t\t\t\t\tformatNumber(subSession.inputTokens),\n\t\t\t\t\t\tformatNumber(subSession.outputTokens),\n\t\t\t\t\t\tformatNumber(subSession.cacheCreationTokens),\n\t\t\t\t\t\tformatNumber(subSession.cacheReadTokens),\n\t\t\t\t\t\tformatNumber(subSession.totalTokens),\n\t\t\t\t\t\tformatCurrency(subSession.totalCost),\n\t\t\t\t\t]);\n\t\t\t\t}\n\n\t\t\t\tconst subtotalInputTokens =\n\t\t\t\t\tparentSession.inputTokens + subSessions.reduce((sum, s) => sum + s.inputTokens, 0);\n\t\t\t\tconst subtotalOutputTokens =\n\t\t\t\t\tparentSession.outputTokens + subSessions.reduce((sum, s) => sum + s.outputTokens, 0);\n\t\t\t\tconst subtotalCacheCreationTokens =\n\t\t\t\t\tparentSession.cacheCreationTokens +\n\t\t\t\t\tsubSessions.reduce((sum, s) => sum + s.cacheCreationTokens, 0);\n\t\t\t\tconst subtotalCacheReadTokens =\n\t\t\t\t\tparentSession.cacheReadTokens +\n\t\t\t\t\tsubSessions.reduce((sum, s) => sum + s.cacheReadTokens, 0);\n\t\t\t\tconst subtotalTotalTokens =\n\t\t\t\t\tparentSession.totalTokens + subSessions.reduce((sum, s) => sum + s.totalTokens, 0);\n\t\t\t\tconst subtotalCost =\n\t\t\t\t\tparentSession.totalCost + subSessions.reduce((sum, s) => sum + s.totalCost, 0);\n\n\t\t\t\ttable.push([\n\t\t\t\t\tpc.dim('  Total (with subagents)'),\n\t\t\t\t\t'',\n\t\t\t\t\tpc.yellow(formatNumber(subtotalInputTokens)),\n\t\t\t\t\tpc.yellow(formatNumber(subtotalOutputTokens)),\n\t\t\t\t\tpc.yellow(formatNumber(subtotalCacheCreationTokens)),\n\t\t\t\t\tpc.yellow(formatNumber(subtotalCacheReadTokens)),\n\t\t\t\t\tpc.yellow(formatNumber(subtotalTotalTokens)),\n\t\t\t\t\tpc.yellow(formatCurrency(subtotalCost)),\n\t\t\t\t]);\n\t\t\t}\n\t\t}\n\n\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\ttable.push([\n\t\t\tpc.yellow('Total'),\n\t\t\t'',\n\t\t\tpc.yellow(formatNumber(totals.inputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.outputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheCreationTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheReadTokens)),\n\t\t\tpc.yellow(formatNumber(totals.totalTokens)),\n\t\t\tpc.yellow(formatCurrency(totals.totalCost)),\n\t\t]);\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(table.toString());\n\n\t\tif (table.isCompactMode()) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('\\nRunning in Compact Mode');\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('Expand terminal width to see cache metrics and total tokens');\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/opencode/src/commands/weekly.ts",
    "content": "import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';\nimport {\n\taddEmptySeparatorRow,\n\tformatCurrency,\n\tformatDateCompact,\n\tformatModelsDisplayMultiline,\n\tformatNumber,\n\tResponsiveTable,\n} from '@ccusage/terminal/table';\nimport { groupBy } from 'es-toolkit';\nimport { define } from 'gunshi';\nimport pc from 'picocolors';\nimport { calculateCostForEntry } from '../cost-utils.ts';\nimport { loadOpenCodeMessages } from '../data-loader.ts';\nimport { logger } from '../logger.ts';\n\nconst TABLE_COLUMN_COUNT = 8;\n\n/**\n * Get ISO week number for a date\n * ISO week starts on Monday, first week contains Jan 4th\n * @param date - Date to get ISO week for\n * @returns Week string in format YYYY-Www (e.g., \"2025-W51\")\n */\nfunction getISOWeek(date: Date): string {\n\t// Copy date to avoid mutating original\n\tconst d = new Date(date.getTime());\n\n\t// Set to nearest Thursday: current date + 4 - current day number\n\t// Make Sunday's day number 7\n\tconst dayNum = d.getDay() || 7;\n\td.setDate(d.getDate() + 4 - dayNum);\n\n\t// Get first day of year\n\tconst yearStart = new Date(d.getFullYear(), 0, 1);\n\n\t// Calculate full weeks to nearest Thursday\n\tconst weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);\n\n\t// Return formatted string\n\treturn `${d.getFullYear()}-W${String(weekNo).padStart(2, '0')}`;\n}\n\nexport const weeklyCommand = define({\n\tname: 'weekly',\n\tdescription: 'Show OpenCode token usage grouped by week (ISO week format)',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'j',\n\t\t\tdescription: 'Output in JSON format',\n\t\t},\n\t\tcompact: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Force compact table mode',\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst jsonOutput = Boolean(ctx.values.json);\n\n\t\tconst entries = await loadOpenCodeMessages();\n\n\t\tif (entries.length === 0) {\n\t\t\tconst output = jsonOutput\n\t\t\t\t? JSON.stringify({ weekly: [], totals: null })\n\t\t\t\t: 'No OpenCode usage data found.';\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(output);\n\t\t\treturn;\n\t\t}\n\n\t\tusing fetcher = new LiteLLMPricingFetcher({ offline: false, logger });\n\n\t\tconst entriesByWeek = groupBy(entries, (entry) => getISOWeek(entry.timestamp));\n\n\t\tconst weeklyData: Array<{\n\t\t\tweek: string;\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\ttotalTokens: number;\n\t\t\ttotalCost: number;\n\t\t\tmodelsUsed: string[];\n\t\t}> = [];\n\n\t\tfor (const [week, weekEntries] of Object.entries(entriesByWeek)) {\n\t\t\tlet inputTokens = 0;\n\t\t\tlet outputTokens = 0;\n\t\t\tlet cacheCreationTokens = 0;\n\t\t\tlet cacheReadTokens = 0;\n\t\t\tlet totalCost = 0;\n\t\t\tconst modelsSet = new Set<string>();\n\n\t\t\tfor (const entry of weekEntries) {\n\t\t\t\tinputTokens += entry.usage.inputTokens;\n\t\t\t\toutputTokens += entry.usage.outputTokens;\n\t\t\t\tcacheCreationTokens += entry.usage.cacheCreationInputTokens;\n\t\t\t\tcacheReadTokens += entry.usage.cacheReadInputTokens;\n\t\t\t\ttotalCost += await calculateCostForEntry(entry, fetcher);\n\t\t\t\tmodelsSet.add(entry.model);\n\t\t\t}\n\n\t\t\tconst totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;\n\n\t\t\tweeklyData.push({\n\t\t\t\tweek,\n\t\t\t\tinputTokens,\n\t\t\t\toutputTokens,\n\t\t\t\tcacheCreationTokens,\n\t\t\t\tcacheReadTokens,\n\t\t\t\ttotalTokens,\n\t\t\t\ttotalCost,\n\t\t\t\tmodelsUsed: Array.from(modelsSet),\n\t\t\t});\n\t\t}\n\n\t\tweeklyData.sort((a, b) => a.week.localeCompare(b.week));\n\n\t\tconst totals = {\n\t\t\tinputTokens: weeklyData.reduce((sum, d) => sum + d.inputTokens, 0),\n\t\t\toutputTokens: weeklyData.reduce((sum, d) => sum + d.outputTokens, 0),\n\t\t\tcacheCreationTokens: weeklyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0),\n\t\t\tcacheReadTokens: weeklyData.reduce((sum, d) => sum + d.cacheReadTokens, 0),\n\t\t\ttotalTokens: weeklyData.reduce((sum, d) => sum + d.totalTokens, 0),\n\t\t\ttotalCost: weeklyData.reduce((sum, d) => sum + d.totalCost, 0),\n\t\t};\n\n\t\tif (jsonOutput) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tweekly: weeklyData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log('\\n📊 OpenCode Token Usage Report - Weekly\\n');\n\n\t\tconst table: ResponsiveTable = new ResponsiveTable({\n\t\t\thead: [\n\t\t\t\t'Week',\n\t\t\t\t'Models',\n\t\t\t\t'Input',\n\t\t\t\t'Output',\n\t\t\t\t'Cache Create',\n\t\t\t\t'Cache Read',\n\t\t\t\t'Total Tokens',\n\t\t\t\t'Cost (USD)',\n\t\t\t],\n\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],\n\t\t\tcompactHead: ['Week', 'Models', 'Input', 'Output', 'Cost (USD)'],\n\t\t\tcompactColAligns: ['left', 'left', 'right', 'right', 'right'],\n\t\t\tcompactThreshold: 100,\n\t\t\tforceCompact: Boolean(ctx.values.compact),\n\t\t\tstyle: { head: ['cyan'] },\n\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t});\n\n\t\tfor (const data of weeklyData) {\n\t\t\ttable.push([\n\t\t\t\tdata.week,\n\t\t\t\tformatModelsDisplayMultiline(data.modelsUsed),\n\t\t\t\tformatNumber(data.inputTokens),\n\t\t\t\tformatNumber(data.outputTokens),\n\t\t\t\tformatNumber(data.cacheCreationTokens),\n\t\t\t\tformatNumber(data.cacheReadTokens),\n\t\t\t\tformatNumber(data.totalTokens),\n\t\t\t\tformatCurrency(data.totalCost),\n\t\t\t]);\n\t\t}\n\n\t\taddEmptySeparatorRow(table, TABLE_COLUMN_COUNT);\n\t\ttable.push([\n\t\t\tpc.yellow('Total'),\n\t\t\t'',\n\t\t\tpc.yellow(formatNumber(totals.inputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.outputTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheCreationTokens)),\n\t\t\tpc.yellow(formatNumber(totals.cacheReadTokens)),\n\t\t\tpc.yellow(formatNumber(totals.totalTokens)),\n\t\t\tpc.yellow(formatCurrency(totals.totalCost)),\n\t\t]);\n\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(table.toString());\n\n\t\tif (table.isCompactMode()) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('\\nRunning in Compact Mode');\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.log('Expand terminal width to see cache metrics and total tokens');\n\t\t}\n\t},\n});\n\nif (import.meta.vitest != null) {\n\tconst { describe, it, expect } = import.meta.vitest;\n\n\tdescribe('getISOWeek', () => {\n\t\tit('should get ISO week for a date in the middle of the year', () => {\n\t\t\tconst date = new Date('2025-06-15T10:00:00Z');\n\t\t\tconst week = getISOWeek(date);\n\t\t\texpect(week).toBe('2025-W24');\n\t\t});\n\n\t\tit('should handle year boundary correctly', () => {\n\t\t\t// Dec 29, 2025 is a Monday (first week of 2026 in ISO)\n\t\t\tconst date = new Date('2025-12-29T10:00:00Z');\n\t\t\tconst week = getISOWeek(date);\n\t\t\texpect(week).toBe('2026-W01');\n\t\t});\n\n\t\tit('should handle first week of year', () => {\n\t\t\t// Jan 5, 2025 is a Sunday (week 1 of 2025)\n\t\t\tconst date = new Date('2025-01-05T10:00:00Z');\n\t\t\tconst week = getISOWeek(date);\n\t\t\texpect(week).toBe('2025-W01');\n\t\t});\n\n\t\tit('should handle last days of previous year belonging to week 1', () => {\n\t\t\t// Jan 1, 2025 is a Wednesday (week 1 of 2025)\n\t\t\tconst date = new Date('2025-01-01T10:00:00Z');\n\t\t\tconst week = getISOWeek(date);\n\t\t\texpect(week).toBe('2025-W01');\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/opencode/src/cost-utils.ts",
    "content": "import type { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';\nimport type { LoadedUsageEntry } from './data-loader.ts';\nimport { Result } from '@praha/byethrow';\n\n/**\n * Model aliases for OpenCode-specific model names that don't exist in LiteLLM.\n * Maps OpenCode model names to their LiteLLM equivalents for pricing lookup.\n */\nconst MODEL_ALIASES: Record<string, string> = {\n\t// OpenCode uses -high suffix for higher tier/thinking mode variants\n\t'gemini-3-pro-high': 'gemini-3-pro-preview',\n};\n\nfunction resolveModelName(modelName: string): string {\n\treturn MODEL_ALIASES[modelName] ?? modelName;\n}\n\n/**\n * Calculate cost for a single usage entry\n * Uses pre-calculated cost if available, otherwise calculates from tokens\n */\nexport async function calculateCostForEntry(\n\tentry: LoadedUsageEntry,\n\tfetcher: LiteLLMPricingFetcher,\n): Promise<number> {\n\tif (entry.costUSD != null && entry.costUSD > 0) {\n\t\treturn entry.costUSD;\n\t}\n\n\tconst resolvedModel = resolveModelName(entry.model);\n\tconst result = await fetcher.calculateCostFromTokens(\n\t\t{\n\t\t\tinput_tokens: entry.usage.inputTokens,\n\t\t\toutput_tokens: entry.usage.outputTokens,\n\t\t\tcache_creation_input_tokens: entry.usage.cacheCreationInputTokens,\n\t\t\tcache_read_input_tokens: entry.usage.cacheReadInputTokens,\n\t\t},\n\t\tresolvedModel,\n\t);\n\n\treturn Result.unwrap(result, 0);\n}\n"
  },
  {
    "path": "apps/opencode/src/data-loader.ts",
    "content": "/**\n * @fileoverview Data loading utilities for OpenCode usage analysis\n *\n * This module provides functions for loading and parsing OpenCode usage data\n * from JSON message files stored in OpenCode data directories.\n * OpenCode stores usage data in ~/.local/share/opencode/storage/message/\n *\n * @module data-loader\n */\n\nimport { readFile } from 'node:fs/promises';\nimport { homedir } from 'node:os';\nimport path from 'node:path';\nimport process from 'node:process';\nimport { isDirectorySync } from 'path-type';\nimport { glob } from 'tinyglobby';\nimport * as v from 'valibot';\n\n/**\n * Default OpenCode data directory path (~/.local/share/opencode)\n */\nconst DEFAULT_OPENCODE_PATH = '.local/share/opencode';\n\n/**\n * OpenCode storage subdirectory containing message data\n */\nconst OPENCODE_STORAGE_DIR_NAME = 'storage';\n\n/**\n * OpenCode messages subdirectory within storage\n */\nconst OPENCODE_MESSAGES_DIR_NAME = 'message';\nconst OPENCODE_SESSIONS_DIR_NAME = 'session';\n\n/**\n * Environment variable for specifying custom OpenCode data directory\n */\nconst OPENCODE_CONFIG_DIR_ENV = 'OPENCODE_DATA_DIR';\n\n/**\n * User home directory\n */\nconst USER_HOME_DIR = homedir();\n\n/**\n * Branded Valibot schema for model names\n */\nconst modelNameSchema = v.pipe(\n\tv.string(),\n\tv.minLength(1, 'Model name cannot be empty'),\n\tv.brand('ModelName'),\n);\n\n/**\n * Branded Valibot schema for session IDs\n */\nconst sessionIdSchema = v.pipe(\n\tv.string(),\n\tv.minLength(1, 'Session ID cannot be empty'),\n\tv.brand('SessionId'),\n);\n\n/**\n * OpenCode message token structure\n */\nexport const openCodeTokensSchema = v.object({\n\tinput: v.optional(v.number()),\n\toutput: v.optional(v.number()),\n\treasoning: v.optional(v.number()),\n\tcache: v.optional(\n\t\tv.object({\n\t\t\tread: v.optional(v.number()),\n\t\t\twrite: v.optional(v.number()),\n\t\t}),\n\t),\n});\n\n/**\n * OpenCode message data structure\n */\nexport const openCodeMessageSchema = v.object({\n\tid: v.string(),\n\tsessionID: v.optional(sessionIdSchema),\n\tproviderID: v.optional(v.string()),\n\tmodelID: v.optional(modelNameSchema),\n\ttime: v.object({\n\t\tcreated: v.optional(v.number()),\n\t\tcompleted: v.optional(v.number()),\n\t}),\n\ttokens: v.optional(openCodeTokensSchema),\n\tcost: v.optional(v.number()),\n});\n\nexport const openCodeSessionSchema = v.object({\n\tid: sessionIdSchema,\n\tparentID: v.optional(v.nullable(sessionIdSchema)),\n\ttitle: v.optional(v.string()),\n\tprojectID: v.optional(v.string()),\n\tdirectory: v.optional(v.string()),\n});\n\n/**\n * Represents a single usage data entry loaded from OpenCode files\n */\nexport type LoadedUsageEntry = {\n\ttimestamp: Date;\n\tsessionID: string;\n\tusage: {\n\t\tinputTokens: number;\n\t\toutputTokens: number;\n\t\tcacheCreationInputTokens: number;\n\t\tcacheReadInputTokens: number;\n\t};\n\tmodel: string;\n\tcostUSD: number | null;\n};\n\nexport type LoadedSessionMetadata = {\n\tid: string;\n\tparentID: string | null;\n\ttitle: string;\n\tprojectID: string;\n\tdirectory: string;\n};\n\n/**\n * Get OpenCode data directory\n * @returns Path to OpenCode data directory, or null if not found\n */\nexport function getOpenCodePath(): string | null {\n\t// Check environment variable first\n\tconst envPath = process.env[OPENCODE_CONFIG_DIR_ENV];\n\tif (envPath != null && envPath.trim() !== '') {\n\t\tconst normalizedPath = path.resolve(envPath);\n\t\tif (isDirectorySync(normalizedPath)) {\n\t\t\treturn normalizedPath;\n\t\t}\n\t}\n\n\t// Use default path\n\tconst defaultPath = path.join(USER_HOME_DIR, DEFAULT_OPENCODE_PATH);\n\tif (isDirectorySync(defaultPath)) {\n\t\treturn defaultPath;\n\t}\n\n\treturn null;\n}\n\n/**\n * Load OpenCode message from JSON file\n * @param filePath - Path to message JSON file\n * @returns Parsed message data or null on failure\n */\nasync function loadOpenCodeMessage(\n\tfilePath: string,\n): Promise<v.InferOutput<typeof openCodeMessageSchema> | null> {\n\ttry {\n\t\tconst content = await readFile(filePath, 'utf-8');\n\t\tconst data: unknown = JSON.parse(content);\n\t\treturn v.parse(openCodeMessageSchema, data);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Convert OpenCode message to LoadedUsageEntry\n * @param message - Parsed OpenCode message\n * @returns LoadedUsageEntry suitable for aggregation\n */\nfunction convertOpenCodeMessageToUsageEntry(\n\tmessage: v.InferOutput<typeof openCodeMessageSchema>,\n): LoadedUsageEntry {\n\tconst createdMs = message.time.created ?? Date.now();\n\n\treturn {\n\t\ttimestamp: new Date(createdMs),\n\t\tsessionID: message.sessionID ?? 'unknown',\n\t\tusage: {\n\t\t\tinputTokens: message.tokens?.input ?? 0,\n\t\t\toutputTokens: message.tokens?.output ?? 0,\n\t\t\tcacheCreationInputTokens: message.tokens?.cache?.write ?? 0,\n\t\t\tcacheReadInputTokens: message.tokens?.cache?.read ?? 0,\n\t\t},\n\t\tmodel: message.modelID ?? 'unknown',\n\t\tcostUSD: message.cost ?? null,\n\t};\n}\n\nasync function loadOpenCodeSession(\n\tfilePath: string,\n): Promise<v.InferOutput<typeof openCodeSessionSchema> | null> {\n\ttry {\n\t\tconst content = await readFile(filePath, 'utf-8');\n\t\tconst data: unknown = JSON.parse(content);\n\t\treturn v.parse(openCodeSessionSchema, data);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction convertOpenCodeSessionToMetadata(\n\tsession: v.InferOutput<typeof openCodeSessionSchema>,\n): LoadedSessionMetadata {\n\treturn {\n\t\tid: session.id,\n\t\tparentID: session.parentID ?? null,\n\t\ttitle: session.title ?? session.id,\n\t\tprojectID: session.projectID ?? 'unknown',\n\t\tdirectory: session.directory ?? 'unknown',\n\t};\n}\n\nexport async function loadOpenCodeSessions(): Promise<Map<string, LoadedSessionMetadata>> {\n\tconst openCodePath = getOpenCodePath();\n\tif (openCodePath == null) {\n\t\treturn new Map();\n\t}\n\n\tconst sessionsDir = path.join(\n\t\topenCodePath,\n\t\tOPENCODE_STORAGE_DIR_NAME,\n\t\tOPENCODE_SESSIONS_DIR_NAME,\n\t);\n\n\tif (!isDirectorySync(sessionsDir)) {\n\t\treturn new Map();\n\t}\n\n\tconst sessionFiles = await glob('**/*.json', {\n\t\tcwd: sessionsDir,\n\t\tabsolute: true,\n\t});\n\n\tconst sessionMap = new Map<string, LoadedSessionMetadata>();\n\n\tfor (const filePath of sessionFiles) {\n\t\tconst session = await loadOpenCodeSession(filePath);\n\n\t\tif (session == null) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst metadata = convertOpenCodeSessionToMetadata(session);\n\t\tsessionMap.set(metadata.id, metadata);\n\t}\n\n\treturn sessionMap;\n}\n\n/**\n * Load all OpenCode messages\n * @returns Array of LoadedUsageEntry for aggregation\n */\nexport async function loadOpenCodeMessages(): Promise<LoadedUsageEntry[]> {\n\tconst openCodePath = getOpenCodePath();\n\tif (openCodePath == null) {\n\t\treturn [];\n\t}\n\n\tconst messagesDir = path.join(\n\t\topenCodePath,\n\t\tOPENCODE_STORAGE_DIR_NAME,\n\t\tOPENCODE_MESSAGES_DIR_NAME,\n\t);\n\n\tif (!isDirectorySync(messagesDir)) {\n\t\treturn [];\n\t}\n\n\t// Find all message JSON files\n\tconst messageFiles = await glob('**/*.json', {\n\t\tcwd: messagesDir,\n\t\tabsolute: true,\n\t});\n\n\tconst entries: LoadedUsageEntry[] = [];\n\tconst dedupeSet = new Set<string>();\n\n\tfor (const filePath of messageFiles) {\n\t\tconst message = await loadOpenCodeMessage(filePath);\n\n\t\tif (message == null) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip messages with no tokens\n\t\tif (message.tokens == null || (message.tokens.input === 0 && message.tokens.output === 0)) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip if no provider or model\n\t\tif (message.providerID == null || message.modelID == null) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Deduplicate by message ID\n\t\tconst dedupeKey = `${message.id}`;\n\t\tif (dedupeSet.has(dedupeKey)) {\n\t\t\tcontinue;\n\t\t}\n\t\tdedupeSet.add(dedupeKey);\n\n\t\tconst entry = convertOpenCodeMessageToUsageEntry(message);\n\t\tentries.push(entry);\n\t}\n\n\treturn entries;\n}\n\nif (import.meta.vitest != null) {\n\tconst { describe, it, expect } = import.meta.vitest;\n\n\tdescribe('data-loader', () => {\n\t\tit('should convert OpenCode message to LoadedUsageEntry', () => {\n\t\t\tconst message = {\n\t\t\t\tid: 'msg_123',\n\t\t\t\tsessionID: 'ses_456' as v.InferOutput<typeof sessionIdSchema>,\n\t\t\t\tproviderID: 'anthropic',\n\t\t\t\tmodelID: 'claude-sonnet-4-5' as v.InferOutput<typeof modelNameSchema>,\n\t\t\t\ttime: {\n\t\t\t\t\tcreated: 1700000000000,\n\t\t\t\t\tcompleted: 1700000010000,\n\t\t\t\t},\n\t\t\t\ttokens: {\n\t\t\t\t\tinput: 100,\n\t\t\t\t\toutput: 200,\n\t\t\t\t\treasoning: 0,\n\t\t\t\t\tcache: {\n\t\t\t\t\t\tread: 50,\n\t\t\t\t\t\twrite: 25,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tcost: 0.001,\n\t\t\t};\n\n\t\t\tconst entry = convertOpenCodeMessageToUsageEntry(message);\n\n\t\t\texpect(entry.sessionID).toBe('ses_456');\n\t\t\texpect(entry.usage.inputTokens).toBe(100);\n\t\t\texpect(entry.usage.outputTokens).toBe(200);\n\t\t\texpect(entry.usage.cacheReadInputTokens).toBe(50);\n\t\t\texpect(entry.usage.cacheCreationInputTokens).toBe(25);\n\t\t\texpect(entry.model).toBe('claude-sonnet-4-5');\n\t\t});\n\n\t\tit('should handle missing optional fields', () => {\n\t\t\tconst message = {\n\t\t\t\tid: 'msg_123',\n\t\t\t\tproviderID: 'openai',\n\t\t\t\tmodelID: 'gpt-5.1' as v.InferOutput<typeof modelNameSchema>,\n\t\t\t\ttime: {\n\t\t\t\t\tcreated: 1700000000000,\n\t\t\t\t},\n\t\t\t\ttokens: {\n\t\t\t\t\tinput: 50,\n\t\t\t\t\toutput: 100,\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst entry = convertOpenCodeMessageToUsageEntry(message);\n\n\t\t\texpect(entry.usage.inputTokens).toBe(50);\n\t\t\texpect(entry.usage.outputTokens).toBe(100);\n\t\t\texpect(entry.usage.cacheReadInputTokens).toBe(0);\n\t\t\texpect(entry.usage.cacheCreationInputTokens).toBe(0);\n\t\t\texpect(entry.costUSD).toBe(null);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/opencode/src/index.ts",
    "content": "#!/usr/bin/env node\n\nimport { run } from './run.ts';\n\n// eslint-disable-next-line antfu/no-top-level-await\nawait run();\n"
  },
  {
    "path": "apps/opencode/src/logger.ts",
    "content": "import { createLogger, log as internalLog } from '@ccusage/internal/logger';\n\nimport { name } from '../package.json';\n\nexport const logger = createLogger(name);\n\nexport const log = internalLog;\n"
  },
  {
    "path": "apps/opencode/src/run.ts",
    "content": "import process from 'node:process';\nimport { cli } from 'gunshi';\nimport { description, name, version } from '../package.json';\nimport { dailyCommand, monthlyCommand, sessionCommand, weeklyCommand } from './commands/index.ts';\n\nconst subCommands = new Map([\n\t['daily', dailyCommand],\n\t['monthly', monthlyCommand],\n\t['session', sessionCommand],\n\t['weekly', weeklyCommand],\n]);\n\nconst mainCommand = dailyCommand;\n\nexport async function run(): Promise<void> {\n\t// When invoked through npx, the binary name might be passed as the first argument\n\t// Filter it out if it matches the expected binary name\n\tlet args = process.argv.slice(2);\n\tif (args[0] === 'ccusage-opencode') {\n\t\targs = args.slice(1);\n\t}\n\n\tawait cli(args, mainCommand, {\n\t\tname,\n\t\tversion,\n\t\tdescription,\n\t\tsubCommands,\n\t\trenderHeader: null,\n\t});\n}\n"
  },
  {
    "path": "apps/opencode/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"moduleDetection\": \"force\",\n\t\t\"module\": \"Preserve\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"types\": [\"vitest/globals\", \"vitest/importMeta\"],\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"allowJs\": false,\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noPropertyAccessFromIndexSignature\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t\"noUnusedLocals\": false,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noEmit\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"exclude\": [\"dist\"]\n}\n"
  },
  {
    "path": "apps/opencode/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n\tentry: ['src/index.ts'],\n\tformat: ['esm'],\n\tclean: true,\n\tdts: false,\n\tshims: true,\n\tplatform: 'node',\n\ttarget: 'node20',\n\tfixedExtension: false,\n});\n"
  },
  {
    "path": "apps/opencode/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\tglobals: true,\n\t\tincludeSource: ['src/**/*.ts'],\n\t\tcoverage: {\n\t\t\tprovider: 'v8',\n\t\t\treporter: ['text', 'json', 'html'],\n\t\t},\n\t},\n\tdefine: {\n\t\t'import.meta.vitest': 'undefined',\n\t},\n});\n"
  },
  {
    "path": "apps/pi/CLAUDE.md",
    "content": "# CLAUDE.md - Pi Package\n\nThis package provides usage tracking for pi-agent.\n\n## Package Overview\n\n**Name**: `@ccusage/pi`\n**Description**: Pi-agent usage tracking\n**Type**: CLI tool with TypeScript exports\n\n## Development Commands\n\n**Testing and Quality:**\n\n- `pnpm run test` - Run all tests using vitest\n- `pnpm run lint` - Lint code using ESLint\n- `pnpm run format` - Format and auto-fix code with ESLint\n- `pnpm typecheck` - Type check with TypeScript\n\n**Build and Release:**\n\n- `pnpm run build` - Build distribution files with tsdown\n- `pnpm run prerelease` - Full release workflow (lint + typecheck + build)\n\n## Usage\n\n```bash\n# Show daily pi-agent usage\nccusage-pi daily\n\n# Show monthly pi-agent usage\nccusage-pi monthly\n\n# Show session-based pi-agent usage\nccusage-pi session\n\n# JSON output\nccusage-pi daily --json\n\n# Custom pi-agent path\nccusage-pi daily --pi-path /path/to/sessions\n```\n\n## Architecture\n\nThis package reads usage data from pi-agent only.\n\n**Data Source:**\n\n- **Pi-agent**: `~/.pi/agent/sessions/`\n\n**Key Modules:**\n\n- `src/index.ts` - CLI entry point with Gunshi-based command routing\n- `src/data-loader.ts` - Loads and aggregates pi-agent JSONL data\n- `src/_pi-agent.ts` - Pi-agent data parsing and transformation\n- `src/commands/` - CLI subcommands (daily, monthly, session)\n\n## Dependencies\n\n**Key Runtime Dependencies:**\n\n- `ccusage` - Main ccusage package (workspace dependency)\n- `@ccusage/terminal` - Shared terminal utilities\n- `gunshi` - CLI framework\n- `valibot` - Schema validation\n- `tinyglobby` - File globbing\n\n**Key Dev Dependencies:**\n\n- `vitest` - Testing framework\n- `tsdown` - TypeScript build tool\n- `eslint` - Linting and formatting\n- `fs-fixture` - Test fixture creation\n\n## Testing\n\n- **In-Source Testing**: Uses the same testing pattern as the main package\n- **Vitest Globals Enabled**: Use `describe`, `it`, `expect` directly without imports\n- **Mock Data**: Uses `fs-fixture` for testing data loading functionality\n- **CRITICAL**: NEVER use `await import()` dynamic imports anywhere\n\n## Code Style\n\nFollow the same code style guidelines as the main ccusage package:\n\n- **Error Handling**: Prefer `@praha/byethrow Result` type over try-catch\n- **Imports**: Use `.ts` extensions for local imports\n- **Exports**: Only export what's actually used\n- **Dependencies**: Add as `devDependencies` unless explicitly requested\n\n**Post-Change Workflow:**\nAlways run these commands in parallel after code changes:\n\n- `pnpm run format` - Auto-fix and format\n- `pnpm typecheck` - Type checking\n- `pnpm run test` - Run tests\n\n## Environment Variables\n\n| Variable       | Description                                   |\n| -------------- | --------------------------------------------- |\n| `PI_AGENT_DIR` | Custom path to pi-agent sessions directory    |\n| `LOG_LEVEL`    | Adjust logging verbosity (0 silent … 5 trace) |\n\n## Package Exports\n\nThe package provides the following exports:\n\n- `.` - Main CLI entry point\n\n## Binary\n\nThe package includes a binary `ccusage-pi` that can be used to run the CLI from the command line.\n"
  },
  {
    "path": "apps/pi/README.md",
    "content": "<div align=\"center\">\n    <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.svg\" alt=\"ccusage logo\" width=\"256\" height=\"256\">\n    <h1>@ccusage/pi</h1>\n</div>\n\n<p align=\"center\">\n    <a href=\"https://socket.dev/api/npm/package/@ccusage/pi\"><img src=\"https://socket.dev/api/badge/npm/package/@ccusage/pi\" alt=\"Socket Badge\" /></a>\n    <a href=\"https://npmjs.com/package/@ccusage/pi\"><img src=\"https://img.shields.io/npm/v/@ccusage/pi?color=yellow\" alt=\"npm version\" /></a>\n    <a href=\"https://tanstack.com/stats/npm?packageGroups=%5B%7B%22packages%22:%5B%7B%22name%22:%22@ccusage/pi%22%7D%5D%7D%5D&range=30-days&transform=none&binType=daily&showDataMode=all&height=400\"><img src=\"https://img.shields.io/npm/dt/@ccusage/pi\" alt=\"NPM Downloads\" /></a>\n    <a href=\"https://packagephobia.com/result?p=@ccusage/pi\"><img src=\"https://packagephobia.com/badge?p=@ccusage/pi\" alt=\"install size\" /></a>\n    <a href=\"https://deepwiki.com/ryoppippi/ccusage\"><img src=\"https://img.shields.io/badge/DeepWiki-ryoppippi%2Fccusage-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==\" alt=\"DeepWiki\"></a>\n</p>\n\n> Analyze [pi-agent](https://github.com/badlogic/pi-mono) session usage with the same reporting experience as <code>ccusage</code>.\n\n## Quick Start\n\n```bash\n# Recommended - always include @latest\nnpx @ccusage/pi@latest --help\nbunx @ccusage/pi@latest --help\n\n# Alternative package runners\npnpm dlx @ccusage/pi\npnpx @ccusage/pi\n```\n\n### Recommended: Shell Alias\n\nSince `npx @ccusage/pi@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias:\n\n```bash\n# bash/zsh: alias ccusage-pi='bunx @ccusage/pi@latest'\n# fish:     alias ccusage-pi 'bunx @ccusage/pi@latest'\n\n# Then simply run:\nccusage-pi daily\nccusage-pi monthly --json\n```\n\n> 💡 The CLI reads pi-agent session data from `~/.pi/agent/sessions/` (configurable via `PI_AGENT_DIR`).\n\n## Common Commands\n\n```bash\n# Daily usage grouped by date (default command)\nnpx @ccusage/pi@latest daily\n\n# Monthly usage grouped by month\nnpx @ccusage/pi@latest monthly\n\n# Session-based usage\nnpx @ccusage/pi@latest session\n\n# JSON output for scripting\nnpx @ccusage/pi@latest daily --json\n\n# Custom pi-agent path\nnpx @ccusage/pi@latest daily --pi-path /path/to/sessions\n\n# Filter by date range\nnpx @ccusage/pi@latest daily --since 2025-12-01 --until 2025-12-19\n```\n\nUseful environment variables:\n\n- `PI_AGENT_DIR` – override the pi-agent sessions directory (defaults to `~/.pi/agent/sessions`)\n- `LOG_LEVEL` – control log verbosity (0 silent … 5 trace)\n\n## What is pi-agent?\n\n[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.\n\n## Features\n\n- 📊 **Daily/Monthly/Session Reports**: Same reporting options as ccusage\n- 💵 **Accurate Cost Calculation**: Uses LiteLLM pricing database\n- 📄 **JSON Output**: Export data in structured JSON format with `--json`\n- 📱 **Compact Mode**: Use `--compact` flag for narrow terminals\n\n## Data Source\n\nPi-agent session data is read from:\n\n| Directory         | Default Path            |\n| ----------------- | ----------------------- |\n| Pi-agent sessions | `~/.pi/agent/sessions/` |\n\n## Documentation\n\nFor detailed guides and examples, visit **[ccusage.com](https://ccusage.com/)**.\n\n## Sponsors\n\n### Featured Sponsor\n\nCheck out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)\n\n<p align=\"center\">\n    <a href=\"https://www.youtube.com/watch?v=Ak6qpQ5qdgk\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/ccusage_thumbnail.png\" alt=\"ccusage: The Claude Code cost scorecard that went viral\" width=\"600\">\n    </a>\n</p>\n\n<p align=\"center\">\n    <a href=\"https://github.com/sponsors/ryoppippi\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/sponsors@main/sponsors.svg\">\n    </a>\n</p>\n\n## License\n\nMIT © [@ryoppippi](https://github.com/ryoppippi)\n"
  },
  {
    "path": "apps/pi/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\n/** @type {import('eslint').Linter.FlatConfig[]} */\nconst config = ryoppippi(\n\t{\n\t\ttype: 'app',\n\t\tstylistic: false,\n\t},\n\t{\n\t\trules: {\n\t\t\t'test/no-importing-vitest-globals': 'error',\n\t\t},\n\t},\n);\n\nexport default config;\n"
  },
  {
    "path": "apps/pi/package.json",
    "content": "{\n\t\"name\": \"@ccusage/pi\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"description\": \"Pi-agent usage tracking - unified Claude Max usage across Claude Code and pi-agent\",\n\t\"author\": \"ryoppippi\",\n\t\"contributors\": [\n\t\t\"nicobailon\"\n\t],\n\t\"license\": \"MIT\",\n\t\"funding\": \"https://github.com/ryoppippi/ccusage?sponsor=1\",\n\t\"homepage\": \"https://github.com/ryoppippi/ccusage#readme\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/ryoppippi/ccusage.git\",\n\t\t\"directory\": \"apps/pi\"\n\t},\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/ryoppippi/ccusage/issues\"\n\t},\n\t\"exports\": {\n\t\t\".\": \"./src/index.ts\",\n\t\t\"./package.json\": \"./package.json\"\n\t},\n\t\"main\": \"./dist/index.js\",\n\t\"module\": \"./dist/index.js\",\n\t\"types\": \"./dist/index.d.ts\",\n\t\"bin\": {\n\t\t\"ccusage-pi\": \"./src/index.ts\"\n\t},\n\t\"files\": [\n\t\t\"README.md\",\n\t\t\"dist\"\n\t],\n\t\"publishConfig\": {\n\t\t\"bin\": {\n\t\t\t\"ccusage-pi\": \"./dist/index.js\"\n\t\t},\n\t\t\"exports\": {\n\t\t\t\".\": \"./dist/index.js\",\n\t\t\t\"./package.json\": \"./package.json\"\n\t\t}\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=20.19.4\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"tsdown\",\n\t\t\"dev\": \"bun -b --watch ./src/index.ts\",\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"prepack\": \"pnpm run build && clean-pkg-json\",\n\t\t\"prerelease\": \"pnpm run lint && pnpm run typecheck && pnpm run build\",\n\t\t\"start\": \"bun ./src/index.ts\",\n\t\t\"test\": \"TZ=UTC vitest\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@ccusage/internal\": \"workspace:*\",\n\t\t\"@ccusage/terminal\": \"workspace:*\",\n\t\t\"@praha/byethrow\": \"catalog:runtime\",\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"@typescript/native-preview\": \"catalog:types\",\n\t\t\"clean-pkg-json\": \"catalog:release\",\n\t\t\"es-toolkit\": \"catalog:runtime\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"fs-fixture\": \"catalog:testing\",\n\t\t\"gunshi\": \"catalog:runtime\",\n\t\t\"path-type\": \"catalog:runtime\",\n\t\t\"picocolors\": \"catalog:runtime\",\n\t\t\"publint\": \"catalog:lint\",\n\t\t\"tinyglobby\": \"catalog:runtime\",\n\t\t\"tsdown\": \"catalog:build\",\n\t\t\"valibot\": \"catalog:runtime\",\n\t\t\"vitest\": \"catalog:testing\"\n\t}\n}\n"
  },
  {
    "path": "apps/pi/src/_consts.ts",
    "content": "import { homedir } from 'node:os';\nimport path from 'node:path';\n\nexport const USER_HOME_DIR = homedir();\n\nexport const PI_AGENT_DIR_ENV = 'PI_AGENT_DIR';\nexport const PI_AGENT_SESSIONS_DIR_NAME = 'sessions';\nexport const DEFAULT_PI_AGENT_PATH = path.join('.pi', 'agent');\n"
  },
  {
    "path": "apps/pi/src/_pi-agent.ts",
    "content": "import path from 'node:path';\nimport process from 'node:process';\nimport { isDirectorySync } from 'path-type';\nimport * as v from 'valibot';\nimport {\n\tDEFAULT_PI_AGENT_PATH,\n\tPI_AGENT_DIR_ENV,\n\tPI_AGENT_SESSIONS_DIR_NAME,\n\tUSER_HOME_DIR,\n} from './_consts.ts';\nimport { isoTimestampSchema } from './_types.ts';\n\nconst piAgentUsageSchema = v.object({\n\tinput: v.number(),\n\toutput: v.number(),\n\tcacheRead: v.optional(v.number()),\n\tcacheWrite: v.optional(v.number()),\n\ttotalTokens: v.optional(v.number()),\n\tcost: v.optional(\n\t\tv.object({\n\t\t\ttotal: v.optional(v.number()),\n\t\t}),\n\t),\n});\n\nexport const piAgentMessageSchema = v.object({\n\ttype: v.optional(v.string()),\n\ttimestamp: isoTimestampSchema,\n\tmessage: v.object({\n\t\trole: v.optional(v.string()),\n\t\tmodel: v.optional(v.string()),\n\t\tusage: v.optional(piAgentUsageSchema),\n\t}),\n});\n\nexport type PiAgentMessage = v.InferOutput<typeof piAgentMessageSchema>;\n\nexport function isPiAgentUsageEntry(data: PiAgentMessage): boolean {\n\tconst isMessage = data.type == null || data.type === 'message';\n\treturn (\n\t\tisMessage &&\n\t\tdata.message?.role === 'assistant' &&\n\t\tdata.message?.usage != null &&\n\t\ttypeof data.message.usage.input === 'number' &&\n\t\ttypeof data.message.usage.output === 'number'\n\t);\n}\n\nexport function extractPiAgentSessionId(filePath: string): string {\n\tconst filename = path.basename(filePath, '.jsonl');\n\tconst idx = filename.indexOf('_');\n\treturn idx !== -1 ? filename.slice(idx + 1) : filename;\n}\n\nexport function extractPiAgentProject(filePath: string): string {\n\tconst normalizedPath = filePath.replace(/[/\\\\]/g, path.sep);\n\tconst segments = normalizedPath.split(path.sep);\n\tconst idx = segments.findIndex((s) => s === 'sessions');\n\tif (idx === -1 || idx + 1 >= segments.length) {\n\t\treturn 'unknown';\n\t}\n\treturn segments[idx + 1] ?? 'unknown';\n}\n\nexport function getPiAgentPaths(customPath?: string): string[] {\n\tif (customPath != null && customPath !== '') {\n\t\tconst resolved = path.resolve(customPath);\n\t\tif (isDirectorySync(resolved)) {\n\t\t\treturn [resolved];\n\t\t}\n\t}\n\n\tconst envPath = (process.env[PI_AGENT_DIR_ENV] ?? '').trim();\n\tif (envPath !== '') {\n\t\tconst resolved = path.resolve(envPath);\n\t\tif (isDirectorySync(resolved)) {\n\t\t\treturn [resolved];\n\t\t}\n\t}\n\n\tconst defaultPath = path.join(USER_HOME_DIR, DEFAULT_PI_AGENT_PATH, PI_AGENT_SESSIONS_DIR_NAME);\n\tif (isDirectorySync(defaultPath)) {\n\t\treturn [defaultPath];\n\t}\n\n\treturn [];\n}\n\nexport function transformPiAgentUsage(data: PiAgentMessage): {\n\tusage: {\n\t\tinput_tokens: number;\n\t\toutput_tokens: number;\n\t\tcache_creation_input_tokens: number;\n\t\tcache_read_input_tokens: number;\n\t};\n\tmodel: string | undefined;\n\tcostUSD: number | undefined;\n\ttotalTokens: number;\n} | null {\n\tif (!isPiAgentUsageEntry(data)) {\n\t\treturn null;\n\t}\n\n\tconst usage = data.message.usage!;\n\tconst totalTokens =\n\t\tusage.totalTokens ??\n\t\tusage.input + usage.output + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);\n\n\treturn {\n\t\tusage: {\n\t\t\tinput_tokens: usage.input,\n\t\t\toutput_tokens: usage.output,\n\t\t\tcache_creation_input_tokens: usage.cacheWrite ?? 0,\n\t\t\tcache_read_input_tokens: usage.cacheRead ?? 0,\n\t\t},\n\t\tmodel: data.message.model != null ? `[pi] ${data.message.model}` : undefined,\n\t\tcostUSD: usage.cost?.total,\n\t\ttotalTokens,\n\t};\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('isPiAgentUsageEntry', () => {\n\t\tit('returns true for valid assistant message with usage', () => {\n\t\t\tconst data: PiAgentMessage = {\n\t\t\t\ttype: 'message',\n\t\t\t\ttimestamp: '2024-01-01T00:00:00Z' as v.InferOutput<typeof isoTimestampSchema>,\n\t\t\t\tmessage: {\n\t\t\t\t\trole: 'assistant',\n\t\t\t\t\tmodel: 'claude-opus-4-5',\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinput: 100,\n\t\t\t\t\t\toutput: 50,\n\t\t\t\t\t\tcacheRead: 10,\n\t\t\t\t\t\tcacheWrite: 20,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(isPiAgentUsageEntry(data)).toBe(true);\n\t\t});\n\n\t\tit('returns false for user message', () => {\n\t\t\tconst data: PiAgentMessage = {\n\t\t\t\ttype: 'message',\n\t\t\t\ttimestamp: '2024-01-01T00:00:00Z' as v.InferOutput<typeof isoTimestampSchema>,\n\t\t\t\tmessage: {\n\t\t\t\t\trole: 'user',\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinput: 100,\n\t\t\t\t\t\toutput: 50,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(isPiAgentUsageEntry(data)).toBe(false);\n\t\t});\n\n\t\tit('returns false for non-message type', () => {\n\t\t\tconst data: PiAgentMessage = {\n\t\t\t\ttype: 'tool_use',\n\t\t\t\ttimestamp: '2024-01-01T00:00:00Z' as v.InferOutput<typeof isoTimestampSchema>,\n\t\t\t\tmessage: {\n\t\t\t\t\trole: 'assistant',\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinput: 100,\n\t\t\t\t\t\toutput: 50,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(isPiAgentUsageEntry(data)).toBe(false);\n\t\t});\n\n\t\tit('returns false when usage is missing', () => {\n\t\t\tconst data: PiAgentMessage = {\n\t\t\t\ttype: 'message',\n\t\t\t\ttimestamp: '2024-01-01T00:00:00Z' as v.InferOutput<typeof isoTimestampSchema>,\n\t\t\t\tmessage: {\n\t\t\t\t\trole: 'assistant',\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(isPiAgentUsageEntry(data)).toBe(false);\n\t\t});\n\n\t\tit('returns true when type is undefined but has assistant with usage', () => {\n\t\t\tconst data: PiAgentMessage = {\n\t\t\t\ttype: undefined,\n\t\t\t\ttimestamp: '2024-01-01T00:00:00Z' as v.InferOutput<typeof isoTimestampSchema>,\n\t\t\t\tmessage: {\n\t\t\t\t\trole: 'assistant',\n\t\t\t\t\tmodel: 'claude-opus-4-5',\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinput: 100,\n\t\t\t\t\t\toutput: 50,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\t\t\texpect(isPiAgentUsageEntry(data)).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('extractPiAgentSessionId', () => {\n\t\tit('extracts session ID from filename with timestamp prefix', () => {\n\t\t\tconst filePath =\n\t\t\t\t'/path/to/sessions/project/2025-12-19T08-12-33-794Z_2c16ab69-02b4-46e1-96ad-5b19ef6be8c4.jsonl';\n\t\t\texpect(extractPiAgentSessionId(filePath)).toBe('2c16ab69-02b4-46e1-96ad-5b19ef6be8c4');\n\t\t});\n\n\t\tit('returns full filename when no underscore', () => {\n\t\t\tconst filePath = '/path/to/sessions/project/session-id.jsonl';\n\t\t\texpect(extractPiAgentSessionId(filePath)).toBe('session-id');\n\t\t});\n\t});\n\n\tdescribe('extractPiAgentProject', () => {\n\t\tit('extracts project name from path', () => {\n\t\t\tconst filePath = '/Users/test/.pi/agent/sessions/--Users-test-project--/file.jsonl';\n\t\t\texpect(extractPiAgentProject(filePath)).toBe('--Users-test-project--');\n\t\t});\n\n\t\tit('returns unknown when sessions not in path', () => {\n\t\t\tconst filePath = '/Users/test/.pi/agent/other/project/file.jsonl';\n\t\t\texpect(extractPiAgentProject(filePath)).toBe('unknown');\n\t\t});\n\t});\n\n\tdescribe('transformPiAgentUsage', () => {\n\t\tit('transforms valid pi-agent usage to ccusage format', () => {\n\t\t\tconst data: PiAgentMessage = {\n\t\t\t\ttype: 'message',\n\t\t\t\ttimestamp: '2024-01-01T00:00:00Z' as v.InferOutput<typeof isoTimestampSchema>,\n\t\t\t\tmessage: {\n\t\t\t\t\trole: 'assistant',\n\t\t\t\t\tmodel: 'claude-opus-4-5',\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinput: 100,\n\t\t\t\t\t\toutput: 50,\n\t\t\t\t\t\tcacheRead: 10,\n\t\t\t\t\t\tcacheWrite: 20,\n\t\t\t\t\t\ttotalTokens: 180,\n\t\t\t\t\t\tcost: {\n\t\t\t\t\t\t\ttotal: 0.05,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst result = transformPiAgentUsage(data);\n\t\t\texpect(result).not.toBeNull();\n\t\t\texpect(result?.usage.input_tokens).toBe(100);\n\t\t\texpect(result?.usage.output_tokens).toBe(50);\n\t\t\texpect(result?.usage.cache_read_input_tokens).toBe(10);\n\t\t\texpect(result?.usage.cache_creation_input_tokens).toBe(20);\n\t\t\texpect(result?.model).toBe('[pi] claude-opus-4-5');\n\t\t\texpect(result?.costUSD).toBe(0.05);\n\t\t\texpect(result?.totalTokens).toBe(180);\n\t\t});\n\n\t\tit('calculates totalTokens when not provided', () => {\n\t\t\tconst data: PiAgentMessage = {\n\t\t\t\ttype: 'message',\n\t\t\t\ttimestamp: '2024-01-01T00:00:00Z' as v.InferOutput<typeof isoTimestampSchema>,\n\t\t\t\tmessage: {\n\t\t\t\t\trole: 'assistant',\n\t\t\t\t\tmodel: 'claude-opus-4-5',\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinput: 100,\n\t\t\t\t\t\toutput: 50,\n\t\t\t\t\t\tcacheRead: 10,\n\t\t\t\t\t\tcacheWrite: 20,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst result = transformPiAgentUsage(data);\n\t\t\texpect(result?.totalTokens).toBe(180);\n\t\t});\n\n\t\tit('returns null for invalid entry', () => {\n\t\t\tconst data: PiAgentMessage = {\n\t\t\t\ttype: 'tool_use',\n\t\t\t\ttimestamp: '2024-01-01T00:00:00Z' as v.InferOutput<typeof isoTimestampSchema>,\n\t\t\t\tmessage: {\n\t\t\t\t\trole: 'assistant',\n\t\t\t\t},\n\t\t\t};\n\n\t\t\texpect(transformPiAgentUsage(data)).toBeNull();\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "apps/pi/src/_types.ts",
    "content": "import * as v from 'valibot';\n\nconst isoTimestampRegex = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?(?:Z|[+-]\\d{2}:\\d{2})$/;\nexport const isoTimestampSchema = v.pipe(\n\tv.string(),\n\tv.regex(isoTimestampRegex, 'Invalid ISO timestamp'),\n\tv.brand('ISOTimestamp'),\n);\n\nexport type ISOTimestamp = v.InferOutput<typeof isoTimestampSchema>;\n"
  },
  {
    "path": "apps/pi/src/commands/daily.ts",
    "content": "import process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tcreateUsageReportTable,\n\tformatDateCompact,\n\tformatTotalsRow,\n\tformatUsageDataRow,\n\tpushBreakdownRows,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport { loadPiAgentDailyData } from '../data-loader.ts';\nimport { log, logger } from '../logger.ts';\n\nexport const dailyCommand = define({\n\tname: 'daily',\n\tdescription: 'Show pi-agent usage by date',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Output as JSON',\n\t\t\tdefault: false,\n\t\t},\n\t\tsince: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Start date (YYYY-MM-DD or YYYYMMDD)',\n\t\t},\n\t\tuntil: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'End date (YYYY-MM-DD or YYYYMMDD)',\n\t\t},\n\t\ttimezone: {\n\t\t\ttype: 'string',\n\t\t\tshort: 'z',\n\t\t\tdescription: 'Timezone for date display',\n\t\t},\n\t\tpiPath: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Path to pi-agent sessions directory',\n\t\t},\n\t\torder: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Sort order: asc or desc',\n\t\t\tdefault: 'desc',\n\t\t},\n\t\tbreakdown: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'b',\n\t\t\tdescription: 'Show model breakdown for each entry',\n\t\t\tdefault: false,\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst options = {\n\t\t\tsince: ctx.values.since,\n\t\t\tuntil: ctx.values.until,\n\t\t\ttimezone: ctx.values.timezone,\n\t\t\torder: ctx.values.order as 'asc' | 'desc',\n\t\t\tpiPath: ctx.values.piPath,\n\t\t};\n\n\t\tconst piData = await loadPiAgentDailyData(options);\n\n\t\tif (piData.length === 0) {\n\t\t\tif (ctx.values.json) {\n\t\t\t\tlog(JSON.stringify([]));\n\t\t\t} else {\n\t\t\t\tlogger.warn('No usage data found.');\n\t\t\t}\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\tconst totals = {\n\t\t\tinputTokens: 0,\n\t\t\toutputTokens: 0,\n\t\t\tcacheCreationTokens: 0,\n\t\t\tcacheReadTokens: 0,\n\t\t\ttotalCost: 0,\n\t\t};\n\n\t\tfor (const d of piData) {\n\t\t\ttotals.inputTokens += d.inputTokens;\n\t\t\ttotals.outputTokens += d.outputTokens;\n\t\t\ttotals.cacheCreationTokens += d.cacheCreationTokens;\n\t\t\ttotals.cacheReadTokens += d.cacheReadTokens;\n\t\t\ttotals.totalCost += d.totalCost;\n\t\t}\n\n\t\tif (ctx.values.json) {\n\t\t\tlog(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tdaily: piData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t} else {\n\t\t\tlogger.box('Pi-Agent Usage Report - Daily');\n\n\t\t\tconst table = createUsageReportTable({\n\t\t\t\tfirstColumnName: 'Date',\n\t\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t\t});\n\n\t\t\tfor (const data of piData) {\n\t\t\t\tconst row = formatUsageDataRow(data.date, {\n\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t});\n\t\t\t\ttable.push(row);\n\n\t\t\t\tif (ctx.values.breakdown) {\n\t\t\t\t\tpushBreakdownRows(table, data.modelBreakdowns);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\taddEmptySeparatorRow(table, 8);\n\n\t\t\tconst totalsRow = formatTotalsRow({\n\t\t\t\tinputTokens: totals.inputTokens,\n\t\t\t\toutputTokens: totals.outputTokens,\n\t\t\t\tcacheCreationTokens: totals.cacheCreationTokens,\n\t\t\t\tcacheReadTokens: totals.cacheReadTokens,\n\t\t\t\ttotalCost: totals.totalCost,\n\t\t\t});\n\t\t\ttable.push(totalsRow);\n\n\t\t\tlog(table.toString());\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/pi/src/commands/index.ts",
    "content": "export { dailyCommand } from './daily.ts';\nexport { monthlyCommand } from './monthly.ts';\nexport { sessionCommand } from './session.ts';\n"
  },
  {
    "path": "apps/pi/src/commands/monthly.ts",
    "content": "import process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tcreateUsageReportTable,\n\tformatDateCompact,\n\tformatTotalsRow,\n\tformatUsageDataRow,\n\tpushBreakdownRows,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport { loadPiAgentMonthlyData } from '../data-loader.ts';\nimport { log, logger } from '../logger.ts';\n\nexport const monthlyCommand = define({\n\tname: 'monthly',\n\tdescription: 'Show pi-agent usage by month',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Output as JSON',\n\t\t\tdefault: false,\n\t\t},\n\t\tsince: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Start date (YYYY-MM-DD or YYYYMMDD)',\n\t\t},\n\t\tuntil: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'End date (YYYY-MM-DD or YYYYMMDD)',\n\t\t},\n\t\ttimezone: {\n\t\t\ttype: 'string',\n\t\t\tshort: 'z',\n\t\t\tdescription: 'Timezone for date display',\n\t\t},\n\t\tpiPath: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Path to pi-agent sessions directory',\n\t\t},\n\t\torder: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Sort order: asc or desc',\n\t\t\tdefault: 'desc',\n\t\t},\n\t\tbreakdown: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'b',\n\t\t\tdescription: 'Show model breakdown for each entry',\n\t\t\tdefault: false,\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst options = {\n\t\t\tsince: ctx.values.since,\n\t\t\tuntil: ctx.values.until,\n\t\t\ttimezone: ctx.values.timezone,\n\t\t\torder: ctx.values.order as 'asc' | 'desc',\n\t\t\tpiPath: ctx.values.piPath,\n\t\t};\n\n\t\tconst piData = await loadPiAgentMonthlyData(options);\n\n\t\tif (piData.length === 0) {\n\t\t\tif (ctx.values.json) {\n\t\t\t\tlog(JSON.stringify([]));\n\t\t\t} else {\n\t\t\t\tlogger.warn('No usage data found.');\n\t\t\t}\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\tconst totals = {\n\t\t\tinputTokens: 0,\n\t\t\toutputTokens: 0,\n\t\t\tcacheCreationTokens: 0,\n\t\t\tcacheReadTokens: 0,\n\t\t\ttotalCost: 0,\n\t\t};\n\n\t\tfor (const d of piData) {\n\t\t\ttotals.inputTokens += d.inputTokens;\n\t\t\ttotals.outputTokens += d.outputTokens;\n\t\t\ttotals.cacheCreationTokens += d.cacheCreationTokens;\n\t\t\ttotals.cacheReadTokens += d.cacheReadTokens;\n\t\t\ttotals.totalCost += d.totalCost;\n\t\t}\n\n\t\tif (ctx.values.json) {\n\t\t\tlog(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tmonthly: piData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t} else {\n\t\t\tlogger.box('Pi-Agent Usage Report - Monthly');\n\n\t\t\tconst table = createUsageReportTable({\n\t\t\t\tfirstColumnName: 'Month',\n\t\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t\t});\n\n\t\t\tfor (const data of piData) {\n\t\t\t\tconst row = formatUsageDataRow(data.month, {\n\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t});\n\t\t\t\ttable.push(row);\n\n\t\t\t\tif (ctx.values.breakdown) {\n\t\t\t\t\tpushBreakdownRows(table, data.modelBreakdowns);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\taddEmptySeparatorRow(table, 8);\n\n\t\t\tconst totalsRow = formatTotalsRow({\n\t\t\t\tinputTokens: totals.inputTokens,\n\t\t\t\toutputTokens: totals.outputTokens,\n\t\t\t\tcacheCreationTokens: totals.cacheCreationTokens,\n\t\t\t\tcacheReadTokens: totals.cacheReadTokens,\n\t\t\t\ttotalCost: totals.totalCost,\n\t\t\t});\n\t\t\ttable.push(totalsRow);\n\n\t\t\tlog(table.toString());\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/pi/src/commands/session.ts",
    "content": "import path from 'node:path';\nimport process from 'node:process';\nimport {\n\taddEmptySeparatorRow,\n\tcreateUsageReportTable,\n\tformatDateCompact,\n\tformatTotalsRow,\n\tformatUsageDataRow,\n\tpushBreakdownRows,\n} from '@ccusage/terminal/table';\nimport { define } from 'gunshi';\nimport { loadPiAgentSessionData } from '../data-loader.ts';\nimport { log, logger } from '../logger.ts';\n\nexport const sessionCommand = define({\n\tname: 'session',\n\tdescription: 'Show pi-agent usage by session',\n\targs: {\n\t\tjson: {\n\t\t\ttype: 'boolean',\n\t\t\tdescription: 'Output as JSON',\n\t\t\tdefault: false,\n\t\t},\n\t\tsince: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Start date (YYYY-MM-DD or YYYYMMDD)',\n\t\t},\n\t\tuntil: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'End date (YYYY-MM-DD or YYYYMMDD)',\n\t\t},\n\t\ttimezone: {\n\t\t\ttype: 'string',\n\t\t\tshort: 'z',\n\t\t\tdescription: 'Timezone for date display',\n\t\t},\n\t\tpiPath: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Path to pi-agent sessions directory',\n\t\t},\n\t\torder: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Sort order: asc or desc',\n\t\t\tdefault: 'desc',\n\t\t},\n\t\tbreakdown: {\n\t\t\ttype: 'boolean',\n\t\t\tshort: 'b',\n\t\t\tdescription: 'Show model breakdown for each entry',\n\t\t\tdefault: false,\n\t\t},\n\t},\n\tasync run(ctx) {\n\t\tconst options = {\n\t\t\tsince: ctx.values.since,\n\t\t\tuntil: ctx.values.until,\n\t\t\ttimezone: ctx.values.timezone,\n\t\t\torder: ctx.values.order as 'asc' | 'desc',\n\t\t\tpiPath: ctx.values.piPath,\n\t\t};\n\n\t\tconst piData = await loadPiAgentSessionData(options);\n\n\t\tif (piData.length === 0) {\n\t\t\tif (ctx.values.json) {\n\t\t\t\tlog(JSON.stringify([]));\n\t\t\t} else {\n\t\t\t\tlogger.warn('No usage data found.');\n\t\t\t}\n\t\t\tprocess.exit(0);\n\t\t}\n\n\t\tconst totals = {\n\t\t\tinputTokens: 0,\n\t\t\toutputTokens: 0,\n\t\t\tcacheCreationTokens: 0,\n\t\t\tcacheReadTokens: 0,\n\t\t\ttotalCost: 0,\n\t\t};\n\n\t\tfor (const d of piData) {\n\t\t\ttotals.inputTokens += d.inputTokens;\n\t\t\ttotals.outputTokens += d.outputTokens;\n\t\t\ttotals.cacheCreationTokens += d.cacheCreationTokens;\n\t\t\ttotals.cacheReadTokens += d.cacheReadTokens;\n\t\t\ttotals.totalCost += d.totalCost;\n\t\t}\n\n\t\tif (ctx.values.json) {\n\t\t\tlog(\n\t\t\t\tJSON.stringify(\n\t\t\t\t\t{\n\t\t\t\t\t\tsessions: piData,\n\t\t\t\t\t\ttotals,\n\t\t\t\t\t},\n\t\t\t\t\tnull,\n\t\t\t\t\t2,\n\t\t\t\t),\n\t\t\t);\n\t\t} else {\n\t\t\tlogger.box('Pi-Agent Usage Report - Sessions');\n\n\t\t\tconst table = createUsageReportTable({\n\t\t\t\tfirstColumnName: 'Session',\n\t\t\t\tdateFormatter: (dateStr: string) => formatDateCompact(dateStr),\n\t\t\t});\n\n\t\t\tfor (const data of piData) {\n\t\t\t\tconst projectName = path.basename(data.projectPath);\n\t\t\t\tconst truncatedName =\n\t\t\t\t\tprojectName.length > 25 ? `${projectName.slice(0, 22)}...` : projectName;\n\n\t\t\t\tconst row = formatUsageDataRow(truncatedName, {\n\t\t\t\t\tinputTokens: data.inputTokens,\n\t\t\t\t\toutputTokens: data.outputTokens,\n\t\t\t\t\tcacheCreationTokens: data.cacheCreationTokens,\n\t\t\t\t\tcacheReadTokens: data.cacheReadTokens,\n\t\t\t\t\ttotalCost: data.totalCost,\n\t\t\t\t\tmodelsUsed: data.modelsUsed,\n\t\t\t\t});\n\t\t\t\ttable.push(row);\n\n\t\t\t\tif (ctx.values.breakdown) {\n\t\t\t\t\tpushBreakdownRows(table, data.modelBreakdowns);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\taddEmptySeparatorRow(table, 8);\n\n\t\t\tconst totalsRow = formatTotalsRow({\n\t\t\t\tinputTokens: totals.inputTokens,\n\t\t\t\toutputTokens: totals.outputTokens,\n\t\t\t\tcacheCreationTokens: totals.cacheCreationTokens,\n\t\t\t\tcacheReadTokens: totals.cacheReadTokens,\n\t\t\t\ttotalCost: totals.totalCost,\n\t\t\t});\n\t\t\ttable.push(totalsRow);\n\n\t\t\tlog(table.toString());\n\t\t}\n\t},\n});\n"
  },
  {
    "path": "apps/pi/src/data-loader.ts",
    "content": "import fs from 'node:fs';\nimport readline from 'node:readline';\nimport { glob } from 'tinyglobby';\nimport * as v from 'valibot';\nimport {\n\textractPiAgentProject,\n\textractPiAgentSessionId,\n\tgetPiAgentPaths,\n\tpiAgentMessageSchema,\n\ttransformPiAgentUsage,\n} from './_pi-agent.ts';\n\nexport type Source = 'claude-code' | 'pi-agent';\n\nexport type LoadOptions = {\n\tpiPath?: string;\n\tsince?: string;\n\tuntil?: string;\n\ttimezone?: string;\n\torder?: 'asc' | 'desc';\n};\n\nexport type DailyUsageWithSource = {\n\tdate: string;\n\tsource: Source;\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\ttotalCost: number;\n\tmodelsUsed: string[];\n\tmodelBreakdowns: Array<{\n\t\tmodelName: string;\n\t\tinputTokens: number;\n\t\toutputTokens: number;\n\t\tcacheCreationTokens: number;\n\t\tcacheReadTokens: number;\n\t\tcost: number;\n\t}>;\n};\n\nexport type SessionUsageWithSource = {\n\tsessionId: string;\n\tprojectPath: string;\n\tsource: Source;\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\ttotalCost: number;\n\tlastActivity: string;\n\tmodelsUsed: string[];\n\tmodelBreakdowns: Array<{\n\t\tmodelName: string;\n\t\tinputTokens: number;\n\t\toutputTokens: number;\n\t\tcacheCreationTokens: number;\n\t\tcacheReadTokens: number;\n\t\tcost: number;\n\t}>;\n};\n\nexport type MonthlyUsageWithSource = {\n\tmonth: string;\n\tsource: Source;\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\ttotalCost: number;\n\tmodelsUsed: string[];\n\tmodelBreakdowns: Array<{\n\t\tmodelName: string;\n\t\tinputTokens: number;\n\t\toutputTokens: number;\n\t\tcacheCreationTokens: number;\n\t\tcacheReadTokens: number;\n\t\tcost: number;\n\t}>;\n};\n\nasync function processJSONLFileByLine(\n\tfilePath: string,\n\tprocessor: (line: string) => Promise<void> | void,\n): Promise<void> {\n\tconst fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });\n\tconst rl = readline.createInterface({\n\t\tinput: fileStream,\n\t\tcrlfDelay: Infinity,\n\t});\n\n\tfor await (const line of rl) {\n\t\tconst trimmedLine = line.trim();\n\t\tif (trimmedLine !== '') {\n\t\t\tawait processor(trimmedLine);\n\t\t}\n\t}\n}\n\nasync function globPiAgentFiles(paths: string[]): Promise<string[]> {\n\tconst allFiles: string[] = [];\n\tfor (const basePath of paths) {\n\t\tconst files = await glob(['**/*.jsonl'], {\n\t\t\tcwd: basePath,\n\t\t\tabsolute: true,\n\t\t});\n\t\tallFiles.push(...files);\n\t}\n\treturn allFiles;\n}\n\nfunction formatDate(timestamp: string, timezone?: string): string {\n\tconst date = new Date(timestamp);\n\tconst tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;\n\treturn date.toLocaleDateString('en-CA', { timeZone: tz });\n}\n\nfunction formatMonth(timestamp: string, timezone?: string): string {\n\tconst date = new Date(timestamp);\n\tconst tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;\n\tconst formatted = date.toLocaleDateString('en-CA', { timeZone: tz });\n\treturn formatted.slice(0, 7);\n}\n\nfunction normalizeDate(value: string): string {\n\treturn value.replace(/-/g, '');\n}\n\nfunction isInDateRange(date: string, since?: string, until?: string): boolean {\n\tconst dateKey = normalizeDate(date);\n\tif (since != null && dateKey < normalizeDate(since)) {\n\t\treturn false;\n\t}\n\tif (until != null && dateKey > normalizeDate(until)) {\n\t\treturn false;\n\t}\n\treturn true;\n}\n\ntype EntryData = {\n\ttimestamp: string;\n\tmodel: string | undefined;\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\tcost: number;\n\tproject: string;\n\tsessionId: string;\n};\n\nexport async function loadPiAgentData(options?: LoadOptions): Promise<EntryData[]> {\n\tconst piPaths = getPiAgentPaths(options?.piPath);\n\tif (piPaths.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst files = await globPiAgentFiles(piPaths);\n\tif (files.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst processedHashes = new Set<string>();\n\tconst entries: EntryData[] = [];\n\n\tfor (const file of files) {\n\t\tconst project = extractPiAgentProject(file);\n\t\tconst sessionId = extractPiAgentSessionId(file);\n\n\t\tawait processJSONLFileByLine(file, (line) => {\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(line) as unknown;\n\t\t\t\tconst result = v.safeParse(piAgentMessageSchema, parsed);\n\t\t\t\tif (!result.success) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst data = result.output;\n\t\t\t\tconst transformed = transformPiAgentUsage(data);\n\t\t\t\tif (transformed == null) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst hash = `pi:${data.timestamp}:${transformed.totalTokens}`;\n\t\t\t\tif (processedHashes.has(hash)) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tprocessedHashes.add(hash);\n\n\t\t\t\tentries.push({\n\t\t\t\t\ttimestamp: data.timestamp,\n\t\t\t\t\tmodel: transformed.model,\n\t\t\t\t\tinputTokens: transformed.usage.input_tokens,\n\t\t\t\t\toutputTokens: transformed.usage.output_tokens,\n\t\t\t\t\tcacheCreationTokens: transformed.usage.cache_creation_input_tokens,\n\t\t\t\t\tcacheReadTokens: transformed.usage.cache_read_input_tokens,\n\t\t\t\t\tcost: transformed.costUSD ?? 0,\n\t\t\t\t\tproject,\n\t\t\t\t\tsessionId,\n\t\t\t\t});\n\t\t\t} catch {\n\t\t\t\t// Skip invalid lines\n\t\t\t}\n\t\t});\n\t}\n\n\treturn entries;\n}\n\nfunction aggregateByModel(entries: EntryData[]): Map<\n\tstring,\n\t{\n\t\tinputTokens: number;\n\t\toutputTokens: number;\n\t\tcacheCreationTokens: number;\n\t\tcacheReadTokens: number;\n\t\tcost: number;\n\t}\n> {\n\tconst modelMap = new Map<\n\t\tstring,\n\t\t{\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\tcost: number;\n\t\t}\n\t>();\n\n\tfor (const entry of entries) {\n\t\tconst model = entry.model ?? 'unknown';\n\t\tconst existing = modelMap.get(model) ?? {\n\t\t\tinputTokens: 0,\n\t\t\toutputTokens: 0,\n\t\t\tcacheCreationTokens: 0,\n\t\t\tcacheReadTokens: 0,\n\t\t\tcost: 0,\n\t\t};\n\n\t\texisting.inputTokens += entry.inputTokens;\n\t\texisting.outputTokens += entry.outputTokens;\n\t\texisting.cacheCreationTokens += entry.cacheCreationTokens;\n\t\texisting.cacheReadTokens += entry.cacheReadTokens;\n\t\texisting.cost += entry.cost;\n\n\t\tmodelMap.set(model, existing);\n\t}\n\n\treturn modelMap;\n}\n\nfunction createBreakdowns(\n\tmodelMap: Map<\n\t\tstring,\n\t\t{\n\t\t\tinputTokens: number;\n\t\t\toutputTokens: number;\n\t\t\tcacheCreationTokens: number;\n\t\t\tcacheReadTokens: number;\n\t\t\tcost: number;\n\t\t}\n\t>,\n): Array<{\n\tmodelName: string;\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\tcost: number;\n}> {\n\treturn Array.from(modelMap.entries()).map(([modelName, data]) => ({\n\t\tmodelName,\n\t\t...data,\n\t}));\n}\n\nfunction calculateTotals(entries: EntryData[]): {\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\ttotalCost: number;\n} {\n\tlet inputTokens = 0;\n\tlet outputTokens = 0;\n\tlet cacheCreationTokens = 0;\n\tlet cacheReadTokens = 0;\n\tlet totalCost = 0;\n\n\tfor (const entry of entries) {\n\t\tinputTokens += entry.inputTokens;\n\t\toutputTokens += entry.outputTokens;\n\t\tcacheCreationTokens += entry.cacheCreationTokens;\n\t\tcacheReadTokens += entry.cacheReadTokens;\n\t\ttotalCost += entry.cost;\n\t}\n\n\treturn { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalCost };\n}\n\nexport async function loadPiAgentDailyData(options?: LoadOptions): Promise<DailyUsageWithSource[]> {\n\tconst entries = await loadPiAgentData(options);\n\n\tconst grouped = new Map<string, EntryData[]>();\n\tfor (const entry of entries) {\n\t\tconst date = formatDate(entry.timestamp, options?.timezone);\n\t\tif (!isInDateRange(date, options?.since, options?.until)) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst existing = grouped.get(date) ?? [];\n\t\texisting.push(entry);\n\t\tgrouped.set(date, existing);\n\t}\n\n\tconst results: DailyUsageWithSource[] = [];\n\tfor (const [date, dateEntries] of grouped) {\n\t\tconst modelMap = aggregateByModel(dateEntries);\n\t\tconst totals = calculateTotals(dateEntries);\n\t\tconst modelsUsed = Array.from(modelMap.keys());\n\t\tconst modelBreakdowns = createBreakdowns(modelMap);\n\n\t\tresults.push({\n\t\t\tdate,\n\t\t\tsource: 'pi-agent',\n\t\t\t...totals,\n\t\t\tmodelsUsed,\n\t\t\tmodelBreakdowns,\n\t\t});\n\t}\n\n\tconst order = options?.order ?? 'desc';\n\tresults.sort((a, b) =>\n\t\torder === 'asc' ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date),\n\t);\n\n\treturn results;\n}\n\nexport async function loadPiAgentSessionData(\n\toptions?: LoadOptions,\n): Promise<SessionUsageWithSource[]> {\n\tconst entries = await loadPiAgentData(options);\n\n\tconst grouped = new Map<string, EntryData[]>();\n\tfor (const entry of entries) {\n\t\tconst key = `${entry.project}\\x00${entry.sessionId}`;\n\t\tconst existing = grouped.get(key) ?? [];\n\t\texisting.push(entry);\n\t\tgrouped.set(key, existing);\n\t}\n\n\tconst results: SessionUsageWithSource[] = [];\n\tfor (const [key, sessionEntries] of grouped) {\n\t\tconst [project, sessionId] = key.split('\\x00') as [string, string];\n\t\tconst modelMap = aggregateByModel(sessionEntries);\n\t\tconst totals = calculateTotals(sessionEntries);\n\t\tconst modelsUsed = Array.from(modelMap.keys());\n\t\tconst modelBreakdowns = createBreakdowns(modelMap);\n\n\t\tconst lastActivity = sessionEntries.reduce((latest, entry) => {\n\t\t\treturn entry.timestamp > latest ? entry.timestamp : latest;\n\t\t}, sessionEntries[0]?.timestamp ?? '');\n\n\t\tconst lastDate = formatDate(lastActivity, options?.timezone);\n\t\tif (!isInDateRange(lastDate, options?.since, options?.until)) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tresults.push({\n\t\t\tsessionId,\n\t\t\tprojectPath: project,\n\t\t\tsource: 'pi-agent',\n\t\t\t...totals,\n\t\t\tlastActivity: lastDate,\n\t\t\tmodelsUsed,\n\t\t\tmodelBreakdowns,\n\t\t});\n\t}\n\n\tconst order = options?.order ?? 'desc';\n\tresults.sort((a, b) =>\n\t\torder === 'asc'\n\t\t\t? a.lastActivity.localeCompare(b.lastActivity)\n\t\t\t: b.lastActivity.localeCompare(a.lastActivity),\n\t);\n\n\treturn results;\n}\n\nexport async function loadPiAgentMonthlyData(\n\toptions?: LoadOptions,\n): Promise<MonthlyUsageWithSource[]> {\n\tconst entries = await loadPiAgentData(options);\n\n\tconst grouped = new Map<string, EntryData[]>();\n\tfor (const entry of entries) {\n\t\tconst month = formatMonth(entry.timestamp, options?.timezone);\n\t\tconst date = formatDate(entry.timestamp, options?.timezone);\n\t\tif (!isInDateRange(date, options?.since, options?.until)) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst existing = grouped.get(month) ?? [];\n\t\texisting.push(entry);\n\t\tgrouped.set(month, existing);\n\t}\n\n\tconst results: MonthlyUsageWithSource[] = [];\n\tfor (const [month, monthEntries] of grouped) {\n\t\tconst modelMap = aggregateByModel(monthEntries);\n\t\tconst totals = calculateTotals(monthEntries);\n\t\tconst modelsUsed = Array.from(modelMap.keys());\n\t\tconst modelBreakdowns = createBreakdowns(modelMap);\n\n\t\tresults.push({\n\t\t\tmonth,\n\t\t\tsource: 'pi-agent',\n\t\t\t...totals,\n\t\t\tmodelsUsed,\n\t\t\tmodelBreakdowns,\n\t\t});\n\t}\n\n\tconst order = options?.order ?? 'desc';\n\tresults.sort((a, b) =>\n\t\torder === 'asc' ? a.month.localeCompare(b.month) : b.month.localeCompare(a.month),\n\t);\n\n\treturn results;\n}\n"
  },
  {
    "path": "apps/pi/src/index.ts",
    "content": "#!/usr/bin/env node\n\nimport process from 'node:process';\nimport { cli } from 'gunshi';\nimport { description, name, version } from '../package.json';\nimport { dailyCommand } from './commands/daily.ts';\nimport { monthlyCommand } from './commands/monthly.ts';\nimport { sessionCommand } from './commands/session.ts';\n\nconst subCommands = new Map([\n\t['daily', dailyCommand],\n\t['monthly', monthlyCommand],\n\t['session', sessionCommand],\n]);\n\nconst mainCommand = dailyCommand;\n\nasync function run(): Promise<void> {\n\tlet args = process.argv.slice(2);\n\tif (args[0] === 'ccusage-pi') {\n\t\targs = args.slice(1);\n\t}\n\n\tawait cli(args, mainCommand, {\n\t\tname,\n\t\tversion,\n\t\tdescription,\n\t\tsubCommands,\n\t\trenderHeader: null,\n\t});\n}\n\n// eslint-disable-next-line antfu/no-top-level-await\nawait run();\n"
  },
  {
    "path": "apps/pi/src/logger.ts",
    "content": "import { createLogger, log as internalLog } from '@ccusage/internal/logger';\n\nimport { name } from '../package.json';\n\nexport const logger = createLogger(name);\n\nexport const log = internalLog;\n"
  },
  {
    "path": "apps/pi/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"jsx\": \"react-jsx\",\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"moduleDetection\": \"force\",\n\t\t\"module\": \"Preserve\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"types\": [\"@types/bun\", \"vitest/globals\", \"vitest/importMeta\"],\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"allowJs\": true,\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noPropertyAccessFromIndexSignature\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t\"noUnusedLocals\": false,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noEmit\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"exclude\": [\"dist\"]\n}\n"
  },
  {
    "path": "apps/pi/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n\tentry: ['src/index.ts'],\n\toutDir: 'dist',\n\tformat: 'esm',\n\tclean: true,\n\tsourcemap: false,\n\tminify: 'dce-only',\n\ttreeshake: true,\n\tfixedExtension: false,\n\tdts: {\n\t\ttsgo: true,\n\t},\n\tpublint: true,\n\tunused: true,\n\texports: {\n\t\tdevExports: true,\n\t},\n\tnodeProtocol: true,\n\tdefine: {\n\t\t'import.meta.vitest': 'undefined',\n\t},\n});\n"
  },
  {
    "path": "apps/pi/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\twatch: false,\n\t\tincludeSource: ['src/**/*.{js,ts}'],\n\t\tglobals: true,\n\t},\n});\n"
  },
  {
    "path": "ccusage.example.json",
    "content": "{\n\t\"$schema\": \"./apps/ccusage/config-schema.json\",\n\t\"defaults\": {\n\t\t\"json\": true,\n\t\t\"mode\": \"auto\",\n\t\t\"timezone\": \"Asia/Tokyo\",\n\t\t\"locale\": \"ja-JP\",\n\t\t\"offline\": false,\n\t\t\"breakdown\": false\n\t},\n\t\"commands\": {\n\t\t\"daily\": {\n\t\t\t\"instances\": true,\n\t\t\t\"order\": \"desc\",\n\t\t\t\"projectAliases\": \"ccusage=Usage Tracker,my-long-project-name=Project X\"\n\t\t},\n\t\t\"monthly\": {\n\t\t\t\"breakdown\": true\n\t\t},\n\t\t\"weekly\": {\n\t\t\t\"startOfWeek\": \"monday\"\n\t\t},\n\t\t\"blocks\": {\n\t\t\t\"tokenLimit\": \"500000\",\n\t\t\t\"sessionLength\": 5,\n\t\t\t\"active\": false\n\t\t},\n\t\t\"statusline\": {\n\t\t\t\"offline\": true\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "# VitePress build output\n.vitepress/dist/\n.vitepress/cache/\n\n# Generated documentation\napi/\n\n# Dependencies\nnode_modules/\n\n# Temporary files\n.temp/\n.cache/\n\n# OS files\n.DS_Store\nThumbs.db\n\npublic/_redirects\npublic/config-schema.json\n"
  },
  {
    "path": "docs/.vitepress/config.ts",
    "content": "import type { DefaultTheme } from 'vitepress';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { cloudflareRedirect } from '@ryoppippi/vite-plugin-cloudflare-redirect';\nimport { defineConfig } from 'vitepress';\nimport { groupIconMdPlugin, groupIconVitePlugin } from 'vitepress-plugin-group-icons';\nimport llmstxt from 'vitepress-plugin-llms';\n\nconst typedocSidebarJson = fs.readFileSync(\n\tpath.join(import.meta.dirname, '../api/typedoc-sidebar.json'),\n);\nconst typedocSidebar = JSON.parse(typedocSidebarJson.toString()) as DefaultTheme.SidebarItem[];\n\nexport default defineConfig({\n\ttitle: 'ccusage',\n\tdescription: 'Usage analysis tool for Claude Code',\n\tbase: '/',\n\tcleanUrls: true,\n\tignoreDeadLinks: true,\n\n\thead: [\n\t\t['link', { rel: 'icon', href: '/favicon.svg' }],\n\t\t['meta', { name: 'theme-color', content: '#646cff' }],\n\t\t['meta', { property: 'og:type', content: 'website' }],\n\t\t['meta', { property: 'og:locale', content: 'en' }],\n\t\t['meta', { property: 'og:title', content: 'ccusage | Claude Code Usage Analysis' }],\n\t\t['meta', { property: 'og:site_name', content: 'ccusage' }],\n\t\t[\n\t\t\t'meta',\n\t\t\t{\n\t\t\t\tproperty: 'og:image',\n\t\t\t\tcontent: 'https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.png',\n\t\t\t},\n\t\t],\n\t\t['meta', { property: 'og:url', content: 'https://github.com/ryoppippi/ccusage' }],\n\t],\n\n\tthemeConfig: {\n\t\tlogo: '/logo.svg',\n\n\t\tnav: [\n\t\t\t{ text: 'Guide', link: '/guide/' },\n\t\t\t{ text: 'API Reference', link: '/api/' },\n\t\t\t{\n\t\t\t\ttext: 'Links',\n\t\t\t\titems: [\n\t\t\t\t\t{ text: 'GitHub', link: 'https://github.com/ryoppippi/ccusage' },\n\t\t\t\t\t{ text: 'npm', link: 'https://www.npmjs.com/package/ccusage' },\n\t\t\t\t\t{ text: 'Changelog', link: 'https://github.com/ryoppippi/ccusage/releases' },\n\t\t\t\t\t{ text: 'DeepWiki', link: 'https://deepwiki.com/ryoppippi/ccusage' },\n\t\t\t\t\t{ text: 'Package Stats', link: 'https://tanstack.com/ccusage?npmPackage=ccusage' },\n\t\t\t\t\t{ text: 'Sponsor', link: 'https://github.com/sponsors/ryoppippi' },\n\t\t\t\t],\n\t\t\t},\n\t\t],\n\n\t\tsidebar: {\n\t\t\t'/guide/': [\n\t\t\t\t{\n\t\t\t\t\ttext: 'Introduction',\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{ text: 'Introduction', link: '/guide/' },\n\t\t\t\t\t\t{ text: 'Getting Started', link: '/guide/getting-started' },\n\t\t\t\t\t\t{ text: 'Installation', link: '/guide/installation' },\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: 'Usage',\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{ text: 'Daily Reports', link: '/guide/daily-reports' },\n\t\t\t\t\t\t{ text: 'Weekly Reports', link: '/guide/weekly-reports' },\n\t\t\t\t\t\t{ text: 'Monthly Reports', link: '/guide/monthly-reports' },\n\t\t\t\t\t\t{ text: 'Session Reports', link: '/guide/session-reports' },\n\t\t\t\t\t\t{ text: 'Blocks Reports', link: '/guide/blocks-reports' },\n\t\t\t\t\t\t{ text: 'Live Monitoring (Removed)', link: '/guide/live-monitoring' },\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: 'Codex (Beta)',\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{ text: 'Overview', link: '/guide/codex/' },\n\t\t\t\t\t\t{ text: 'Daily Report', link: '/guide/codex/daily' },\n\t\t\t\t\t\t{ text: 'Monthly Report', link: '/guide/codex/monthly' },\n\t\t\t\t\t\t{ text: 'Session Report', link: '/guide/codex/session' },\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: 'OpenCode (Beta)',\n\t\t\t\t\titems: [{ text: 'Overview', link: '/guide/opencode/' }],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: 'Configuration',\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{ text: 'Overview', link: '/guide/configuration' },\n\t\t\t\t\t\t{ text: 'Command-Line Options', link: '/guide/cli-options' },\n\t\t\t\t\t\t{ text: 'Environment Variables', link: '/guide/environment-variables' },\n\t\t\t\t\t\t{ text: 'Configuration Files', link: '/guide/config-files' },\n\t\t\t\t\t\t{ text: 'Directory Detection', link: '/guide/directory-detection' },\n\t\t\t\t\t\t{ text: 'Custom Paths', link: '/guide/custom-paths' },\n\t\t\t\t\t\t{ text: 'Cost Calculation Modes', link: '/guide/cost-modes' },\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: 'Integration',\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{ text: 'Library Usage', link: '/guide/library-usage' },\n\t\t\t\t\t\t{ text: 'MCP Server', link: '/guide/mcp-server' },\n\t\t\t\t\t\t{ text: 'JSON Output', link: '/guide/json-output' },\n\t\t\t\t\t\t{ text: 'Statusline Integration', link: '/guide/statusline' },\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: 'Community',\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{ text: 'Related Projects', link: '/guide/related-projects' },\n\t\t\t\t\t\t{ text: 'Sponsors', link: '/guide/sponsors' },\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t],\n\t\t\t'/api/': [\n\t\t\t\t{\n\t\t\t\t\ttext: 'API Reference',\n\t\t\t\t\titems: [{ text: 'Overview', link: '/api/' }, ...typedocSidebar],\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\n\t\tsocialLinks: [\n\t\t\t{ icon: 'github', link: 'https://github.com/ryoppippi/ccusage' },\n\t\t\t{ icon: 'npm', link: 'https://www.npmjs.com/package/ccusage' },\n\t\t\t{ icon: 'twitter', link: 'https://x.com/cc_usage' },\n\t\t],\n\n\t\tfooter: {\n\t\t\tmessage: 'Released under the MIT License.',\n\t\t\tcopyright: 'Copyright © 2025 ryoppippi',\n\t\t},\n\n\t\tsearch: {\n\t\t\tprovider: 'local',\n\t\t},\n\n\t\teditLink: {\n\t\t\tpattern: 'https://github.com/ryoppippi/ccusage/edit/main/docs/:path',\n\t\t\ttext: 'Edit this page on GitHub',\n\t\t},\n\n\t\tlastUpdated: {\n\t\t\ttext: 'Updated at',\n\t\t\tformatOptions: {\n\t\t\t\tyear: 'numeric',\n\t\t\t\tmonth: '2-digit',\n\t\t\t\tday: '2-digit',\n\t\t\t\thour: '2-digit',\n\t\t\t\tminute: '2-digit',\n\t\t\t\thour12: false,\n\t\t\t\ttimeZone: 'UTC',\n\t\t\t},\n\t\t},\n\t},\n\n\tvite: {\n\t\tplugins: [\n\t\t\tcloudflareRedirect({\n\t\t\t\tmode: 'generate',\n\t\t\t\tentries: [\n\t\t\t\t\t{ from: '/raycast', to: 'https://www.raycast.com/nyatinte/ccusage', status: 302 },\n\t\t\t\t\t{ from: '/gh', to: 'https://github.com/ryoppippi/ccusage', status: 302 },\n\t\t\t\t\t{ from: '/npm', to: 'https://www.npmjs.com/package/ccusage', status: 302 },\n\t\t\t\t\t{ from: '/deepwiki', to: 'https://deepwiki.com/ryoppippi/ccusage', status: 302 },\n\t\t\t\t\t{ from: '/sponsor', to: 'https://github.com/sponsors/ryoppippi', status: 302 },\n\t\t\t\t],\n\t\t\t}) as any,\n\t\t\tgroupIconVitePlugin(),\n\t\t\t...llmstxt(),\n\t\t],\n\t},\n\n\tmarkdown: {\n\t\tconfig(md) {\n\t\t\tmd.use(groupIconMdPlugin);\n\t\t},\n\t},\n});\n"
  },
  {
    "path": "docs/CLAUDE.md",
    "content": "# CLAUDE.md - Documentation\n\nThis directory contains the VitePress-based documentation website for ccusage.\n\n## Package Overview\n\n**Name**: `@ccusage/docs`\n**Description**: Documentation for ccusage\n**Type**: VitePress documentation site (private package)\n\n## Development Commands\n\n**Documentation Development:**\n\n- `pnpm run dev` - Start development server with API docs generation and schema copy\n- `pnpm run build` - Build documentation site for production\n- `pnpm run preview` - Preview built documentation locally\n- `pnpm run docs:api` - Generate API documentation from TypeScript source\n- `pnpm run lint` - Lint documentation files using ESLint\n- `pnpm run format` - Format and auto-fix documentation files with ESLint\n- `pnpm typecheck` - Type check TypeScript files\n\n**Deployment:**\n\n- `pnpm run deploy` - Deploy to Cloudflare using Wrangler\n\n## Architecture\n\n**Documentation Structure:**\n\n- `guide/` - User guides and tutorials with screenshots\n- `api/` - Auto-generated API documentation from TypeScript source\n- `public/` - Static assets including screenshots and config schema\n- `.vitepress/` - VitePress configuration and theme customization\n\n**Key Files:**\n\n- `update-api-index.ts` - Script to generate API documentation index\n- `typedoc.config.mjs` - TypeDoc configuration for API docs generation\n- `public/config-schema.json` - JSON schema copied from ccusage package during build\n\n## Documentation Guidelines\n\n**Screenshot Usage:**\n\n- **Placement**: Always place screenshots immediately after main headings (H1)\n- **Purpose**: Provide immediate visual context before textual explanations\n- **Guides with Screenshots**:\n  - `/docs/guide/index.md` - Main usage screenshot\n  - `/docs/guide/daily-reports.md` - Daily report output screenshot\n  - `/docs/guide/live-monitoring.md` - Live monitoring dashboard screenshot\n  - `/docs/guide/mcp-server.md` - Claude Desktop integration screenshot\n- **Image Path**: Use relative paths like `/screenshot.png` for images in `/docs/public/`\n- **Alt Text**: Always include descriptive alt text for accessibility\n\n**Content Organization:**\n\n- User-facing guides in `guide/` directory\n- Auto-generated API reference in `api/` directory\n- Static assets and schemas in `public/` directory\n\n## Build Process\n\n1. **API Documentation**: `./update-api-index.ts` generates API docs from ccusage TypeScript source\n2. **Schema Copy**: `config-schema.json` is copied from the ccusage package to public directory\n3. **VitePress Build**: Standard VitePress build process creates static site\n4. **Deployment**: Built site is deployed to Cloudflare using Wrangler\n\n## Dependencies\n\n**Key Dev Dependencies:**\n\n- `vitepress` - Static site generator\n- `typedoc` - API documentation generation\n- `typedoc-plugin-markdown` - Markdown output for TypeDoc\n- `typedoc-vitepress-theme` - VitePress theme for TypeDoc\n- `wrangler` - Cloudflare deployment tool\n- `ccusage` - Main package (workspace dependency for API docs)\n\n**VitePress Plugins:**\n\n- `vitepress-plugin-group-icons` - Group icons in navigation\n- `vitepress-plugin-llms` - LLM-specific enhancements\n- `@ryoppippi/vite-plugin-cloudflare-redirect` - Cloudflare redirect handling\n\n## Development Workflow\n\n1. **Start Development**: `pnpm run dev` automatically generates API docs and starts dev server\n2. **Edit Content**: Modify markdown files in `guide/` or update source code for API changes\n3. **Preview Changes**: Development server automatically reloads on changes\n4. **Build for Production**: `pnpm run build` generates final static site\n5. **Deploy**: `pnpm run deploy` pushes to Cloudflare\n\n## Content Guidelines\n\n- **No console.log**: Documentation scripts should use appropriate logging\n- **Accessibility**: Always include alt text for images and screenshots\n- **Visual First**: Lead with screenshots, then explain with text\n- **Consistency**: Follow established patterns for new documentation pages\n- **Cross-References**: Link between related guides and API documentation\n- **ESLint in Markdown**: For code blocks that should skip ESLint parsing (e.g., containing `...` syntax), add `<!-- eslint-skip -->` before the code block\n\n## File Organization\n\n```\ndocs/\n├── guide/          # User guides and tutorials\n├── api/            # Auto-generated API docs\n├── public/         # Static assets (screenshots, schemas)\n├── .vitepress/     # VitePress configuration\n├── package.json    # Dependencies and scripts\n└── CLAUDE.md       # This file\n```\n"
  },
  {
    "path": "docs/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\nexport default ryoppippi({\n\ttype: 'app',\n\tmarkdown: true,\n\tstylistic: false,\n});\n"
  },
  {
    "path": "docs/guide/blocks-reports.md",
    "content": "# Blocks Reports\n\nBlocks reports show your Claude Code usage grouped by 5-hour billing windows, helping you understand Claude's billing cycle and track active session progress.\n\n## Basic Usage\n\n```bash\nccusage blocks\n```\n\n## Example Output\n\n```\n╭──────────────────────────────────────────────────╮\n│                                                  │\n│  Claude Code Token Usage Report - Session Blocks │\n│                                                  │\n╰──────────────────────────────────────────────────╯\n\n┌─────────────────────┬──────────────────┬────────┬─────────┬──────────────┬────────────┬──────────────┬────────────┐\n│ Block Start Time    │ Models           │ Input  │ Output  │ Cache Create │ Cache Read │ Total Tokens │ Cost (USD) │\n├─────────────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┤\n│ 2025-06-21 09:00:00 │ • opus-4         │  4,512 │ 285,846 │          512 │      1,024 │      291,894 │    $156.40 │\n│ ⏰ Active (2h 15m)  │ • sonnet-4       │        │         │              │            │              │            │\n│ 🔥 Rate: 2.1k/min   │                  │        │         │              │            │              │            │\n│ 📊 Projected: 450k  │                  │        │         │              │            │              │            │\n├─────────────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┤\n│ 2025-06-21 04:00:00 │ • sonnet-4       │  2,775 │ 186,645 │          256 │        768 │      190,444 │     $98.45 │\n│ ✅ Completed (3h 42m)│                  │        │         │              │            │              │            │\n├─────────────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┤\n│ 2025-06-20 15:30:00 │ • opus-4         │  1,887 │ 183,055 │          128 │        512 │      185,582 │     $81.73 │\n│ ✅ Completed (4h 12m)│                  │        │         │              │            │              │            │\n├─────────────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┤\n│ Total               │                  │  9,174 │ 655,546 │          896 │      2,304 │      667,920 │    $336.58 │\n└─────────────────────┴──────────────────┴────────┴─────────┴──────────────┴────────────┴──────────────┴────────────┘\n```\n\n## Understanding Blocks\n\n### Session Block Concept\n\nClaude Code uses **5-hour billing windows** for session tracking:\n\n- **Block Start**: Triggered by your first message\n- **Block Duration**: Lasts exactly 5 hours from start time\n- **Rolling Windows**: New blocks start with activity after previous block expires\n- **Billing Relevance**: Matches Claude's internal session tracking\n- **UTC Time Handling**: Block boundaries are calculated in UTC to ensure consistent behavior across time zones\n\n### Block Status Indicators\n\n- **⏰ Active**: Currently running block with time remaining\n- **✅ Completed**: Finished block that ran its full duration or ended naturally\n- **⌛ Gap**: Time periods with no activity (shown when relevant)\n- **🔥 Rate**: Token burn rate (tokens per minute) for active blocks\n- **📊 Projected**: Estimated total tokens if current rate continues\n\n## Command Options\n\n### Show Active Block Only\n\nFocus on your current session with detailed projections:\n\n```bash\nccusage blocks --active\n```\n\nThis shows only the currently active block with:\n\n- Time remaining in the 5-hour window\n- Current token burn rate\n- Projected final token count and cost\n\n### Show Recent Blocks\n\nDisplay blocks from the last 3 days (including active):\n\n```bash\nccusage blocks --recent\n```\n\nPerfect for understanding recent usage patterns without scrolling through all historical data.\n\n### Token Limit Tracking\n\nSet token limits to monitor quota usage:\n\n```bash\n# Set explicit token limit\nccusage blocks --token-limit 500000\n\n# Use highest previous block as limit\nccusage blocks --token-limit max\n# or short form:\nccusage blocks -t max\n```\n\nWhen limits are set, blocks display:\n\n- ⚠️ **Warning indicators** when approaching limits\n- 🚨 **Alert indicators** when exceeding limits\n- **Progress bars** showing usage relative to limit\n\n### Live Monitoring (Removed)\n\n::: danger REMOVED IN v18\nThe `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.\n:::\n\nPreviously available options (v17.x only):\n\n```bash\n# Basic live monitoring (uses -t max automatically)\nccusage blocks --live\n\n# Live monitoring with explicit token limit\nccusage blocks --live --token-limit 500000\n\n# Custom refresh interval (1-60 seconds)\nccusage blocks --live --refresh-interval 5\n```\n\n### Custom Session Duration\n\nChange the block duration (default is 5 hours):\n\n```bash\n# 3-hour blocks\nccusage blocks --session-length 3\n\n# 8-hour blocks\nccusage blocks --session-length 8\n```\n\n### Date Filtering\n\nFilter blocks by date range:\n\n```bash\n# Show blocks from specific date range\nccusage blocks --since 20250620 --until 20250621\n\n# Show blocks from last week\nccusage blocks --since $(date -d '7 days ago' +%Y%m%d)\n```\n\n### Sort Order\n\n```bash\n# Show newest blocks first (default)\nccusage blocks --order desc\n\n# Show oldest blocks first\nccusage blocks --order asc\n```\n\n### Cost Calculation Modes\n\n```bash\n# Use pre-calculated costs when available (default)\nccusage blocks --mode auto\n\n# Always calculate costs from tokens\nccusage blocks --mode calculate\n\n# Only show pre-calculated costs\nccusage blocks --mode display\n```\n\n### JSON Output\n\nExport block data for analysis:\n\n```bash\nccusage blocks --json\n```\n\n```json\n{\n\t\"blocks\": [\n\t\t{\n\t\t\t\"id\": \"2025-06-21T09:00:00.000Z\",\n\t\t\t\"startTime\": \"2025-06-21T09:00:00.000Z\",\n\t\t\t\"endTime\": \"2025-06-21T14:00:00.000Z\",\n\t\t\t\"actualEndTime\": \"2025-06-21T11:15:00.000Z\",\n\t\t\t\"isActive\": true,\n\t\t\t\"tokenCounts\": {\n\t\t\t\t\"inputTokens\": 4512,\n\t\t\t\t\"outputTokens\": 285846,\n\t\t\t\t\"cacheCreationInputTokens\": 512,\n\t\t\t\t\"cacheReadInputTokens\": 1024\n\t\t\t},\n\t\t\t\"costUSD\": 156.4,\n\t\t\t\"models\": [\"opus-4\", \"sonnet-4\"]\n\t\t}\n\t]\n}\n```\n\n### Offline Mode\n\nUse cached pricing data without network access:\n\n```bash\nccusage blocks --offline\n# or short form:\nccusage blocks -O\n```\n\n## Analysis Use Cases\n\n### Session Planning\n\nUnderstanding 5-hour windows helps with:\n\n```bash\n# Check current active block\nccusage blocks --active\n```\n\n- **Time Management**: Know how much time remains in current session\n- **Usage Pacing**: Monitor if you're on track for reasonable usage\n- **Break Planning**: Understand when current session will expire\n\n### Usage Optimization\n\n```bash\n# Find your highest usage patterns\nccusage blocks -t max --recent\n```\n\n- **Peak Usage Identification**: Which blocks consumed the most tokens\n- **Efficiency Patterns**: Compare block efficiency (tokens per hour)\n- **Model Selection Impact**: How model choice affects block costs\n\n### Real-time Session Tracking\n\n```bash\n# Monitor active sessions in real-time\nccusage statusline\n```\n\nPerfect for:\n\n- **Long coding sessions**: Track progress against historical limits\n- **Budget management**: Watch costs accumulate in real-time\n- **Productivity tracking**: Understand work intensity patterns\n\n### Historical Analysis\n\n```bash\n# Export data for detailed analysis\nccusage blocks --json > blocks-history.json\n\n# Analyze patterns over time\nccusage blocks --since 20250601 --until 20250630\n```\n\n## Block Analysis Tips\n\n### 1. Understanding Block Efficiency\n\nLook for patterns in your block data:\n\n- **High-efficiency blocks**: Lots of output tokens for minimal input\n- **Exploratory blocks**: High input/output ratios (research, debugging)\n- **Focused blocks**: Steady token burn rates with clear objectives\n\n### 2. Time Management\n\nUse blocks to optimize your Claude usage:\n\n- **Session planning**: Start important work at the beginning of blocks\n- **Break timing**: Use block boundaries for natural work breaks\n- **Batch processing**: Group similar tasks within single blocks\n\n### 3. Cost Optimization\n\nBlocks help identify cost patterns:\n\n- **Model switching**: When to use Opus vs Sonnet within blocks\n- **Cache efficiency**: How cache usage affects block costs\n- **Usage intensity**: Whether short focused sessions or long exploratory ones are more cost-effective\n\n### 4. Quota Management\n\nWhen working with token limits:\n\n- **Rate monitoring**: Watch burn rates to avoid exceeding limits\n- **Early warning**: Set limits below actual quotas for safety margin\n- **Usage spreading**: Distribute heavy usage across multiple blocks\n\n## Responsive Display\n\nBlocks reports adapt to your terminal width:\n\n- **Wide terminals (≥100 chars)**: Shows all columns with full timestamps\n- **Narrow terminals (<100 chars)**: Compact mode with abbreviated times and essential data\n\n## Advanced Features\n\n### Gap Detection\n\nBlocks reports automatically detect and display gaps:\n\n```\n┌─────────────────────┬──────────────────┬────────┬─────────┬────────────┐\n│ 2025-06-21 09:00:00 │ • opus-4         │  4,512 │ 285,846 │    $156.40 │\n│ ⏰ Active (2h 15m)  │ • sonnet-4       │        │         │            │\n├─────────────────────┼──────────────────┼────────┼─────────┼────────────┤\n│ 2025-06-20 22:00:00 │ ⌛ 11h gap       │      0 │       0 │      $0.00 │\n│ 2025-06-21 09:00:00 │                  │        │         │            │\n├─────────────────────┼──────────────────┼────────┼─────────┼────────────┤\n│ 2025-06-20 15:30:00 │ • opus-4         │  1,887 │ 183,055 │     $81.73 │\n│ ✅ Completed (4h 12m)│                  │        │         │            │\n└─────────────────────┴──────────────────┴────────┴─────────┴────────────┘\n```\n\n### Burn Rate Calculations\n\nFor active blocks, the tool calculates:\n\n- **Tokens per minute**: Based on activity within the block\n- **Cost per hour**: Projected hourly spend rate\n- **Projected totals**: Estimated final tokens/cost if current rate continues\n\n### Progress Visualization\n\nWhen using token limits, blocks show visual progress:\n\n- **Green**: Usage well below limit (< 70%)\n- **Yellow**: Approaching limit (70-90%)\n- **Red**: At or exceeding limit (≥ 90%)\n\n## Related Commands\n\n- [Daily Reports](/guide/daily-reports) - Usage aggregated by calendar date\n- [Monthly Reports](/guide/monthly-reports) - Monthly usage summaries\n- [Session Reports](/guide/session-reports) - Individual conversation analysis\n- [Statusline](/guide/statusline) - Real-time session tracking (replacement for live monitoring)\n\n## Next Steps\n\nAfter understanding block patterns, consider:\n\n1. [Statusline](/guide/statusline) for real-time active session tracking\n2. [Session Reports](/guide/session-reports) to analyze individual conversations within blocks\n3. [Daily Reports](/guide/daily-reports) to see how blocks aggregate across days\n"
  },
  {
    "path": "docs/guide/cli-options.md",
    "content": "# Command-Line Options\n\nccusage provides extensive command-line options to customize its behavior. These options take precedence over configuration files and environment variables.\n\n## Global Options\n\nAll ccusage commands support these global options:\n\n### Date Filtering\n\nFilter usage data by date range:\n\n```bash\n# Filter by date range\nccusage daily --since 20250101 --until 20250630\n\n# Show data from a specific date\nccusage monthly --since 20250101\n\n# Show data up to a specific date\nccusage session --until 20250630\n```\n\n### Output Format\n\nControl how data is displayed:\n\n```bash\n# JSON output for programmatic use\nccusage daily --json\nccusage daily -j\n\n# Show per-model breakdown\nccusage daily --breakdown\nccusage daily -b\n\n# Combine options\nccusage daily --json --breakdown\n```\n\n### Cost Calculation Mode\n\nChoose how costs are calculated:\n\n```bash\n# Auto mode (default) - use costUSD when available\nccusage daily --mode auto\n\n# Calculate mode - always calculate from tokens\nccusage daily --mode calculate\n\n# Display mode - only show pre-calculated costUSD\nccusage daily --mode display\n```\n\n### Sort Order\n\nControl the ordering of results:\n\n```bash\n# Newest first (default)\nccusage daily --order desc\n\n# Oldest first\nccusage daily --order asc\n```\n\n### Offline Mode\n\nRun without network connectivity:\n\n```bash\n# Use cached pricing data\nccusage daily --offline\nccusage daily -O\n```\n\n### Timezone\n\nSet the timezone for date calculations:\n\n```bash\n# Use UTC timezone\nccusage daily --timezone UTC\n\n# Use specific timezone\nccusage daily --timezone America/New_York\nccusage daily -z Asia/Tokyo\n\n# Short alias\nccusage monthly -z Europe/London\n```\n\n#### Timezone Effect\n\nThe timezone affects how usage is grouped by date. For example, usage at 11 PM UTC on January 1st would appear on:\n\n- **January 1st** when `--timezone UTC`\n- **January 1st** when `--timezone America/New_York` (6 PM EST)\n- **January 2nd** when `--timezone Asia/Tokyo` (8 AM JST next day)\n\n### Locale\n\nControl date and time formatting:\n\n```bash\n# US English (12-hour time format)\nccusage daily --locale en-US\n\n# Japanese (24-hour time format)\nccusage blocks --locale ja-JP\n\n# German (24-hour time format)\nccusage session -l de-DE\n\n# Short alias\nccusage daily -l fr-FR\n```\n\n#### Locale Effects\n\nThe locale affects display formatting:\n\n**Date Format:**\n\n- `en-US`: 08/04/2025\n- `en-CA`: 2025-08-04 (ISO format, default)\n- `ja-JP`: 2025/08/04\n- `de-DE`: 04.08.2025\n\n**Time Format:**\n\n- `en-US`: 3:30:00 PM (12-hour)\n- Others: 15:30:00 (24-hour)\n\n### Debug Options\n\nGet detailed debugging information:\n\n```bash\n# Debug mode - show pricing mismatches and config loading\nccusage daily --debug\n\n# Show sample discrepancies\nccusage daily --debug --debug-samples 10\n```\n\n### Configuration File\n\nUse a custom configuration file:\n\n```bash\n# Specify custom config file\nccusage daily --config ./my-config.json\nccusage monthly --config /path/to/team-config.json\n```\n\n## Command-Specific Options\n\n### Daily Command\n\nAdditional options for daily reports:\n\n```bash\n# Group by project\nccusage daily --instances\nccusage daily -i\n\n# Filter to specific project\nccusage daily --project myproject\nccusage daily -p myproject\n\n# Combine project filtering\nccusage daily --instances --project myproject\n```\n\n### Weekly Command\n\nOptions for weekly reports:\n\n```bash\n# Set week start day\nccusage weekly --start-of-week monday\nccusage weekly --start-of-week sunday\n```\n\n### Session Command\n\nOptions for session reports:\n\n```bash\n# Filter by session ID\nccusage session --id abc123-session\n\n# Filter by project\nccusage session --project myproject\n```\n\n### Blocks Command\n\nOptions for 5-hour billing blocks:\n\n```bash\n# Show only active block\nccusage blocks --active\nccusage blocks -a\n\n# Show recent blocks (last 3 days)\nccusage blocks --recent\nccusage blocks -r\n\n# Set token limit for warnings\nccusage blocks --token-limit 500000\nccusage blocks --token-limit max\n\n# Live monitoring mode\nccusage blocks --live\nccusage blocks --live --refresh-interval 2\n\n# Customize session length\nccusage blocks --session-length 5\n```\n\n> **Note:** The MCP server CLI moved to the dedicated `@ccusage/mcp` package. See the [MCP Server guide](/guide/mcp-server) for usage details.\n\n### Statusline\n\nOptions for statusline display:\n\n```bash\n# Basic statusline\nccusage statusline\n\n# Force offline mode\nccusage statusline --offline\n\n# Enable caching\nccusage statusline --cache\n\n# Custom refresh interval\nccusage statusline --refresh-interval 5\n```\n\n## JSON Output Options\n\nWhen using `--json` output, additional processing options are available:\n\n```bash\n# Apply jq filter to JSON output\nccusage daily --json --jq \".data[]\"\n\n# Filter high-cost days\nccusage daily --json --jq \".data[] | select(.cost > 10)\"\n\n# Extract specific fields\nccusage session --json --jq \".data[] | {date, cost}\"\n```\n\n## Option Precedence\n\nOptions are applied in this order (highest to lowest priority):\n\n1. **Command-line arguments** - Direct CLI options\n2. **Custom config file** - Via `--config` flag\n3. **Local project config** - `.ccusage/ccusage.json`\n4. **User config** - `~/.config/claude/ccusage.json`\n5. **Legacy config** - `~/.claude/ccusage.json`\n6. **Built-in defaults**\n\n## Examples\n\n### Development Workflow\n\n```bash\n# Daily development check\nccusage daily --instances --breakdown\n\n# Check specific project costs\nccusage daily --project myapp --since 20250101\n\n# Export for reporting\nccusage monthly --json > monthly-report.json\n```\n\n### Team Collaboration\n\n```bash\n# Use team configuration\nccusage daily --config ./team-config.json\n\n# Consistent timezone for remote team\nccusage daily --timezone UTC --locale en-CA\n\n# Generate shareable report\nccusage weekly --json --jq \".summary\"\n```\n\n### Cost Monitoring\n\n```bash\n# Monitor active usage\nccusage blocks --active --live\n\n# Check if approaching limits\nccusage blocks --token-limit 500000\n\n# Historical analysis\nccusage monthly --mode calculate --breakdown\n```\n\n### Debugging Issues\n\n```bash\n# Debug configuration loading\nccusage daily --debug --config ./test-config.json\n\n# Check pricing discrepancies\nccusage daily --debug --debug-samples 20\n\n# Silent mode for scripts\nLOG_LEVEL=0 ccusage daily --json\n```\n\n## Short Aliases\n\nMany options have short aliases for convenience:\n\n| Long Option   | Short | Description         |\n| ------------- | ----- | ------------------- |\n| `--json`      | `-j`  | JSON output         |\n| `--breakdown` | `-b`  | Per-model breakdown |\n| `--offline`   | `-O`  | Offline mode        |\n| `--timezone`  | `-z`  | Set timezone        |\n| `--locale`    | `-l`  | Set locale          |\n| `--instances` | `-i`  | Group by project    |\n| `--project`   | `-p`  | Filter project      |\n| `--active`    | `-a`  | Active block only   |\n| `--recent`    | `-r`  | Recent blocks       |\n\n## Related Documentation\n\n- [Environment Variables](/guide/environment-variables) - Configure via environment\n- [Configuration Files](/guide/config-files) - Persistent configuration\n- [Cost Calculation Modes](/guide/cost-modes) - Understanding cost modes\n"
  },
  {
    "path": "docs/guide/codex/daily.md",
    "content": "# Codex Daily Report (Beta)\n\nThe `daily` command mirrors ccusage's daily report but operates on Codex CLI session logs.\n\n```bash\n# Recommended (fastest)\nbunx @ccusage/codex@latest daily\n\n# Using npx\nnpx @ccusage/codex@latest daily\n```\n\n## Options\n\n| Flag                         | Description                                                    |\n| ---------------------------- | -------------------------------------------------------------- |\n| `--since` / `--until`        | Filter to a specific date range (YYYYMMDD or YYYY-MM-DD)       |\n| `--timezone`                 | Override timezone used for grouping (defaults to system)       |\n| `--locale`                   | Adjust date formatting locale                                  |\n| `--json`                     | Emit structured JSON instead of a table                        |\n| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching           |\n| `--compact`                  | Force compact table layout (same columns as a narrow terminal) |\n\nThe output uses the same responsive table component as ccusage, including compact mode support and per-model token summaries.\n\nNeed higher-level trends? Switch to the [monthly report](./monthly.md) for month-by-month rollups with the same flag set.\n"
  },
  {
    "path": "docs/guide/codex/index.md",
    "content": "# Codex CLI Overview (Beta)\n\n![Codex CLI daily report](/codex-cli.jpeg)\n\n> ⚠️ 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.\n\nThe `@ccusage/codex` package reuses ccusage's responsive tables, pricing cache, and token accounting to analyze OpenAI Codex CLI session logs.\n\n## Installation & Launch\n\n```bash\n# Recommended - always include @latest\nnpx @ccusage/codex@latest --help\nbunx @ccusage/codex@latest --help  # ⚠️ MUST include @latest with bunx\n\n# Alternative package runners\npnpm dlx @ccusage/codex --help\npnpx @ccusage/codex --help\n\n# Using deno (with security flags)\ndeno run -E -R=$HOME/.codex/ -S=homedir -N='raw.githubusercontent.com:443' npm:@ccusage/codex@latest --help\n```\n\n::: warning ⚠️ Critical for bunx users\nBun 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.\n:::\n\n### Recommended: Shell Alias\n\nSince `npx @ccusage/codex@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias for convenience:\n\n```bash\n# bash/zsh: alias ccusage-codex='bunx @ccusage/codex@latest'\n# fish:     alias ccusage-codex 'bunx @ccusage/codex@latest'\n\n# Then simply run:\nccusage-codex daily\nccusage-codex monthly --json\n```\n\n::: tip\nAfter 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.\n:::\n\n## Data Source\n\nThe 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.\n\n## What Gets Calculated\n\n- **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).\n- **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.\n- **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.\n- **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`).\n- **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.\n- **Totals and reports** – Daily, monthly, and session commands display per-model breakdowns, overall totals, and optional JSON for automation.\n\n## Environment Variables\n\n| Variable     | Description                                                  |\n| ------------ | ------------------------------------------------------------ |\n| `CODEX_HOME` | Override the root directory containing Codex session folders |\n| `LOG_LEVEL`  | Adjust consola verbosity (0 silent … 5 trace)                |\n\nWhen 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.\n\n## Next Steps\n\n- [Daily report command](./daily.md)\n- [Monthly report command](./monthly.md)\n- [Session report command](./session.md)\n- Additional reports will mirror the ccusage CLI as the Codex tooling stabilizes.\n\nHave feedback or ideas? [Open an issue](https://github.com/ryoppippi/ccusage/issues/new) so we can improve the beta.\n\n## Troubleshooting\n\n::: details Why are there no entries before September 2025?\nOpenAI'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.\n:::\n\n::: details What if some September 2025 sessions still get skipped?\nDuring 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.\n:::\n"
  },
  {
    "path": "docs/guide/codex/monthly.md",
    "content": "# Codex Monthly Report (Beta)\n\n![Codex CLI monthly report](/codex-cli.jpeg)\n\nThe `monthly` command mirrors ccusage's monthly report while operating on Codex CLI session logs.\n\n```bash\n# Recommended (fastest)\nbunx @ccusage/codex@latest monthly\n\n# Using npx\nnpx @ccusage/codex@latest monthly\n```\n\n## Options\n\n| Flag                         | Description                                                                 |\n| ---------------------------- | --------------------------------------------------------------------------- |\n| `--since` / `--until`        | Filter to a specific date range (YYYYMMDD or YYYY-MM-DD) before aggregating |\n| `--timezone`                 | Override the timezone used to bucket usage into months                      |\n| `--locale`                   | Adjust month label formatting                                               |\n| `--json`                     | Emit structured JSON instead of a table                                     |\n| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching                        |\n| `--compact`                  | Force compact table layout (same columns as a narrow terminal)              |\n\nThe output uses the same responsive table component as ccusage, including compact mode support, per-model token summaries, and a combined totals row.\n"
  },
  {
    "path": "docs/guide/codex/session.md",
    "content": "# Codex Session Report (Beta)\n\nThe `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.\n\nSessions 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.\n\n```bash\n# Recommended (fastest)\nbunx @ccusage/codex@latest session\n\n# Using npx\nnpx @ccusage/codex@latest session\n```\n\n## Options\n\n| Flag                         | Description                                                              |\n| ---------------------------- | ------------------------------------------------------------------------ |\n| `--since` / `--until`        | Filter sessions by their activity date (YYYYMMDD or YYYY-MM-DD)          |\n| `--timezone`                 | Override the timezone used for date grouping and last-activity display   |\n| `--locale`                   | Adjust locale for table and timestamp formatting                         |\n| `--json`                     | Emit structured JSON (`{ sessions: [], totals: {} }`) instead of a table |\n| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching                     |\n| `--compact`                  | Force compact table layout (same columns as a narrow terminal)           |\n\nJSON 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.\n\nNeed time-based rollups instead? Check out the [daily](./daily.md) and [monthly](./monthly.md) reports for broader aggregates that reuse the same data source.\n"
  },
  {
    "path": "docs/guide/config-files.md",
    "content": "# Configuration Files\n\nccusage 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.\n\n## Quick Start\n\n### 1. Use Schema for IDE Support\n\nAlways include the schema for autocomplete and validation:\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\"\n}\n```\n\n### 2. Set Common Defaults\n\nPut frequently used options in `defaults`:\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"timezone\": \"UTC\",\n\t\t\"locale\": \"en-CA\",\n\t\t\"breakdown\": true\n\t}\n}\n```\n\n### 3. Override for Specific Commands\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"breakdown\": false\n\t},\n\t\"commands\": {\n\t\t\"daily\": {\n\t\t\t\"breakdown\": true // Only daily needs breakdown\n\t\t}\n\t}\n}\n```\n\n### 4. Convert CLI Arguments to Config\n\nIf you find yourself repeating CLI arguments:\n\n```bash\n# Before (repeated CLI arguments)\nccusage daily --breakdown --instances --timezone UTC\nccusage monthly --breakdown --timezone UTC\n```\n\nConvert them to a config file:\n\n```json\n// ccusage.json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"breakdown\": true,\n\t\t\"timezone\": \"UTC\"\n\t},\n\t\"commands\": {\n\t\t\"daily\": {\n\t\t\t\"instances\": true\n\t\t}\n\t}\n}\n```\n\nNow simpler commands:\n\n```bash\nccusage daily\nccusage monthly\n```\n\n## Configuration File Locations\n\nccusage searches for configuration files in these locations (in priority order):\n\n1. **Local project**: `.ccusage/ccusage.json` (higher priority)\n2. **User config**: `~/.claude/ccusage.json` or `~/.config/claude/ccusage.json` (lower priority)\n\nConfiguration files are merged in priority order, with local project settings overriding user settings.\nIf you pass a custom config file using `--config`, it will override both local and user configs.\nNote that configuration files are not required; if none are found, ccusage will use built-in defaults.\nAlso, if you have multiple config files, only the first one found will be used.\n\n## Basic Configuration\n\nCreate a `ccusage.json` file with your preferred defaults:\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"json\": false,\n\t\t\"mode\": \"auto\",\n\t\t\"offline\": false,\n\t\t\"timezone\": \"Asia/Tokyo\",\n\t\t\"locale\": \"ja-JP\",\n\t\t\"breakdown\": true\n\t}\n}\n```\n\n## Configuration Structure\n\n### Schema Support\n\nAdd the `$schema` property to get IntelliSense and validation in your IDE:\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\"\n}\n```\n\nYou can also reference a local schema file after installing ccusage:\n\n```json\n{\n\t\"$schema\": \"./node_modules/ccusage/config-schema.json\"\n}\n```\n\n### Global Defaults\n\nThe `defaults` section sets default values for all commands:\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"since\": \"20250101\",\n\t\t\"until\": \"20250630\",\n\t\t\"json\": false,\n\t\t\"mode\": \"auto\",\n\t\t\"debug\": false,\n\t\t\"debugSamples\": 5,\n\t\t\"order\": \"asc\",\n\t\t\"breakdown\": false,\n\t\t\"offline\": false,\n\t\t\"timezone\": \"UTC\",\n\t\t\"locale\": \"en-CA\",\n\t\t\"jq\": \".data[]\"\n\t}\n}\n```\n\n### Command-Specific Configuration\n\nOverride defaults for specific commands using the `commands` section:\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"mode\": \"auto\",\n\t\t\"offline\": false\n\t},\n\t\"commands\": {\n\t\t\"daily\": {\n\t\t\t\"instances\": true,\n\t\t\t\"breakdown\": true\n\t\t},\n\t\t\"blocks\": {\n\t\t\t\"active\": true,\n\t\t\t\"tokenLimit\": \"500000\"\n\t\t}\n\t}\n}\n```\n\n## Command-Specific Options\n\n### Daily Command\n\n```json\n{\n\t\"commands\": {\n\t\t\"daily\": {\n\t\t\t\"instances\": true,\n\t\t\t\"project\": \"my-project\",\n\t\t\t\"breakdown\": true,\n\t\t\t\"since\": \"20250101\",\n\t\t\t\"until\": \"20250630\"\n\t\t}\n\t}\n}\n```\n\n### Weekly Command\n\n```json\n{\n\t\"commands\": {\n\t\t\"weekly\": {\n\t\t\t\"startOfWeek\": \"monday\",\n\t\t\t\"breakdown\": true,\n\t\t\t\"timezone\": \"Europe/London\"\n\t\t}\n\t}\n}\n```\n\n### Monthly Command\n\n```json\n{\n\t\"commands\": {\n\t\t\"monthly\": {\n\t\t\t\"breakdown\": true,\n\t\t\t\"mode\": \"calculate\",\n\t\t\t\"locale\": \"en-US\"\n\t\t}\n\t}\n}\n```\n\n### Session Command\n\n```json\n{\n\t\"commands\": {\n\t\t\"session\": {\n\t\t\t\"id\": \"abc123-session\",\n\t\t\t\"project\": \"my-project\",\n\t\t\t\"json\": true\n\t\t}\n\t}\n}\n```\n\n### Blocks Command\n\n```json\n{\n\t\"commands\": {\n\t\t\"blocks\": {\n\t\t\t\"active\": true,\n\t\t\t\"recent\": false,\n\t\t\t\"tokenLimit\": \"max\",\n\t\t\t\"sessionLength\": 5,\n\t\t\t\"live\": false,\n\t\t\t\"refreshInterval\": 1\n\t\t}\n\t}\n}\n```\n\n### Statusline\n\n```json\n{\n\t\"commands\": {\n\t\t\"statusline\": {\n\t\t\t\"offline\": true,\n\t\t\t\"cache\": true,\n\t\t\t\"refreshInterval\": 2\n\t\t}\n\t}\n}\n```\n\n## Custom Configuration Files\n\nUse the `--config` option to specify a custom configuration file:\n\n```bash\n# Use a specific configuration file\nccusage daily --config ./my-config.json\n\n# Works with all commands\nccusage blocks --config /path/to/team-config.json\n```\n\nThis is useful for:\n\n- **Team configurations** - Share configuration files across team members\n- **Environment-specific settings** - Different configs for development/production\n- **Project-specific overrides** - Use different settings for different projects\n\n## Configuration Example\n\nFor a complete configuration example, see [`/ccusage.example.json`](/ccusage.example.json) in the repository root, which demonstrates:\n\n- Global defaults configuration\n- Command-specific overrides\n- All available options with proper types\n\n## Configuration Priority\n\nSettings are applied in this priority order (highest to lowest):\n\n1. **Command-line arguments** (e.g., `--json`, `--offline`)\n2. **Custom config file** (specified with `--config /path/to/config.json`)\n3. **Local project config** (`.ccusage/ccusage.json`)\n4. **User config** (`~/.config/claude/ccusage.json`)\n5. **Legacy config** (`~/.claude/ccusage.json`)\n6. **Built-in defaults**\n\nExample:\n\n```json\n// .ccusage/ccusage.json\n{\n\t\"defaults\": {\n\t\t\"mode\": \"calculate\"\n\t}\n}\n```\n\n```bash\n# Config file sets mode to \"calculate\"\nccusage daily  # Uses mode: calculate\n\n# But CLI argument overrides it\nccusage daily --mode display  # Uses mode: display\n```\n\n## Debugging Configuration\n\nUse the `--debug` flag to see configuration loading details:\n\n```bash\n# Debug configuration loading\nccusage daily --debug\n\n# Debug custom config file\nccusage daily --debug --config ./my-config.json\n```\n\nDebug output shows:\n\n- Which config files are checked and found\n- Schema and option details from loaded configs\n- How options are merged from different sources\n- Final values used for each option\n\nExample debug output:\n\n```\n[ccusage] ℹ Debug mode enabled - showing config loading details\n\n[ccusage] ℹ Searching for config files:\n  • Checking: .ccusage/ccusage.json (found ✓)\n  • Checking: ~/.config/claude/ccusage.json (found ✓)\n  • Checking: ~/.claude/ccusage.json (not found)\n\n[ccusage] ℹ Loaded config from: .ccusage/ccusage.json\n  • Schema: https://ccusage.com/config-schema.json\n  • Has defaults: yes (3 options)\n  • Has command configs: yes (daily)\n\n[ccusage] ℹ Merging options for 'daily' command:\n  • From defaults: mode=\"auto\", offline=false\n  • From command config: instances=true\n  • From CLI args: debug=true\n  • Final merged options: {\n      mode: \"auto\" (from defaults),\n      offline: false (from defaults),\n      instances: true (from command config),\n      debug: true (from CLI)\n    }\n```\n\n## Best Practices\n\n### Version Control\n\nFor project configs, commit `.ccusage/ccusage.json` to version control:\n\n```bash\n# Add to git\ngit add .ccusage/ccusage.json\ngit commit -m \"Add ccusage configuration\"\n```\n\n### Document Team Configs\n\nAdd comments using a README alongside team configs:\n\n```\nteam-configs/\n├── ccusage.json\n└── README.md  # Explain configuration choices\n```\n\n## Troubleshooting\n\n### Config Not Being Applied\n\n1. Check file location is correct\n2. Verify JSON syntax is valid\n3. Use `--debug` to see loading details\n4. Ensure option names match exactly\n\n### Invalid JSON\n\nUse a JSON validator or IDE with JSON support:\n\n```bash\n# Validate JSON syntax\njq . < ccusage.json\n```\n\n### Schema Validation Errors\n\nEnsure option values match expected types:\n\n```json\n{\n\t\"defaults\": {\n\t\t\"tokenLimit\": \"500000\", // ✅ String or number\n\t\t\"active\": true, // ✅ Boolean\n\t\t\"refreshInterval\": 2 // ✅ Number\n\t}\n}\n```\n\n## Related Documentation\n\n- [Command-Line Options](/guide/cli-options) - Available CLI arguments\n- [Environment Variables](/guide/environment-variables) - Environment configuration\n- [Configuration Overview](/guide/configuration) - Complete configuration guide\n"
  },
  {
    "path": "docs/guide/configuration.md",
    "content": "# Configuration Overview\n\nccusage 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.\n\n## Configuration Methods\n\nccusage supports four configuration methods, each with its own use case:\n\n1. **[Command-Line Options](/guide/cli-options)** - Direct control for individual commands\n2. **[Environment Variables](/guide/environment-variables)** - System-wide or session settings\n3. **[Configuration Files](/guide/config-files)** - Persistent, shareable settings\n4. **[Directory Detection](/guide/directory-detection)** - Automatic Claude data discovery\n\n## Configuration Priority\n\nSettings are applied in this priority order (highest to lowest):\n\n1. **Command-line arguments** (e.g., `--json`, `--offline`)\n2. **Custom config file** (via `--config` flag)\n3. **Environment variables** (e.g., `CLAUDE_CONFIG_DIR`, `LOG_LEVEL`)\n4. **Local project config** (`.ccusage/ccusage.json`)\n5. **User config** (`~/.config/claude/ccusage.json`)\n6. **Legacy config** (`~/.claude/ccusage.json`)\n7. **Built-in defaults**\n\n### Priority Example\n\n```bash\n# Configuration file sets mode to \"calculate\"\n# .ccusage/ccusage.json\n{\n  \"defaults\": {\n    \"mode\": \"calculate\"\n  }\n}\n\n# Environment variable sets timezone\nexport CCUSAGE_TIMEZONE=\"Asia/Tokyo\"\n\n# Command-line argument takes highest priority\nccusage daily --mode display --timezone UTC\n# Result: mode=display (CLI), timezone=UTC (CLI)\n```\n\n## Quick Start\n\n### Basic Setup\n\n1. **Set your Claude directory** (if not using defaults):\n\n```bash\nexport CLAUDE_CONFIG_DIR=\"$HOME/.config/claude\"\n```\n\n2. **Create a configuration file** for your preferences:\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"timezone\": \"America/New_York\",\n\t\t\"locale\": \"en-US\",\n\t\t\"breakdown\": true\n\t}\n}\n```\n\n3. **Use command-line options** for one-off changes:\n\n```bash\nccusage daily --since 20250101 --json\n```\n\n## Common Configuration Scenarios\n\n### Personal Development\n\nFor individual developers working on multiple projects:\n\n```json\n// ~/.config/claude/ccusage.json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"breakdown\": true,\n\t\t\"timezone\": \"local\"\n\t},\n\t\"commands\": {\n\t\t\"daily\": {\n\t\t\t\"instances\": true\n\t\t}\n\t}\n}\n```\n\n### Team Collaboration\n\nFor teams sharing configuration:\n\n```json\n// .ccusage/ccusage.json (committed to repo)\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\",\n\t\"defaults\": {\n\t\t\"timezone\": \"UTC\",\n\t\t\"locale\": \"en-CA\",\n\t\t\"mode\": \"auto\"\n\t}\n}\n```\n\n### CI/CD Pipeline\n\nFor automated environments:\n\n```bash\n# Environment variables\nexport CLAUDE_CONFIG_DIR=\"/ci/claude-data\"\nexport LOG_LEVEL=1  # Warnings only\n\n# Run with specific options\nccusage daily --offline --json > report.json\n```\n\n### Multiple Claude Installations\n\nFor users with multiple Claude Code versions:\n\n```bash\n# Aggregate from multiple directories\nexport CLAUDE_CONFIG_DIR=\"$HOME/.claude,$HOME/.config/claude\"\nccusage daily\n```\n\n## Configuration by Feature\n\n### Cost Calculation\n\nControl how costs are calculated:\n\n- **Mode**: `auto` (default), `calculate`, or `display`\n- **Offline**: Use cached pricing data\n- **Breakdown**: Show per-model costs\n\n```bash\nccusage daily --mode calculate --breakdown --offline\n```\n\n### Date and Time\n\nCustomize date/time handling:\n\n- **Timezone**: Any valid timezone (e.g., `UTC`, `America/New_York`)\n- **Locale**: Format preferences (e.g., `en-US`, `ja-JP`)\n- **Date Range**: Filter with `--since` and `--until`\n\n```bash\nccusage daily --timezone UTC --locale en-CA --since 20250101\n```\n\n### Output Format\n\nControl output presentation:\n\n- **JSON**: Machine-readable output with `--json`\n- **JQ Filtering**: Process JSON with `--jq`\n- **Debug**: Show detailed information with `--debug`\n\n```bash\nccusage daily --json --jq \".data[] | select(.cost > 10)\"\n```\n\n### Project Analysis\n\nAnalyze usage by project:\n\n- **Instances**: Group by project with `--instances`\n- **Project Filter**: Focus on specific project with `--project`\n- **Aliases**: Set custom names via configuration file\n\n```json\n// .ccusage/ccusage.json\n{\n\t\"commands\": {\n\t\t\"daily\": {\n\t\t\t\"projectAliases\": \"uuid-123=My App,long-name=Backend\"\n\t\t}\n\t}\n}\n```\n\n```bash\nccusage daily --instances --project \"My App\"\n```\n\n## Debugging Configuration\n\nUse debug mode to understand configuration loading:\n\n```bash\n# See which config files are loaded\nccusage daily --debug\n\n# Check environment variables\nenv | grep -E \"CLAUDE|CCUSAGE|LOG_LEVEL\"\n\n# Verbose logging\nLOG_LEVEL=5 ccusage daily\n```\n\n### Debug Output\n\nDebug mode shows:\n\n- Config file discovery and loading\n- Option merging from different sources\n- Final configuration values\n- Pricing calculation details\n\n## Best Practices\n\n### 1. Layer Your Configuration\n\nUse different configuration methods for different scopes:\n\n- **Environment variables**: Machine-specific settings (paths)\n- **User config**: Personal preferences (timezone, locale)\n- **Project config**: Team standards (mode, formatting)\n- **CLI arguments**: One-off overrides\n\n### 2. Use Configuration Files for Teams\n\nShare consistent settings across team members:\n\n```bash\n# Commit to version control\ngit add .ccusage/ccusage.json\ngit commit -m \"Add team ccusage configuration\"\n```\n\n### 3. Document Your Configuration\n\nAdd comments or README files explaining configuration choices:\n\n```markdown\n# ccusage Configuration\n\nOur team uses:\n\n- UTC timezone for consistency\n- JSON output for automated processing\n- Calculate mode for accurate cost tracking\n```\n\n### 4. Validate Configuration\n\nUse the schema for validation:\n\n```json\n{\n\t\"$schema\": \"https://ccusage.com/config-schema.json\"\n}\n```\n\n### 5. Keep Secrets Secure\n\nNever put sensitive information in configuration files:\n\n- ❌ API keys or tokens\n- ❌ Personal identifiers\n- ✅ Timezone preferences\n- ✅ Output formats\n\n## Migration Guide\n\n### From v1 to v2\n\nIf upgrading from older versions:\n\n1. Update directory paths (now supports `~/.config/claude`)\n2. Migrate environment variables to config files\n3. Update any scripts using old CLI options\n\n### From Manual Commands\n\nConvert repeated commands to configuration:\n\n```bash\n# Before: Repeated commands\nccusage daily --breakdown --instances --timezone UTC\n\n# After: Configuration file\n{\n  \"defaults\": {\n    \"breakdown\": true,\n    \"timezone\": \"UTC\"\n  },\n  \"commands\": {\n    \"daily\": {\n      \"instances\": true\n    }\n  }\n}\n\n# Simplified command\nccusage daily\n```\n\n## Troubleshooting\n\n### Common Issues\n\n1. **Configuration not applied**: Check priority order\n2. **Invalid JSON**: Validate syntax with `jq`\n3. **Directory not found**: Verify `CLAUDE_CONFIG_DIR`\n4. **No data**: Check directory permissions\n\n### Getting Help\n\nIf configuration issues persist:\n\n1. Run with debug mode: `ccusage daily --debug`\n2. Check verbose logs: `LOG_LEVEL=5 ccusage daily`\n3. Validate JSON config: `jq . < ccusage.json`\n4. Report issues on [GitHub](https://github.com/ryoppippi/ccusage/issues)\n\n## Next Steps\n\nExplore specific configuration topics:\n\n- [Command-Line Options](/guide/cli-options) - All available CLI arguments\n- [Environment Variables](/guide/environment-variables) - System configuration\n- [Configuration Files](/guide/config-files) - Persistent settings\n- [Directory Detection](/guide/directory-detection) - Claude data discovery\n- [Cost Modes](/guide/cost-modes) - Understanding calculation modes\n- [Custom Paths](/guide/custom-paths) - Advanced path management\n"
  },
  {
    "path": "docs/guide/cost-modes.md",
    "content": "# Cost Modes\n\nccusage 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.\n\n## Overview\n\nClaude 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:\n\n- **`auto`** - Smart mode using the best available data\n- **`calculate`** - Always calculate from token counts\n- **`display`** - Only show pre-calculated costs\n\n## Mode Details\n\n### auto (Default)\n\nThe `auto` mode intelligently chooses the best cost calculation method for each entry:\n\n```bash\nccusage daily --mode auto\n# or simply:\nccusage daily\n```\n\n#### How it works:\n\n1. **Pre-calculated costs available** → Uses Claude's `costUSD` values\n2. **No pre-calculated costs** → Calculates from token counts using model pricing\n3. **Mixed data** → Uses the best method for each entry\n\n#### Best for:\n\n- ✅ **General usage** - Works well for most scenarios\n- ✅ **Mixed data sets** - Handles old and new data properly\n- ✅ **Accuracy** - Uses official costs when available\n- ✅ **Completeness** - Shows estimates for all entries\n\n#### Example output:\n\n```\n┌──────────────┬─────────────┬────────┬─────────┬────────────┐\n│ Date         │ Models      │ Input  │ Output  │ Cost (USD) │\n├──────────────┼─────────────┼────────┼─────────┼────────────┤\n│ 2025-01-15   │ • opus-4    │  1,245 │  28,756 │    $12.45  │ ← Pre-calculated\n│ 2024-12-20   │ • sonnet-4  │    856 │  19,234 │     $8.67  │ ← Calculated\n│ 2024-11-10   │ • opus-4    │    634 │  15,678 │     $7.23  │ ← Calculated\n└──────────────┴─────────────┴────────┴─────────┴────────────┘\n```\n\n### calculate\n\nThe `calculate` mode always computes costs from token counts using model pricing:\n\n```bash\nccusage daily --mode calculate\nccusage monthly --mode calculate --breakdown\n```\n\n#### How it works:\n\n1. **Ignores `costUSD` values** from Claude Code data\n2. **Uses token counts** (input, output, cache) for all entries\n3. **Applies current model pricing** from LiteLLM database\n4. **Consistent methodology** across all time periods\n\n#### Best for:\n\n- ✅ **Consistent comparisons** - Same calculation method for all data\n- ✅ **Token analysis** - Understanding pure token-based costs\n- ✅ **Historical analysis** - Comparing costs across different time periods\n- ✅ **Pricing research** - Analyzing cost per token trends\n\n#### Example output:\n\n```\n┌──────────────┬─────────────┬────────┬─────────┬────────────┐\n│ Date         │ Models      │ Input  │ Output  │ Cost (USD) │\n├──────────────┼─────────────┼────────┼─────────┼────────────┤\n│ 2025-01-15   │ • opus-4    │  1,245 │  28,756 │    $12.38  │ ← Calculated\n│ 2024-12-20   │ • sonnet-4  │    856 │  19,234 │     $8.67  │ ← Calculated\n│ 2024-11-10   │ • opus-4    │    634 │  15,678 │     $7.23  │ ← Calculated\n└──────────────┴─────────────┴────────┴─────────┴────────────┘\n```\n\n### display\n\nThe `display` mode only shows pre-calculated costs from Claude Code:\n\n```bash\nccusage daily --mode display\nccusage session --mode display --json\n```\n\n#### How it works:\n\n1. **Uses only `costUSD` values** from Claude Code data\n2. **Shows $0.00** for entries without pre-calculated costs\n3. **No token-based calculations** performed\n4. **Exact Claude billing data** when available\n\n#### Best for:\n\n- ✅ **Official costs only** - Shows exactly what Claude calculated\n- ✅ **Billing verification** - Comparing with actual Claude charges\n- ✅ **Recent data** - Most accurate for newer usage entries\n- ✅ **Audit purposes** - Verifying pre-calculated costs\n\n#### Example output:\n\n```\n┌──────────────┬─────────────┬────────┬─────────┬────────────┐\n│ Date         │ Models      │ Input  │ Output  │ Cost (USD) │\n├──────────────┼─────────────┼────────┼─────────┼────────────┤\n│ 2025-01-15   │ • opus-4    │  1,245 │  28,756 │    $12.45  │ ← Pre-calculated\n│ 2024-12-20   │ • sonnet-4  │    856 │  19,234 │     $0.00  │ ← No cost data\n│ 2024-11-10   │ • opus-4    │    634 │  15,678 │     $0.00  │ ← No cost data\n└──────────────┴─────────────┴────────┴─────────┴────────────┘\n```\n\n## Practical Examples\n\n### Scenario 1: Mixed Data Analysis\n\nYou have data from different time periods with varying cost information:\n\n```bash\n# Auto mode handles mixed data intelligently\nccusage daily --mode auto --since 20241201\n\n# Shows:\n# - Pre-calculated costs for recent entries (Jan 2025)\n# - Calculated costs for older entries (Dec 2024)\n```\n\n### Scenario 2: Consistent Cost Comparison\n\nYou want to compare costs across different months using the same methodology:\n\n```bash\n# Calculate mode ensures consistent methodology\nccusage monthly --mode calculate --breakdown\n\n# All months use the same token-based calculation\n# Useful for trend analysis and cost projections\n```\n\n### Scenario 3: Billing Verification\n\nYou want to verify Claude's official cost calculations:\n\n```bash\n# Display mode shows only official Claude costs\nccusage daily --mode display --since 20250101\n\n# Compare with your Claude billing dashboard\n# Entries without costs show $0.00\n```\n\n### Scenario 4: Historical Analysis\n\nAnalyzing usage patterns over time:\n\n```bash\n# Auto mode for complete picture\nccusage daily --mode auto --since 20240101 --until 20241231\n\n# Calculate mode for consistent comparison\nccusage monthly --mode calculate --order asc\n```\n\n## Cost Calculation Details\n\n### Token-Based Calculation\n\nWhen calculating costs from tokens, ccusage uses:\n\n#### Model Pricing Sources\n\n- **LiteLLM database** - Up-to-date model pricing\n- **Automatic updates** - Pricing refreshed regularly\n- **Multiple models** - Supports Claude Opus, Sonnet, and other models\n\n#### Token Types\n\n```typescript\ntype TokenCosts = {\n\tinput: number; // Input tokens\n\toutput: number; // Output tokens\n\tcacheCreate: number; // Cache creation tokens\n\tcacheRead: number; // Cache read tokens\n};\n```\n\n#### Calculation Formula\n\n```typescript\ntotalCost =\n\tinputTokens * inputPrice +\n\toutputTokens * outputPrice +\n\tcacheCreateTokens * cacheCreatePrice +\n\tcacheReadTokens * cacheReadPrice;\n```\n\n### Pre-calculated Costs\n\nClaude Code provides `costUSD` values in JSONL files:\n\n```json\n{\n\t\"timestamp\": \"2025-01-15T10:30:00Z\",\n\t\"model\": \"claude-opus-4-20250514\",\n\t\"usage\": {\n\t\t\"input_tokens\": 1245,\n\t\t\"output_tokens\": 28756,\n\t\t\"cache_creation_input_tokens\": 512,\n\t\t\"cache_read_input_tokens\": 256\n\t},\n\t\"costUSD\": 12.45\n}\n```\n\n## Debug Mode\n\nUse debug mode to understand cost calculation discrepancies:\n\n```bash\nccusage daily --mode auto --debug\n```\n\nShows:\n\n- **Pricing mismatches** between calculated and pre-calculated costs\n- **Missing cost data** entries\n- **Calculation details** for each entry\n- **Sample discrepancies** for investigation\n\n```bash\n# Show more sample discrepancies\nccusage daily --debug --debug-samples 10\n```\n\n## Mode Selection Guide\n\n### When to use `auto` mode:\n\n- **General usage** - Default for most scenarios\n- **Mixed data sets** - Combining old and new usage data\n- **Maximum accuracy** - Best available cost information\n- **Regular reporting** - Daily/monthly usage tracking\n\n### When to use `calculate` mode:\n\n- **Consistent analysis** - Comparing different time periods\n- **Token cost research** - Understanding pure token costs\n- **Pricing validation** - Verifying calculated vs actual costs\n- **Historical comparison** - Analyzing cost trends over time\n\n### When to use `display` mode:\n\n- **Billing verification** - Comparing with Claude charges\n- **Official costs only** - Trusting Claude's calculations\n- **Recent data analysis** - Most accurate for new usage\n- **Audit purposes** - Verifying pre-calculated costs\n\n## Advanced Usage\n\n### Combining with Other Options\n\n```bash\n# Calculate mode with breakdown by model\nccusage daily --mode calculate --breakdown\n\n# Display mode with JSON output for analysis\nccusage session --mode display --json | jq '.[] | select(.totalCost > 0)'\n\n# Auto mode with date filtering\nccusage monthly --mode auto --since 20240101 --order asc\n```\n\n### Performance Considerations\n\n- **`display` mode** - Fastest (no calculations)\n- **`auto` mode** - Moderate (conditional calculations)\n- **`calculate` mode** - Slowest (always calculates)\n\n### Offline Mode Compatibility\n\n```bash\n# All modes work with offline pricing data\nccusage daily --mode calculate --offline\nccusage monthly --mode auto --offline\n```\n\n## Common Issues and Solutions\n\n### Issue: Costs showing as $0.00\n\n**Cause**: Using `display` mode with data that lacks pre-calculated costs\n\n**Solution**:\n\n```bash\n# Switch to auto or calculate mode\nccusage daily --mode auto\nccusage daily --mode calculate\n```\n\n### Issue: Inconsistent cost calculations\n\n**Cause**: Mixed use of different modes or pricing changes\n\n**Solution**:\n\n```bash\n# Use calculate mode for consistency\nccusage daily --mode calculate --since 20240101\n```\n\n### Issue: Large discrepancies in debug mode\n\n**Cause**: Pricing updates or model changes\n\n**Solution**:\n\n```bash\n# Check for pricing updates\nccusage daily --mode auto  # Updates pricing cache\nccusage daily --mode calculate --debug  # Compare calculations\n```\n\n### Issue: Missing cost data for recent entries\n\n**Cause**: Claude Code hasn't calculated costs yet\n\n**Solution**:\n\n```bash\n# Use calculate mode as fallback\nccusage daily --mode calculate\n```\n\n## Next Steps\n\nAfter understanding cost modes:\n\n- Explore [Configuration](/guide/configuration) for environment setup\n- Learn about [Custom Paths](/guide/custom-paths) for multiple data sources\n- Try [Live Monitoring](/guide/live-monitoring) with different cost modes\n"
  },
  {
    "path": "docs/guide/custom-paths.md",
    "content": "# Custom Paths\n\nccusage supports flexible path configuration to handle various Claude Code installation scenarios and custom data locations.\n\n## Overview\n\nBy default, ccusage automatically detects Claude Code data in standard locations. However, you can customize these paths for:\n\n- **Multiple Claude installations** - Different versions or profiles\n- **Custom data locations** - Non-standard installation directories\n- **Shared environments** - Team or organization setups\n- **Backup/archive analysis** - Analyzing historical data from different locations\n\n## CLAUDE_CONFIG_DIR Environment Variable\n\nThe primary method for specifying custom paths is the `CLAUDE_CONFIG_DIR` environment variable.\n\n### Single Custom Path\n\nSpecify one custom directory:\n\n```bash\n# Set environment variable\nexport CLAUDE_CONFIG_DIR=\"/path/to/your/claude/data\"\n\n# Use with any command\nccusage daily\nccusage monthly --breakdown\nccusage blocks --live\n```\n\nExample scenarios:\n\n```bash\n# Custom installation location\nexport CLAUDE_CONFIG_DIR=\"/opt/claude-code/.claude\"\n\n# User-specific directory\nexport CLAUDE_CONFIG_DIR=\"/home/username/Documents/claude-data\"\n\n# Network drive\nexport CLAUDE_CONFIG_DIR=\"/mnt/shared/claude-usage\"\n```\n\n### Multiple Custom Paths\n\nSpecify multiple directories separated by commas:\n\n```bash\n# Multiple installations\nexport CLAUDE_CONFIG_DIR=\"/path/to/claude1,/path/to/claude2\"\n\n# Current and archived data\nexport CLAUDE_CONFIG_DIR=\"~/.claude,/backup/claude-archive\"\n\n# Team member data aggregation\nexport CLAUDE_CONFIG_DIR=\"/team/alice/.claude,/team/bob/.claude,/team/charlie/.claude\"\n```\n\nWhen multiple paths are specified:\n\n- ✅ **Data aggregation** - Usage from all paths is automatically combined\n- ✅ **Automatic filtering** - Invalid or empty directories are silently skipped\n- ✅ **Consistent reporting** - All reports show unified data across paths\n\n## Default Path Detection\n\n### Standard Locations\n\nWhen `CLAUDE_CONFIG_DIR` is not set, ccusage searches these locations automatically:\n\n1. **`~/.config/claude/projects/`** - New default (Claude Code v1.0.30+)\n2. **`~/.claude/projects/`** - Legacy location (pre-v1.0.30)\n\n### Version Compatibility\n\n::: info Breaking Change\nClaude Code v1.0.30 moved data from `~/.claude` to `~/.config/claude` without documentation. ccusage handles both locations automatically for seamless compatibility.\n:::\n\n#### Migration Scenarios\n\n**Scenario 1: Fresh Installation**\n\n```bash\n# Claude Code v1.0.30+ - uses new location\nls ~/.config/claude/projects/\n\n# ccusage automatically finds data\nccusage daily\n```\n\n**Scenario 2: Upgraded Installation**\n\n```bash\n# Old data still exists\nls ~/.claude/projects/\n\n# New data in new location\nls ~/.config/claude/projects/\n\n# ccusage combines both automatically\nccusage daily  # Shows data from both locations\n```\n\n**Scenario 3: Manual Migration**\n\n```bash\n# If you moved data manually\nexport CLAUDE_CONFIG_DIR=\"/custom/location/claude\"\nccusage daily\n```\n\n## Path Structure Requirements\n\n### Expected Directory Structure\n\nccusage expects this directory structure:\n\n```\nclaude-data-directory/\n├── projects/\n│   ├── project-1/\n│   │   ├── session-1/\n│   │   │   ├── file1.jsonl\n│   │   │   └── file2.jsonl\n│   │   └── session-2/\n│   │       └── file3.jsonl\n│   └── project-2/\n│       └── session-3/\n│           └── file4.jsonl\n```\n\n### Validation\n\nccusage validates paths by checking:\n\n- **Directory exists** and is readable\n- **Contains `projects/` subdirectory**\n- **Has JSONL files** in the expected structure\n\nInvalid paths are automatically skipped with debug information available.\n\n## Common Use Cases\n\n### Multiple Claude Profiles\n\nIf you use multiple Claude profiles or installations:\n\n```bash\n# Work profile\nexport CLAUDE_CONFIG_DIR=\"/Users/username/.config/claude-work\"\n\n# Personal profile\nexport CLAUDE_CONFIG_DIR=\"/Users/username/.config/claude-personal\"\n\n# Combined analysis\nexport CLAUDE_CONFIG_DIR=\"/Users/username/.config/claude-work,/Users/username/.config/claude-personal\"\n```\n\n### Team Environments\n\nFor team usage analysis:\n\n```bash\n# Individual analysis\nexport CLAUDE_CONFIG_DIR=\"/shared/claude-data/$USER\"\nccusage daily\n\n# Team aggregate\nexport CLAUDE_CONFIG_DIR=\"/shared/claude-data/alice,/shared/claude-data/bob\"\nccusage monthly --breakdown\n```\n\n### Development vs Production\n\nSeparate environments:\n\n```bash\n# Development environment\nexport CLAUDE_CONFIG_DIR=\"/dev/claude-data\"\nccusage daily --since 20250101\n\n# Production environment\nexport CLAUDE_CONFIG_DIR=\"/prod/claude-data\"\nccusage daily --since 20250101\n```\n\n### Historical Analysis\n\nAnalyzing archived or backup data:\n\n```bash\n# Current month\nexport CLAUDE_CONFIG_DIR=\"~/.config/claude\"\nccusage monthly\n\n# Compare with previous month backup\nexport CLAUDE_CONFIG_DIR=\"/backup/claude-2024-12\"\nccusage monthly --since 20241201 --until 20241231\n\n# Combined analysis\nexport CLAUDE_CONFIG_DIR=\"~/.config/claude,/backup/claude-2024-12\"\nccusage monthly --since 20241201\n```\n\n## Shell Integration\n\n### Setting Persistent Environment Variables\n\n#### Bash/Zsh\n\nAdd to `~/.bashrc`, `~/.zshrc`, or `~/.profile`:\n\n```bash\n# Default Claude data directory\nexport CLAUDE_CONFIG_DIR=\"$HOME/.config/claude\"\n\n# Or multiple directories\nexport CLAUDE_CONFIG_DIR=\"$HOME/.config/claude,$HOME/.claude\"\n```\n\n#### Fish Shell\n\nAdd to `~/.config/fish/config.fish`:\n\n```fish\n# Default Claude data directory\nset -gx CLAUDE_CONFIG_DIR \"$HOME/.config/claude\"\n\n# Or multiple directories\nset -gx CLAUDE_CONFIG_DIR \"$HOME/.config/claude,$HOME/.claude\"\n```\n\n### Temporary Path Override\n\nFor one-time analysis without changing environment:\n\n```bash\n# Temporary override for single command\nCLAUDE_CONFIG_DIR=\"/tmp/claude-backup\" ccusage daily\n\n# Multiple commands with temporary override\n(\n  export CLAUDE_CONFIG_DIR=\"/archive/claude-2024\"\n  ccusage daily --json > 2024-report.json\n  ccusage monthly --breakdown > 2024-monthly.txt\n)\n```\n\n### Aliases and Functions\n\nCreate convenient aliases:\n\n```bash\n# ~/.bashrc or ~/.zshrc\nalias ccu-work=\"CLAUDE_CONFIG_DIR='/work/claude' ccusage\"\nalias ccu-personal=\"CLAUDE_CONFIG_DIR='/personal/claude' ccusage\"\nalias ccu-archive=\"CLAUDE_CONFIG_DIR='/archive/claude' ccusage\"\n\n# Usage\nccu-work daily\nccu-personal monthly --breakdown\nccu-archive session --since 20240101\n```\n\nOr use functions for more complex setups:\n\n```bash\n# Function to analyze specific time periods\nccu-period() {\n  local period=$1\n  local path=\"/archive/claude-$period\"\n\n  if [[ -d \"$path\" ]]; then\n    CLAUDE_CONFIG_DIR=\"$path\" ccusage daily --since \"${period}01\" --until \"${period}31\"\n  else\n    echo \"Archive not found: $path\"\n  fi\n}\n\n# Usage\nccu-period 202412  # December 2024\nccu-period 202501  # January 2025\n```\n\n## MCP Integration with Custom Paths\n\nWhen using the standalone MCP CLI with custom paths:\n\n### Claude Desktop Configuration\n\n```json\n{\n\t\"mcpServers\": {\n\t\t\"ccusage\": {\n\t\t\t\"command\": \"npx\",\n\t\t\t\"args\": [\"@ccusage/mcp@latest\"],\n\t\t\t\"env\": {\n\t\t\t\t\"CLAUDE_CONFIG_DIR\": \"/path/to/your/claude/data\"\n\t\t\t}\n\t\t},\n\t\t\"ccusage-archive\": {\n\t\t\t\"command\": \"npx\",\n\t\t\t\"args\": [\"@ccusage/mcp@latest\"],\n\t\t\t\"env\": {\n\t\t\t\t\"CLAUDE_CONFIG_DIR\": \"/archive/claude-2024,/archive/claude-2025\"\n\t\t\t}\n\t\t}\n\t}\n}\n```\n\nThis allows you to have multiple MCP servers analyzing different data sets.\n\n## Troubleshooting Custom Paths\n\n### Path Validation\n\nCheck if your custom path is valid:\n\n```bash\n# Test path manually\nls -la \"$CLAUDE_CONFIG_DIR/projects/\"\n\n# Run with debug output\nccusage daily --debug\n```\n\n### Common Issues\n\n#### Path Not Found\n\n```bash\n# Error: Directory doesn't exist\nexport CLAUDE_CONFIG_DIR=\"/nonexistent/path\"\nccusage daily\n# Result: No data found\n\n# Solution: Verify path exists\nls -la /nonexistent/path\n```\n\n#### Permission Issues\n\n```bash\n# Error: Permission denied\nexport CLAUDE_CONFIG_DIR=\"/root/.claude\"\nccusage daily  # May fail if no read permission\n\n# Solution: Check permissions\nls -la /root/.claude\n```\n\n#### Multiple Paths Syntax\n\n```bash\n# Wrong: Using semicolon or space\nexport CLAUDE_CONFIG_DIR=\"/path1;/path2\"  # ❌\nexport CLAUDE_CONFIG_DIR=\"/path1 /path2\"  # ❌\n\n# Correct: Using comma\nexport CLAUDE_CONFIG_DIR=\"/path1,/path2\"  # ✅\n```\n\n#### Data Structure Issues\n\n```bash\n# Wrong structure\n/custom/claude/\n├── file1.jsonl  # ❌ Files in wrong location\n└── data/\n    └── file2.jsonl\n\n# Correct structure\n/custom/claude/\n└── projects/\n    └── project1/\n        └── session1/\n            └── file1.jsonl\n```\n\n### Debug Mode\n\nUse debug mode to troubleshoot path issues:\n\n```bash\nccusage daily --debug\n\n# Shows:\n# - Which paths are being searched\n# - Which paths are valid/invalid\n# - How many files are found in each path\n# - Any permission or structure issues\n```\n\n## Performance Considerations\n\n### Large Data Sets\n\nWhen using multiple paths with large data sets:\n\n```bash\n# Filter by date to improve performance\nccusage daily --since 20250101 --until 20250131\n\n# Use JSON output for programmatic processing\nccusage daily --json | jq '.[] | select(.totalCost > 10)'\n```\n\n### Network Paths\n\nFor network-mounted directories:\n\n```bash\n# Ensure network path is mounted\nmount | grep claude-data\n\n# Consider local caching for frequently accessed data\nrsync -av /network/claude-data/ /local/cache/claude-data/\nexport CLAUDE_CONFIG_DIR=\"/local/cache/claude-data\"\n```\n\n## Next Steps\n\nAfter setting up custom paths:\n\n- Learn about [Configuration](/guide/configuration) for additional options\n- Explore [Cost Modes](/guide/cost-modes) for different calculation methods\n- Set up [Live Monitoring](/guide/live-monitoring) with your custom data\n"
  },
  {
    "path": "docs/guide/daily-reports.md",
    "content": "# Daily Reports\n\n![Daily usage report showing token usage and costs by date with model breakdown](/screenshot.png)\n\nDaily reports show token usage and costs aggregated by calendar date, giving you a clear view of your Claude Code usage patterns over time.\n\n## Basic Usage\n\nShow all daily usage:\n\n```bash\nccusage daily\n# or simply:\nccusage\n```\n\nThe daily command is the default, so you can omit it when running ccusage.\n\n## Example Output\n\n![Daily usage report showing token usage and costs by date with model breakdown](/screenshot.png)\n\n## Understanding the Columns\n\n### Basic Columns\n\n- **Date**: Calendar date in YYYY-MM-DD format\n- **Models**: Claude models used that day (shown as bulleted list)\n- **Input**: Total input tokens sent to Claude\n- **Output**: Total output tokens received from Claude\n- **Cost (USD)**: Estimated cost for that day\n\n### Cache Columns\n\n- **Cache Create**: Tokens used to create cache entries\n- **Cache Read**: Tokens read from cache (typically cheaper)\n\n### Responsive Display\n\nccusage automatically adapts to your terminal width:\n\n- **Wide terminals (≥100 chars)**: Shows all columns\n- **Narrow terminals (<100 chars)**: Compact mode with essential columns only\n\n## Command Options\n\n### Date Filtering\n\nFilter reports by date range:\n\n```bash\n# Show usage from December 2024\nccusage daily --since 20241201 --until 20241231\n\n# Show last week\nccusage daily --since 20241215 --until 20241222\n\n# Show usage since a specific date\nccusage daily --since 20241201\n```\n\n### Sort Order\n\nControl the order of dates:\n\n```bash\n# Newest dates first (default)\nccusage daily --order desc\n\n# Oldest dates first\nccusage daily --order asc\n```\n\n### Cost Calculation Modes\n\nControl how costs are calculated:\n\n```bash\n# Use pre-calculated costs when available (default)\nccusage daily --mode auto\n\n# Always calculate costs from tokens\nccusage daily --mode calculate\n\n# Only show pre-calculated costs\nccusage daily --mode display\n```\n\n### Model Breakdown\n\nSee per-model cost breakdown:\n\n```bash\nccusage daily --breakdown\n```\n\nThis shows costs split by individual models:\n\n```\n┌──────────────┬──────────────────┬────────┬─────────┬────────────┐\n│ Date         │ Models           │ Input  │ Output  │ Cost (USD) │\n├──────────────┼──────────────────┼────────┼─────────┼────────────┤\n│ 2025-06-21   │ opus-4, sonnet-4 │    277 │  31,456 │     $17.58 │\n├──────────────┼──────────────────┼────────┼─────────┼────────────┤\n│   └─ opus-4  │                  │    100 │  15,000 │     $10.25 │\n├──────────────┼──────────────────┼────────┼─────────┼────────────┤\n│   └─ sonnet-4│                  │    177 │  16,456 │      $7.33 │\n└──────────────┴──────────────────┴────────┴─────────┴────────────┘\n```\n\n### JSON Output\n\nExport data as JSON for further analysis:\n\n```bash\nccusage daily --json\n```\n\n```json\n{\n\t\"type\": \"daily\",\n\t\"data\": [\n\t\t{\n\t\t\t\"date\": \"2025-06-21\",\n\t\t\t\"models\": [\"claude-opus-4-20250514\", \"claude-sonnet-4-20250514\"],\n\t\t\t\"inputTokens\": 277,\n\t\t\t\"outputTokens\": 31456,\n\t\t\t\"cacheCreationTokens\": 512,\n\t\t\t\"cacheReadTokens\": 1024,\n\t\t\t\"totalTokens\": 33269,\n\t\t\t\"costUSD\": 17.58\n\t\t}\n\t],\n\t\"summary\": {\n\t\t\"totalInputTokens\": 277,\n\t\t\"totalOutputTokens\": 31456,\n\t\t\"totalCacheCreationTokens\": 512,\n\t\t\"totalCacheReadTokens\": 1024,\n\t\t\"totalTokens\": 33269,\n\t\t\"totalCostUSD\": 17.58\n\t}\n}\n```\n\n### Offline Mode\n\nUse cached pricing data without network access:\n\n```bash\nccusage daily --offline\n# or short form:\nccusage daily -O\n```\n\n### Project Analysis\n\nGroup usage by project instead of aggregating across all projects:\n\n```bash\n# Group daily usage by project\nccusage daily --instances\nccusage daily -i\n```\n\nWhen using `--instances`, the report shows usage for each project separately:\n\n```\n┌──────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐\n│ Project: my-project                                                                                     │\n├──────────────┬──────────────────┬────────┬─────────┬────────────┬────────────┬─────────────┬──────────┤\n│ Date         │ Models           │ Input  │ Output  │ Cache Create│ Cache Read │ Total Tokens│ Cost (USD)│\n├──────────────┼──────────────────┼────────┼─────────┼────────────┼────────────┼─────────────┼──────────┤\n│ 2025-06-21   │ • sonnet-4       │    277 │  31,456 │         512│      1,024 │      33,269 │     $7.33│\n└──────────────┴──────────────────┴────────┴─────────┴────────────┴────────────┴─────────────┴──────────┘\n\n┌──────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐\n│ Project: other-project                                                                                  │\n├──────────────┬──────────────────┬────────┬─────────┬────────────┬────────────┬─────────────┬──────────┤\n│ Date         │ Models           │ Input  │ Output  │ Cache Create│ Cache Read │ Total Tokens│ Cost (USD)│\n├──────────────┼──────────────────┼────────┼─────────┼────────────┼────────────┼─────────────┼──────────┤\n│ 2025-06-21   │ • opus-4         │    100 │  15,000 │         256│        512 │      15,868 │    $10.25│\n└──────────────┴──────────────────┴────────┴─────────┴────────────┴────────────┴─────────────┴──────────┘\n```\n\nFilter to a specific project:\n\n```bash\n# Show only usage from \"my-project\"\nccusage daily --project my-project\nccusage daily -p my-project\n\n# Combine with instances flag\nccusage daily --instances --project my-project\n```\n\n## Common Use Cases\n\n### Track Monthly Spending\n\n```bash\n# See December 2024 usage\nccusage daily --since 20241201 --until 20241231\n```\n\n### Find Expensive Days\n\n```bash\n# Sort by cost (highest first)\nccusage daily --order desc\n```\n\n### Export for Spreadsheet Analysis\n\n```bash\nccusage daily --json > december-usage.json\n```\n\n### Compare Model Usage\n\n```bash\n# See which models you use most\nccusage daily --breakdown\n```\n\n### Check Recent Activity\n\n```bash\n# Last 7 days\nccusage daily --since $(date -d '7 days ago' +%Y%m%d)\n```\n\n### Analyze Project Usage\n\n```bash\n# See usage breakdown by project\nccusage daily --instances\n\n# Track specific project costs\nccusage daily --project my-important-project --since 20250601\n\n# Compare project usage with JSON export\nccusage daily --instances --json > project-analysis.json\n```\n\n### Team Usage Analysis\n\nUse project aliases to replace cryptic or long project directory names with readable labels:\n\n```json\n// .ccusage/ccusage.json - Set custom project names for better reporting\n{\n\t\"commands\": {\n\t\t\"daily\": {\n\t\t\t\"projectAliases\": \"uuid-project=Frontend App,long-name=Backend API\"\n\t\t}\n\t}\n}\n```\n\nThe `projectAliases` setting uses a comma-separated format of `original-name=display-name` pairs. This is especially useful when:\n\n- Your projects have UUID-based names (e.g., `a2cd99ed-a586=My App`)\n- Directory names are long paths that get truncated\n- You want consistent naming across team reports\n\n```bash\n# Generate team report with readable project names\nccusage daily --instances --since 20250601\n# Now shows \"Frontend App\" instead of \"uuid-project\"\n```\n\n## Tips\n\n1. **Compact Mode**: If your terminal is narrow, expand it to see all columns\n2. **Date Format**: Use YYYYMMDD format for date filters (e.g., 20241225)\n3. **Regular Monitoring**: Run daily reports regularly to track usage patterns\n4. **JSON Export**: Use `--json` for creating charts or additional analysis\n\n## Related Commands\n\n- [Monthly Reports](/guide/monthly-reports) - Aggregate by month\n- [Session Reports](/guide/session-reports) - Per-conversation analysis\n- [Blocks Reports](/guide/blocks-reports) - 5-hour billing windows\n- [Live Monitoring](/guide/live-monitoring) - Real-time tracking\n"
  },
  {
    "path": "docs/guide/directory-detection.md",
    "content": "# Directory Detection\n\nccusage automatically detects and manages Claude Code data directories.\n\n## Default Directory Locations\n\nccusage automatically searches for Claude Code data in these locations:\n\n- **`~/.config/claude/projects/`** - New default location (Claude Code v1.0.30+)\n- **`~/.claude/projects/`** - Legacy location (pre-v1.0.30)\n\nWhen no custom directory is specified, ccusage searches both locations and aggregates data from all valid directories found.\n\n::: info Breaking Change\nThe 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.\n:::\n\n## Search Priority\n\nWhen `CLAUDE_CONFIG_DIR` environment variable is not set, ccusage searches in this order:\n\n1. **Primary**: `~/.config/claude/projects/` (preferred for newer installations)\n2. **Fallback**: `~/.claude/projects/` (for legacy installations)\n\nData from all valid directories is automatically combined.\n\n## Custom Directory Configuration\n\n### Single Custom Directory\n\nOverride the default search with a specific directory:\n\n```bash\nexport CLAUDE_CONFIG_DIR=\"/custom/path/to/claude\"\nccusage daily\n```\n\n### Multiple Directories\n\nAggregate data from multiple Claude installations:\n\n```bash\nexport CLAUDE_CONFIG_DIR=\"/path/to/claude1,/path/to/claude2\"\nccusage daily\n```\n\n## Directory Structure\n\nClaude Code stores usage data in a specific structure:\n\n```\n~/.config/claude/projects/\n├── project-name-1/\n│   ├── session-id-1.jsonl\n│   ├── session-id-2.jsonl\n│   └── session-id-3.jsonl\n├── project-name-2/\n│   └── session-id-4.jsonl\n└── project-name-3/\n    └── session-id-5.jsonl\n```\n\nEach:\n\n- **Project directory** represents a different Claude Code project/workspace\n- **JSONL file** contains usage data for a specific session\n- **Session ID** in the filename matches the `sessionId` field within the file\n\n## Troubleshooting\n\n### No Data Found\n\nIf ccusage reports no data found:\n\n```bash\n# Check if directories exist\nls -la ~/.claude/projects/\nls -la ~/.config/claude/projects/\n\n# Verify environment variable\necho $CLAUDE_CONFIG_DIR\n\n# Test with explicit directory\nexport CLAUDE_CONFIG_DIR=\"/path/to/claude\"\nccusage daily\n```\n\n### Permission Errors\n\n```bash\n# Check directory permissions\nls -la ~/.claude/\nls -la ~/.config/claude/\n\n# Fix permissions if needed\nchmod -R 755 ~/.claude/\nchmod -R 755 ~/.config/claude/\n```\n\n### Wrong Directory Detection\n\n```bash\n# Force specific directory\nexport CLAUDE_CONFIG_DIR=\"/exact/path/to/claude\"\nccusage daily\n\n# Verify which directory is being used\nLOG_LEVEL=4 ccusage daily\n```\n\n## Related Documentation\n\n- [Environment Variables](/guide/environment-variables) - Configure with CLAUDE_CONFIG_DIR\n- [Custom Paths](/guide/custom-paths) - Advanced path management\n- [Configuration Overview](/guide/configuration) - Complete configuration guide\n"
  },
  {
    "path": "docs/guide/environment-variables.md",
    "content": "# Environment Variables\n\nccusage supports several environment variables for configuration and customization. Environment variables provide a way to configure ccusage without modifying command-line arguments or configuration files.\n\n## CLAUDE_CONFIG_DIR\n\nSpecifies where ccusage should look for Claude Code data. This is the most important environment variable for ccusage.\n\n### Single Directory\n\nSet a single custom Claude data directory:\n\n```bash\nexport CLAUDE_CONFIG_DIR=\"/path/to/your/claude/data\"\nccusage daily\n```\n\n### Multiple Directories\n\nSet multiple directories (comma-separated) to aggregate data from multiple sources:\n\n```bash\nexport CLAUDE_CONFIG_DIR=\"/path/to/claude1,/path/to/claude2\"\nccusage daily\n```\n\nWhen multiple directories are specified, ccusage automatically aggregates usage data from all valid locations.\n\n### Default Behavior\n\nWhen `CLAUDE_CONFIG_DIR` is not set, ccusage automatically searches in:\n\n1. `~/.config/claude/projects/` (new default, Claude Code v1.0.30+)\n2. `~/.claude/projects/` (legacy location, pre-v1.0.30)\n\nData from all valid directories is automatically combined.\n\n::: info Directory Change\nThe 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.\n:::\n\n### Use Cases\n\n#### Development Environment\n\n```bash\n# Set in your shell profile (.bashrc, .zshrc, config.fish)\nexport CLAUDE_CONFIG_DIR=\"$HOME/.config/claude\"\n```\n\n#### Multiple Claude Installations\n\n```bash\n# Aggregate data from different Claude installations\nexport CLAUDE_CONFIG_DIR=\"$HOME/.claude,$HOME/.config/claude\"\n```\n\n#### Team Shared Directory\n\n```bash\n# Use team-shared data directory\nexport CLAUDE_CONFIG_DIR=\"/team-shared/claude-data/$USER\"\n```\n\n#### CI/CD Environment\n\n```bash\n# Use specific directory in CI pipeline\nexport CLAUDE_CONFIG_DIR=\"/ci-data/claude-logs\"\nccusage daily --json > usage-report.json\n```\n\n## LOG_LEVEL\n\nControls the verbosity of log output. ccusage uses [consola](https://github.com/unjs/consola) for logging under the hood.\n\n### Log Levels\n\n| Level  | Value | Description                  | Use Case               |\n| ------ | ----- | ---------------------------- | ---------------------- |\n| Silent | `0`   | Errors only                  | Scripts, piping output |\n| Warn   | `1`   | Warnings and errors          | CI/CD environments     |\n| Log    | `2`   | Normal logs                  | General use            |\n| Info   | `3`   | Informational logs (default) | Standard operation     |\n| Debug  | `4`   | Debug information            | Troubleshooting        |\n| Trace  | `5`   | All operations               | Deep debugging         |\n\n### Usage Examples\n\n```bash\n# Silent mode - only show results\nLOG_LEVEL=0 ccusage daily\n\n# Warning level - for CI/CD\nLOG_LEVEL=1 ccusage monthly\n\n# Debug mode - troubleshooting\nLOG_LEVEL=4 ccusage session\n\n# Trace everything - deep debugging\nLOG_LEVEL=5 ccusage blocks\n```\n\n### Practical Applications\n\n#### Clean Output for Scripts\n\n```bash\n# Get clean JSON output without logs\nLOG_LEVEL=0 ccusage daily --json | jq '.summary.totalCost'\n```\n\n#### CI/CD Pipeline\n\n```bash\n# Show only warnings and errors in CI\nLOG_LEVEL=1 ccusage daily --instances\n```\n\n#### Debugging Issues\n\n```bash\n# Maximum verbosity for troubleshooting\nLOG_LEVEL=5 ccusage daily --debug\n```\n\n#### Piping Output\n\n```bash\n# Silent logs when piping to other commands\nLOG_LEVEL=0 ccusage monthly --json | python analyze.py\n```\n\n## Additional Environment Variables\n\n### CCUSAGE_OFFLINE\n\nForce offline mode by default:\n\n```bash\nexport CCUSAGE_OFFLINE=1\nccusage daily  # Runs in offline mode\n```\n\n### NO_COLOR\n\nDisable colored output (standard CLI convention):\n\n```bash\nexport NO_COLOR=1\nccusage daily  # No color formatting\n```\n\n### FORCE_COLOR\n\nForce colored output even when piping:\n\n```bash\nexport FORCE_COLOR=1\nccusage daily | less -R  # Preserves colors\n```\n\n## Setting Environment Variables\n\n### Temporary (Current Session)\n\n```bash\n# Set for single command\nLOG_LEVEL=0 ccusage daily\n\n# Set for current shell session\nexport CLAUDE_CONFIG_DIR=\"/custom/path\"\nccusage daily\n```\n\n### Permanent (Shell Profile)\n\nAdd to your shell configuration file:\n\n#### Bash (~/.bashrc)\n\n```bash\nexport CLAUDE_CONFIG_DIR=\"$HOME/.config/claude\"\nexport LOG_LEVEL=3\n```\n\n#### Zsh (~/.zshrc)\n\n```zsh\nexport CLAUDE_CONFIG_DIR=\"$HOME/.config/claude\"\nexport LOG_LEVEL=3\n```\n\n#### Fish (~/.config/fish/config.fish)\n\n```fish\nset -x CLAUDE_CONFIG_DIR \"$HOME/.config/claude\"\nset -x LOG_LEVEL 3\n```\n\n#### PowerShell (Profile.ps1)\n\n```powershell\n$env:CLAUDE_CONFIG_DIR = \"$env:USERPROFILE\\.config\\claude\"\n$env:LOG_LEVEL = \"3\"\n```\n\n## Precedence\n\nEnvironment variables have lower precedence than command-line arguments but higher than configuration files:\n\n1. **Command-line arguments** (highest priority)\n2. **Environment variables**\n3. **Configuration files**\n4. **Built-in defaults** (lowest priority)\n\nExample:\n\n```bash\n# Environment variable sets offline mode\nexport CCUSAGE_OFFLINE=1\n\n# But command-line argument overrides it\nccusage daily --no-offline  # Runs in online mode\n```\n\n## Debugging\n\nTo see which environment variables are being used:\n\n```bash\n# Show all environment variables\nenv | grep -E \"CLAUDE|CCUSAGE|LOG_LEVEL\"\n\n# Debug mode shows environment variable usage\nLOG_LEVEL=4 ccusage daily --debug\n```\n\n## Related Documentation\n\n- [Command-Line Options](/guide/cli-options) - CLI arguments and flags\n- [Configuration Files](/guide/config-files) - JSON configuration files\n- [Configuration Overview](/guide/configuration) - Complete configuration guide\n"
  },
  {
    "path": "docs/guide/getting-started.md",
    "content": "# Getting Started\n\nWelcome to ccusage! This guide will help you get up and running with analyzing your Claude Code usage data.\n\n## Prerequisites\n\n- Claude Code installed and used (generates JSONL files)\n- Node.js 20+ or Bun runtime\n\n## Quick Start\n\nThe fastest way to try ccusage is to run it directly without installation:\n\n::: code-group\n\n```bash [npx]\nnpx ccusage@latest\n```\n\n```bash [bunx]\nbunx ccusage\n```\n\n```bash [pnpm]\npnpm dlx ccusage\n```\n\n```bash [claude x]\nBUN_BE_BUN=1 claude x ccusage\n```\n\n:::\n\nThis will show your daily usage report by default.\n\n## Your First Report\n\nWhen you run ccusage for the first time, you'll see a table showing your Claude Code usage by date:\n\n```\n╭──────────────────────────────────────────╮\n│                                          │\n│  Claude Code Token Usage Report - Daily  │\n│                                          │\n╰──────────────────────────────────────────╯\n\n┌──────────────┬──────────────────┬────────┬─────────┬────────────┐\n│ Date         │ Models           │  Input │  Output │ Cost (USD) │\n├──────────────┼──────────────────┼────────┼─────────┼────────────┤\n│ 2025-06-21   │ • sonnet-4       │  1,234 │  15,678 │    $12.34  │\n│ 2025-06-20   │ • opus-4         │    890 │  12,345 │    $18.92  │\n└──────────────┴──────────────────┴────────┴─────────┴────────────┘\n```\n\n## Understanding the Output\n\n### Columns Explained\n\n- **Date**: The date when Claude Code was used\n- **Models**: Which Claude models were used (Sonnet, Opus, etc.)\n- **Input**: Number of input tokens sent to Claude\n- **Output**: Number of output tokens received from Claude\n- **Cost (USD)**: Estimated cost based on model pricing\n\n### Cache Tokens\n\nIf you have a wide terminal, you'll also see cache token columns:\n\n- **Cache Create**: Tokens used to create cache entries\n- **Cache Read**: Tokens read from cache (typically cheaper)\n\n## Next Steps\n\nNow that you have your first report, explore these features:\n\n1. **[Weekly Reports](/guide/weekly-reports)** - Track usage patterns by week\n2. **[Monthly Reports](/guide/monthly-reports)** - See usage aggregated by month\n3. **[Session Reports](/guide/session-reports)** - Analyze individual conversations\n4. **[Statusline](/guide/statusline)** - Real-time usage display for Claude Code status bar\n5. **[Configuration](/guide/configuration)** - Customize ccusage behavior\n\n## Common Use Cases\n\n### Monitor Daily Usage\n\n```bash\nccusage daily --since 20241201 --until 20241231\n```\n\n### Analyze Sessions\n\n```bash\nccusage session\n```\n\n### Export for Analysis\n\n```bash\nccusage monthly --json > usage-data.json\n```\n\n### Real-time Status Display\n\nAdd statusline to your Claude Code settings:\n\n```bash\n# Using jq to add statusline configuration\njq '.statusLine = {\"type\": \"command\", \"command\": \"bun x ccusage statusline\", \"padding\": 0}' \\\n  ~/.config/claude/settings.json > tmp.json && mv tmp.json ~/.config/claude/settings.json\n```\n\n## Colors\n\nccusage 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.\n\n## Automatic Table Adjustment\n\nccusage automatically adjusts its table layout based on terminal width:\n\n- **Wide terminals (≥100 characters)**: Full table with all columns including cache metrics, model names, and detailed breakdowns\n- **Narrow terminals (<100 characters)**: Compact view with essential columns only (Date, Models, Input, Output, Cost)\n\nThe 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.\n\n## Troubleshooting\n\n### No Data Found\n\nIf ccusage shows no data, check:\n\n1. **Claude Code is installed and used** - ccusage reads from Claude Code's data files\n2. **Data directory exists** - Default locations:\n   - `~/.config/claude/projects/` (new default)\n   - `~/.claude/projects/` (legacy)\n\n### Custom Data Directory\n\nIf your Claude data is in a custom location:\n\n```bash\nexport CLAUDE_CONFIG_DIR=\"/path/to/your/claude/data\"\nccusage daily\n```\n\n## Getting Help\n\n- Use `ccusage --help` for command options\n- Visit our [GitHub repository](https://github.com/ryoppippi/ccusage) for issues\n- Check the [API Reference](/api/) for programmatic usage\n"
  },
  {
    "path": "docs/guide/index.md",
    "content": "# Introduction\n\n![ccusage daily report showing token usage and costs by date](/screenshot.png)\n\n**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.\n\n## The Problem\n\nClaude Code's Max plan offers unlimited usage, which is fantastic! But many users are curious:\n\n- How much am I actually using Claude Code?\n- Which conversations are the most expensive?\n- What would I be paying on a pay-per-use plan?\n- Am I getting good value from my subscription?\n\n## The Solution\n\nccusage analyzes the local JSONL files that Claude Code automatically generates and provides:\n\n- **Detailed Usage Reports** - Daily, weekly, monthly, and session-based breakdowns\n- **Cost Analysis** - Estimated costs based on token usage and model pricing\n- **Statusline Integration** - Real-time usage display for Claude Code status bar\n- **Multiple Formats** - Beautiful tables or JSON for further analysis\n\n## How It Works\n\n1. **Claude Code generates JSONL files** containing usage data\n2. **ccusage reads these files** from your local machine\n3. **Analyzes and aggregates** the data by date, session, or time blocks\n4. **Calculates estimated costs** using model pricing information\n5. **Presents results** in beautiful tables or JSON format\n\n## Key Features\n\n### 🚀 Ultra-Small Bundle Size\n\nUnlike 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.\n\n### 📊 Multiple Report Types\n\n- **Daily Reports** - Usage aggregated by calendar date\n- **Weekly Reports** - Usage aggregated by week with configurable start day\n- **Monthly Reports** - Monthly summaries with trends\n- **Session Reports** - Per-conversation analysis\n- **Blocks Reports** - 5-hour billing window tracking\n\n### 💰 Cost Analysis\n\n- Estimated costs based on token counts and model pricing\n- Support for different cost calculation modes\n- Model-specific pricing (Opus vs Sonnet vs other models)\n- Cache token cost calculation\n\n### 📈 Statusline Integration\n\n- Compact real-time usage display for Claude Code status bar hooks\n- Session cost, daily cost, and block cost tracking\n- Burn rate calculations with visual indicators\n- Context usage percentage with color-coded alerts\n\n### 🔧 Flexible Configuration\n\n- **JSON Configuration Files** - Set defaults for all commands or customize per-command\n- **IDE Support** - JSON Schema for autocomplete and validation\n- **Priority-based Settings** - CLI args > local config > user config > defaults\n- **Multiple Claude Data Directories** - Automatic detection and aggregation\n- **Environment Variables** - Traditional configuration options\n- **Custom Date Filtering** - Flexible time range selection and sorting\n- **Offline Mode** - Cached pricing data for air-gapped environments\n\n## Data Sources\n\nccusage reads from Claude Code's local data directories:\n\n- **New location**: `~/.config/claude/projects/` (Claude Code v1.0.30+)\n- **Legacy location**: `~/.claude/projects/` (pre-v1.0.30)\n\nThe tool automatically detects and aggregates data from both locations for compatibility.\n\n## Privacy & Security\n\n- **100% Local** - All analysis happens on your machine\n- **No Data Transmission** - Your usage data never leaves your computer\n- **Read-Only** - ccusage only reads files, never modifies them\n- **Open Source** - Full transparency in how your data is processed\n\n## Limitations\n\n::: warning Important Limitations\n\n- **Local Files Only** - Only analyzes data from your current machine\n- **Language Model Tokens** - API calls for tools like Web Search are not included\n- **Estimate Accuracy** - Costs are estimates and may not reflect actual billing\n  :::\n\n## Acknowledgments\n\nThanks to [@milliondev](https://note.com/milliondev) for the [original concept and approach](https://note.com/milliondev/n/n1d018da2d769) to Claude Code usage analysis.\n\n## Getting Started\n\nReady to analyze your Claude Code usage? Check out our [Getting Started Guide](/guide/getting-started) to begin exploring your data!\n"
  },
  {
    "path": "docs/guide/installation.md",
    "content": "# Installation\n\nccusage can be installed and used in several ways depending on your preferences and use case.\n\n## Why No Installation Needed?\n\nThanks 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:\n\n- ✅ Near-instant startup times\n- ✅ Minimal download overhead\n- ✅ Always use the latest version\n- ✅ No global pollution of your system\n\n## Quick Start (Recommended)\n\nThe fastest way to use ccusage is to run it directly:\n\n::: code-group\n\n```bash [bunx (Recommended)]\nbunx ccusage\n```\n\n```bash [npx]\nnpx ccusage@latest\n```\n\n```bash [pnpm]\npnpm dlx ccusage\n```\n\n```bash [deno]\ndeno run -E -R=$HOME/.claude/projects/ -S=homedir -N='raw.githubusercontent.com:443' npm:ccusage@latest\n```\n\n:::\n\n::: tip Speed Recommendation\nWe 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.\n:::\n\n::: info Deno Security\nConsider 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.\n:::\n\n### Performance Comparison\n\nHere's why runtime choice matters:\n\n| Runtime  | First Run | Subsequent Runs | Notes               |\n| -------- | --------- | --------------- | ------------------- |\n| bunx     | Fast      | **Instant**     | Best overall choice |\n| npx      | Slow      | Moderate        | Widely available    |\n| pnpm dlx | Fast      | Fast            | Good alternative    |\n| deno     | Moderate  | Fast            | Best for security   |\n\n## Global Installation (Optional)\n\nWhile not necessary due to our small bundle size, you can still install ccusage globally if you prefer:\n\n::: code-group\n\n```bash [npm]\nnpm install -g ccusage\n```\n\n```bash [bun]\nbun install -g ccusage\n```\n\n```bash [yarn]\nyarn global add ccusage\n```\n\n```bash [pnpm]\npnpm add -g ccusage\n```\n\n:::\n\nAfter global installation, run commands directly:\n\n```bash\nccusage daily\nccusage monthly --breakdown\nccusage blocks --live\n```\n\n## Development Installation\n\nFor development or contributing to ccusage:\n\n```bash\n# Clone the repository\ngit clone https://github.com/ryoppippi/ccusage.git\ncd ccusage\n\n# Install dependencies\nbun install\n\n# Run directly from source\nbun run start daily\nbun run start monthly --json\n```\n\n### Development Scripts\n\n```bash\n# Run tests\nbun run test\n\n# Type checking\nbun typecheck\n\n# Build distribution\nbun run build\n\n# Lint and format\nbun run format\n```\n\n## Runtime Requirements\n\n### Node.js\n\n- **Minimum**: Node.js 20.x\n- **Recommended**: Node.js 20.x or later\n- **LTS versions** are fully supported\n\n### Bun (Alternative)\n\n- **Minimum**: Bun 1.2+\n- **Recommended**: Latest stable release\n- Often faster than Node.js for ccusage\n\n### Deno\n\nDeno 2.0+ is fully supported with proper permissions:\n\n```bash\ndeno run \\\n  -E \\\n  -R=$HOME/.claude/projects/ \\\n  -S=homedir \\\n  -N='raw.githubusercontent.com:443' \\\n  npm:ccusage@latest\n```\n\nAlso you can use `offline` mode to run ccusage without network access:\n\n```bash\ndeno run \\\n  -E \\\n  -R=$HOME/.claude/projects/ \\\n  -S=homedir \\\n  npm:ccusage@latest --offline\n```\n\n## Verification\n\nAfter installation, verify ccusage is working:\n\n```bash\n# Check version\nccusage --version\n\n# Run help command\nccusage --help\n\n# Test with daily report\nccusage daily\n```\n\n## Updating\n\n### Direct Execution (npx/bunx)\n\nAlways gets the latest version automatically.\n\n### Global Installation\n\n```bash\n# Update with npm\nnpm update -g ccusage\n\n# Update with bun\nbun update -g ccusage\n```\n\n### Check Current Version\n\n```bash\nccusage --version\n```\n\n## Uninstalling\n\n### Global Installation\n\n::: code-group\n\n```bash [npm]\nnpm uninstall -g ccusage\n```\n\n```bash [bun]\nbun remove -g ccusage\n```\n\n```bash [yarn]\nyarn global remove ccusage\n```\n\n```bash [pnpm]\npnpm remove -g ccusage\n```\n\n:::\n\n### Development Installation\n\n```bash\n# Remove cloned repository\nrm -rf ccusage/\n```\n\n## Troubleshooting Installation\n\n### Permission Errors\n\nIf you get permission errors during global installation:\n\n::: code-group\n\n```bash [npm]\n# Use npx instead of global install\nnpx ccusage@latest\n\n# Or configure npm to use a different directory\nnpm config set prefix ~/.npm-global\nexport PATH=~/.npm-global/bin:$PATH\n```\n\n```bash [Node Version Managers]\n# Use nvm (recommended)\nnvm install node\nnpm install -g ccusage\n\n# Or use fnm\nfnm install node\nnpm install -g ccusage\n```\n\n:::\n\n### Network Issues\n\nIf installation fails due to network issues:\n\n```bash\n# Try with different registry\nnpm install -g ccusage --registry https://registry.npmjs.org\n\n# Or use bunx for offline-capable runs\nbunx ccusage\n```\n\n### Version Conflicts\n\nIf you have multiple versions installed:\n\n```bash\n# Check which version is being used\nwhich ccusage\nccusage --version\n\n# Uninstall and reinstall\nnpm uninstall -g ccusage\nnpm install -g ccusage@latest\n```\n\n## Next Steps\n\nAfter installation, check out:\n\n- [Getting Started Guide](/guide/getting-started) - Your first usage report\n- [Configuration](/guide/configuration) - Customize ccusage behavior\n- [Daily Reports](/guide/daily-reports) - Understand daily usage patterns\n"
  },
  {
    "path": "docs/guide/json-output.md",
    "content": "# JSON Output\n\nccusage 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.\n\n## Enabling JSON Output\n\nAdd the `--json` (or `-j`) flag to any command:\n\n```bash\n# Daily report in JSON format\nccusage daily --json\n\n# Monthly report in JSON format\nccusage monthly --json\n\n# Session report in JSON format\nccusage session --json\n\n# 5-hour blocks report in JSON format\nccusage blocks --json\n```\n\n## JSON Structure\n\n### Daily Reports (Standard)\n\nStandard daily reports aggregate usage across all projects:\n\n<!-- eslint-skip -->\n\n```json\n{\n\t\"daily\": [\n\t\t{\n\t\t\t\"date\": \"2025-05-30\",\n\t\t\t\"inputTokens\": 277,\n\t\t\t\"outputTokens\": 31456,\n\t\t\t\"cacheCreationTokens\": 512,\n\t\t\t\"cacheReadTokens\": 1024,\n\t\t\t\"totalTokens\": 33269,\n\t\t\t\"totalCost\": 17.58,\n\t\t\t\"modelsUsed\": [\"claude-opus-4-20250514\", \"claude-sonnet-4-20250514\"],\n\t\t\t\"modelBreakdowns\": [...]\n\t\t}\n\t],\n\t\"totals\": {\n\t\t\"inputTokens\": 11174,\n\t\t\"outputTokens\": 720366,\n\t\t\"cacheCreationTokens\": 896,\n\t\t\"cacheReadTokens\": 2304,\n\t\t\"totalTokens\": 734740,\n\t\t\"totalCost\": 336.47\n\t}\n}\n```\n\n### Daily Reports (Project-Grouped)\n\nWhen using `--instances`, daily reports group usage by project:\n\n<!-- eslint-skip -->\n\n```json\n{\n\t\"projects\": {\n\t\t\"my-frontend-app\": [\n\t\t\t{\n\t\t\t\t\"date\": \"2025-05-30\",\n\t\t\t\t\"inputTokens\": 177,\n\t\t\t\t\"outputTokens\": 16456,\n\t\t\t\t\"cacheCreationTokens\": 256,\n\t\t\t\t\"cacheReadTokens\": 512,\n\t\t\t\t\"totalTokens\": 17401,\n\t\t\t\t\"totalCost\": 7.33,\n\t\t\t\t\"modelsUsed\": [\"claude-sonnet-4-20250514\"],\n\t\t\t\t\"modelBreakdowns\": [...]\n\t\t\t}\n\t\t],\n\t\t\"backend-api\": [\n\t\t\t{\n\t\t\t\t\"date\": \"2025-05-30\",\n\t\t\t\t\"inputTokens\": 100,\n\t\t\t\t\"outputTokens\": 15000,\n\t\t\t\t\"cacheCreationTokens\": 256,\n\t\t\t\t\"cacheReadTokens\": 512,\n\t\t\t\t\"totalTokens\": 15868,\n\t\t\t\t\"totalCost\": 10.25,\n\t\t\t\t\"modelsUsed\": [\"claude-opus-4-20250514\"],\n\t\t\t\t\"modelBreakdowns\": [...]\n\t\t\t}\n\t\t]\n\t},\n\t\"totals\": {\n\t\t\"inputTokens\": 277,\n\t\t\"outputTokens\": 31456,\n\t\t\"cacheCreationTokens\": 512,\n\t\t\"cacheReadTokens\": 1024,\n\t\t\"totalTokens\": 33269,\n\t\t\"totalCost\": 17.58\n\t}\n}\n```\n\n#### Usage\n\n```bash\n# Standard aggregated output\nccusage daily --json\n\n# Project-grouped output\nccusage daily --instances --json\n\n# Filter to specific project\nccusage daily --project my-frontend-app --json\n```\n\n### Monthly Reports\n\n```json\n{\n\t\"type\": \"monthly\",\n\t\"data\": [\n\t\t{\n\t\t\t\"month\": \"2025-05\",\n\t\t\t\"models\": [\"claude-opus-4-20250514\", \"claude-sonnet-4-20250514\"],\n\t\t\t\"inputTokens\": 11174,\n\t\t\t\"outputTokens\": 720366,\n\t\t\t\"cacheCreationTokens\": 896,\n\t\t\t\"cacheReadTokens\": 2304,\n\t\t\t\"totalTokens\": 734740,\n\t\t\t\"costUSD\": 336.47\n\t\t}\n\t],\n\t\"summary\": {\n\t\t\"totalInputTokens\": 11174,\n\t\t\"totalOutputTokens\": 720366,\n\t\t\"totalCacheCreationTokens\": 896,\n\t\t\"totalCacheReadTokens\": 2304,\n\t\t\"totalTokens\": 734740,\n\t\t\"totalCostUSD\": 336.47\n\t}\n}\n```\n\n### Session Reports\n\n```json\n{\n\t\"type\": \"session\",\n\t\"data\": [\n\t\t{\n\t\t\t\"session\": \"session-1\",\n\t\t\t\"models\": [\"claude-opus-4-20250514\", \"claude-sonnet-4-20250514\"],\n\t\t\t\"inputTokens\": 4512,\n\t\t\t\"outputTokens\": 350846,\n\t\t\t\"cacheCreationTokens\": 512,\n\t\t\t\"cacheReadTokens\": 1024,\n\t\t\t\"totalTokens\": 356894,\n\t\t\t\"costUSD\": 156.4,\n\t\t\t\"lastActivity\": \"2025-05-24\"\n\t\t}\n\t],\n\t\"summary\": {\n\t\t\"totalInputTokens\": 11174,\n\t\t\"totalOutputTokens\": 720445,\n\t\t\"totalCacheCreationTokens\": 768,\n\t\t\"totalCacheReadTokens\": 1792,\n\t\t\"totalTokens\": 734179,\n\t\t\"totalCostUSD\": 336.68\n\t}\n}\n```\n\n### Blocks Reports\n\n```json\n{\n\t\"type\": \"blocks\",\n\t\"data\": [\n\t\t{\n\t\t\t\"blockStart\": \"2025-05-30T10:00:00.000Z\",\n\t\t\t\"blockEnd\": \"2025-05-30T15:00:00.000Z\",\n\t\t\t\"isActive\": true,\n\t\t\t\"timeRemaining\": \"2h 15m\",\n\t\t\t\"models\": [\"claude-sonnet-4-20250514\"],\n\t\t\t\"inputTokens\": 1250,\n\t\t\t\"outputTokens\": 15000,\n\t\t\t\"cacheCreationTokens\": 256,\n\t\t\t\"cacheReadTokens\": 512,\n\t\t\t\"totalTokens\": 17018,\n\t\t\t\"costUSD\": 8.75,\n\t\t\t\"burnRate\": 2400,\n\t\t\t\"projectedTotal\": 25000,\n\t\t\t\"projectedCost\": 12.5\n\t\t}\n\t],\n\t\"summary\": {\n\t\t\"totalInputTokens\": 11174,\n\t\t\"totalOutputTokens\": 720366,\n\t\t\"totalCacheCreationTokens\": 896,\n\t\t\"totalCacheReadTokens\": 2304,\n\t\t\"totalTokens\": 734740,\n\t\t\"totalCostUSD\": 336.47\n\t}\n}\n```\n\n## Field Descriptions\n\n### Common Fields\n\n- `models`: Array of Claude model names used\n- `inputTokens`: Number of input tokens consumed\n- `outputTokens`: Number of output tokens generated\n- `cacheCreationTokens`: Tokens used for cache creation\n- `cacheReadTokens`: Tokens read from cache\n- `totalTokens`: Sum of all token types\n- `costUSD`: Estimated cost in US dollars\n\n### Report-Specific Fields\n\n#### Daily Reports\n\n- `date`: Date in YYYY-MM-DD format\n\n#### Monthly Reports\n\n- `month`: Month in YYYY-MM format\n\n#### Session Reports\n\n- `session`: Session identifier\n- `lastActivity`: Date of last activity in the session\n\n#### Blocks Reports\n\n- `blockStart`: ISO timestamp of block start\n- `blockEnd`: ISO timestamp of block end\n- `isActive`: Whether the block is currently active\n- `timeRemaining`: Human-readable time remaining (active blocks only)\n- `burnRate`: Tokens per hour rate (active blocks only)\n- `projectedTotal`: Projected total tokens for the block\n- `projectedCost`: Projected total cost for the block\n\n## Filtering with JSON Output\n\nAll filtering options work with JSON output:\n\n```bash\n# Filter by date range\nccusage daily --json --since 20250525 --until 20250530\n\n# Different cost calculation modes\nccusage monthly --json --mode calculate\nccusage session --json --mode display\n\n# Sort order\nccusage daily --json --order asc\n\n# With model breakdown\nccusage daily --json --breakdown\n\n# Project analysis\nccusage daily --json --instances                    # Group by project\nccusage daily --json --project my-project           # Filter to project\nccusage daily --json --instances --project my-app   # Combined usage\n```\n\n### Model Breakdown JSON\n\nWhen using `--breakdown`, the JSON includes per-model details:\n\n```json\n{\n\t\"type\": \"daily\",\n\t\"data\": [\n\t\t{\n\t\t\t\"date\": \"2025-05-30\",\n\t\t\t\"models\": [\"claude-opus-4-20250514\", \"claude-sonnet-4-20250514\"],\n\t\t\t\"inputTokens\": 277,\n\t\t\t\"outputTokens\": 31456,\n\t\t\t\"totalTokens\": 33269,\n\t\t\t\"costUSD\": 17.58,\n\t\t\t\"breakdown\": {\n\t\t\t\t\"claude-opus-4-20250514\": {\n\t\t\t\t\t\"inputTokens\": 100,\n\t\t\t\t\t\"outputTokens\": 15000,\n\t\t\t\t\t\"cacheCreationTokens\": 256,\n\t\t\t\t\t\"cacheReadTokens\": 512,\n\t\t\t\t\t\"totalTokens\": 15868,\n\t\t\t\t\t\"costUSD\": 10.25\n\t\t\t\t},\n\t\t\t\t\"claude-sonnet-4-20250514\": {\n\t\t\t\t\t\"inputTokens\": 177,\n\t\t\t\t\t\"outputTokens\": 16456,\n\t\t\t\t\t\"cacheCreationTokens\": 256,\n\t\t\t\t\t\"cacheReadTokens\": 512,\n\t\t\t\t\t\"totalTokens\": 17401,\n\t\t\t\t\t\"costUSD\": 7.33\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n}\n```\n\n## Using the --jq Option\n\nccusage includes built-in jq processing with the `--jq` option. This allows you to process JSON output directly without using pipes:\n\n```bash\n# Get total cost directly\nccusage daily --jq '.totals.totalCost'\n\n# Find the most expensive session\nccusage session --jq '.sessions | sort_by(.totalCost) | reverse | .[0]'\n\n# Get daily costs as CSV\nccusage daily --jq '.daily[] | [.date, .totalCost] | @csv'\n\n# List all unique models used\nccusage session --jq '[.sessions[].modelsUsed[]] | unique | sort[]'\n\n# Get usage by specific date\nccusage daily --jq '.daily[] | select(.date == \"2025-05-30\")'\n\n# Calculate average daily cost\nccusage daily --jq '[.daily[].totalCost] | add / length'\n```\n\n### Important Notes\n\n- The `--jq` option implies `--json` (you don't need to specify both)\n- Requires jq to be installed on your system\n- If jq is not installed, you'll get an error message with installation instructions\n\n## Integration Examples\n\n### Using with jq (via pipes)\n\nYou can also pipe JSON output to jq for advanced filtering and formatting:\n\n```bash\n# Get total cost for the last 7 days\nccusage daily --json --since $(date -d '7 days ago' +%Y%m%d) | jq '.summary.totalCostUSD'\n\n# List all unique models used\nccusage session --json | jq -r '.data[].models[]' | sort -u\n\n# Find the most expensive session\nccusage session --json | jq -r '.data | sort_by(.costUSD) | reverse | .[0].session'\n\n# Get daily costs as CSV\nccusage daily --json | jq -r '.daily[] | [.date, .totalCost] | @csv'\n\n# Analyze project costs\nccusage daily --instances --json | jq -r '.projects | to_entries[] | [.key, (.value | map(.totalCost) | add)] | @csv'\n\n# Find most expensive project\nccusage daily --instances --json | jq -r '.projects | to_entries | map({project: .key, total: (.value | map(.totalCost) | add)}) | sort_by(.total) | reverse | .[0].project'\n\n# Get usage by project for specific date\nccusage daily --instances --json | jq '.projects | to_entries[] | select(.value[].date == \"2025-05-30\") | {project: .key, usage: .value[0]}'\n```\n\n### Using with Python\n\n```python\nimport json\nimport subprocess\n\n# Get daily usage data\nresult = subprocess.run(['ccusage', 'daily', '--json'], capture_output=True, text=True)\ndata = json.loads(result.stdout)\n\n# Process the data\nfor day in data['data']:\n    print(f\"Date: {day['date']}, Cost: ${day['costUSD']:.2f}\")\n\ntotal_cost = data['totals']['totalCost']\nprint(f\"Total cost: ${total_cost:.2f}\")\n\n# Project analysis example\nresult = subprocess.run(['ccusage', 'daily', '--instances', '--json'], capture_output=True, text=True)\nproject_data = json.loads(result.stdout)\n\nif 'projects' in project_data:\n    for project_name, daily_entries in project_data['projects'].items():\n        project_total = sum(day['totalCost'] for day in daily_entries)\n        print(f\"Project {project_name}: ${project_total:.2f}\")\n\n    # Find highest spending project\n    project_totals = {\n        project: sum(day['totalCost'] for day in days)\n        for project, days in project_data['projects'].items()\n    }\n    top_project = max(project_totals, key=project_totals.get)\n    print(f\"Highest spending project: {top_project} (${project_totals[top_project]:.2f})\")\n```\n\n### Using with Node.js\n\n```javascript\nimport { execSync } from 'node:child_process';\n\n// Get session usage data\nconst output = execSync('ccusage session --json', { encoding: 'utf-8' });\nconst data = JSON.parse(output);\n\n// Find sessions over $10\nconst expensiveSessions = data.data.filter((session) => session.costUSD > 10);\nconsole.log(`Found ${expensiveSessions.length} expensive sessions`);\n\nexpensiveSessions.forEach((session) => {\n\tconsole.log(`${session.session}: $${session.costUSD.toFixed(2)}`);\n});\n\n// Project analysis example\nconst projectOutput = execSync('ccusage daily --instances --json', { encoding: 'utf-8' });\nconst projectData = JSON.parse(projectOutput);\n\nif (projectData.projects) {\n\t// Calculate total cost per project\n\tconst projectCosts = Object.entries(projectData.projects).map(([name, days]) => ({\n\t\tname,\n\t\ttotalCost: days.reduce((sum, day) => sum + day.totalCost, 0),\n\t\ttotalTokens: days.reduce((sum, day) => sum + day.totalTokens, 0),\n\t}));\n\n\t// Sort by cost descending\n\tprojectCosts.sort((a, b) => b.totalCost - a.totalCost);\n\n\tconsole.log('Project Usage Summary:');\n\tprojectCosts.forEach((project) => {\n\t\tconsole.log(\n\t\t\t`${project.name}: $${project.totalCost.toFixed(2)} (${project.totalTokens.toLocaleString()} tokens)`,\n\t\t);\n\t});\n}\n```\n\n## Programmatic Usage\n\nJSON output is designed for programmatic consumption:\n\n- **Consistent structure**: All fields are always present (with 0 or empty values when not applicable)\n- **Standard types**: Numbers for metrics, strings for identifiers, arrays for lists\n- **ISO timestamps**: Standardized date/time formats for reliable parsing\n- **Stable schema**: Field names and structures remain consistent across versions\n"
  },
  {
    "path": "docs/guide/library-usage.md",
    "content": "# Library Usage\n\nWhile **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.\n\n## Installation\n\n```bash\nnpm install ccusage\n# or\nyarn add ccusage\n# or\npnpm add ccusage\n# or\nbun add ccusage\n```\n\n## Basic Usage\n\nThe library provides functions to load and analyze Claude Code usage data:\n\n```typescript\nimport { loadDailyUsageData, loadMonthlyUsageData, loadSessionData } from 'ccusage/data-loader';\n\n// Load daily usage data\nconst dailyData = await loadDailyUsageData();\nconsole.log(dailyData);\n\n// Load monthly usage data\nconst monthlyData = await loadMonthlyUsageData();\nconsole.log(monthlyData);\n\n// Load session data\nconst sessionData = await loadSessionData();\nconsole.log(sessionData);\n```\n\n## Cost Calculation\n\nUse the cost calculation utilities to work with token costs:\n\n```typescript\nimport { calculateTotals, getTotalTokens } from 'ccusage/calculate-cost';\n\n// Assume 'usageEntries' is an array of usage data objects\nconst totals = calculateTotals(usageEntries);\n\n// Get total tokens from the same entries\nconst totalTokens = getTotalTokens(usageEntries);\n```\n\n## Advanced Configuration\n\nYou can customize the data loading behavior:\n\n```typescript\nimport { loadDailyUsageData } from 'ccusage/data-loader';\n\n// Load data with custom options\nconst data = await loadDailyUsageData({\n\tmode: 'calculate', // Force cost calculation\n\tclaudePaths: ['/custom/path/to/claude'], // Custom Claude data paths\n});\n```\n\n## TypeScript Support\n\nThe library is fully typed with TypeScript definitions:\n\n```typescript\nimport type {\n\tDailyUsage,\n\tModelBreakdown,\n\tMonthlyUsage,\n\tSessionUsage,\n\tUsageData,\n} from 'ccusage/data-loader';\n\n// Use the types in your application\nfunction processUsageData(data: UsageData[]): void {\n\t// Your processing logic here\n}\n```\n\n## MCP Server Integration\n\nYou can also create your own MCP server using the dedicated `@ccusage/mcp` package:\n\n> **Note**: Install `ccusage` and `@ccusage/mcp` together, for example with `pnpm add ccusage @ccusage/mcp`.\n\n```typescript\nimport { createMcpServer } from '@ccusage/mcp';\n\n// Create an MCP server instance\nconst server = createMcpServer();\n\n// Start the server\nserver.start();\n```\n\n## API Reference\n\nFor detailed information about all available functions, types, and options, see the [API Reference](/api/) section.\n\n## Examples\n\nHere are some common use cases:\n\n### Building a Web Dashboard\n\n```typescript\nimport { loadDailyUsageData } from 'ccusage/data-loader';\n\nexport async function GET() {\n\tconst data = await loadDailyUsageData();\n\treturn Response.json(data);\n}\n```\n\n### Creating Custom Reports\n\n```typescript\nimport { calculateTotals, loadSessionData } from 'ccusage';\n\nasync function generateCustomReport() {\n\tconst sessions = await loadSessionData();\n\n\tconst report = sessions.map((session) => ({\n\t\tproject: session.project,\n\t\tsession: session.session,\n\t\ttotalCost: calculateTotals(session.usage).costUSD,\n\t}));\n\n\treturn report;\n}\n```\n\n### Monitoring Usage Programmatically\n\n```typescript\nimport { loadDailyUsageData } from 'ccusage/data-loader';\n\nasync function checkUsageAlert() {\n\tconst dailyData = await loadDailyUsageData();\n\tconst today = dailyData[0]; // Most recent day\n\n\tif (today.totalCostUSD > 10) {\n\t\tconsole.warn(`High usage detected: $${today.totalCostUSD}`);\n\t}\n}\n```\n\n## Next Steps\n\n- Explore the [API Reference](/api/) for complete documentation\n- Check out the [MCP Server guide](/guide/mcp-server) for integration examples\n- See [JSON Output](/guide/json-output) for data format details\n"
  },
  {
    "path": "docs/guide/live-monitoring.md",
    "content": "# Live Monitoring (Removed)\n\n![Live monitoring dashboard showing real-time token usage, burn rate, and cost projections](/blocks-live.png)\n\n::: danger REMOVED IN v18\nThe `blocks --live` monitor feature has been removed in v18.0.0. This feature is available in v17.x.\n:::\n\n## Historical Reference (v17.x)\n\nThe following documentation is preserved for users on v17.x.\n\n### Quick Start\n\n```bash\nccusage blocks --live\n```\n\nThis starts live monitoring with automatic token limit detection based on your usage history.\n\n### Features\n\n#### Real-time Updates\n\nThe dashboard refreshes every second, showing:\n\n- **Current session progress** with visual progress bar\n- **Token burn rate** (tokens per minute)\n- **Time remaining** in current 5-hour block\n- **Cost projections** based on current usage patterns\n- **Quota warnings** with color-coded alerts\n\n### Command Options\n\n#### Token Limits\n\nSet custom token limits for quota warnings:\n\n```bash\n# Use specific token limit\nccusage blocks --live -t 500000\n\n# Use highest previous session as limit (default)\nccusage blocks --live -t max\n```\n\n#### Refresh Interval\n\nControl update frequency:\n\n```bash\n# Update every 5 seconds\nccusage blocks --live --refresh-interval 5\n\n# Update every 10 seconds (lighter on CPU)\nccusage blocks --live --refresh-interval 10\n```\n\n### Keyboard Controls\n\nWhile live monitoring is active:\n\n- **Ctrl+C**: Exit monitoring gracefully\n- **Terminal resize**: Automatically adjusts display\n\n## Related Commands\n\n- [Blocks Reports](/guide/blocks-reports) - Static 5-hour block analysis\n- [Session Reports](/guide/session-reports) - Historical session data\n- [Daily Reports](/guide/daily-reports) - Day-by-day usage patterns\n"
  },
  {
    "path": "docs/guide/mcp-server.md",
    "content": "# MCP Server\n\nThe 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.\n\n## Running the MCP CLI\n\nExecute the MCP CLI directly without installation using `bunx` or `npx`:\n\n```bash\nbunx @ccusage/mcp@latest --help\n# or\nnpx @ccusage/mcp@latest --help\n```\n\nAll examples below use `bunx @ccusage/mcp@latest` (you can substitute with `npx @ccusage/mcp@latest` if preferred).\n\n## Starting the MCP Server\n\n### stdio transport (default)\n\n```bash\nbunx @ccusage/mcp@latest\n# equivalent:\nbunx @ccusage/mcp@latest --type stdio\n```\n\nThe stdio transport is ideal when the MCP client spawns the process directly (for example, Claude Desktop on the same machine).\n\n### HTTP Stream Transport\n\n```bash\nbunx @ccusage/mcp@latest --type http --port 8080\n```\n\nHTTP mode is useful when you need to expose the server to other hosts or run it as a background service.\n\n### Cost Calculation Mode\n\nControl how costs are calculated when generating reports:\n\n```bash\n# Use cached costUSD values when present, otherwise calculate from tokens (default)\nbunx @ccusage/mcp@latest --mode auto\n\n# Always calculate from tokens using LiteLLM pricing data\nbunx @ccusage/mcp@latest --mode calculate\n\n# Only use pre-calculated costUSD values and default to 0 when missing\nbunx @ccusage/mcp@latest --mode display\n```\n\nAll options from the original command remain available, including `CLAUDE_CONFIG_DIR` for custom data locations.\n\n## Available MCP Tools\n\nThe server still provides four tools with the same schemas as before:\n\n- **daily** – aggregated usage per day\n- **monthly** – aggregated usage per month\n- **session** – grouped by Claude session ID / project directory\n- **blocks** – 5-hour billing block summaries\n\nEach tool accepts `since`, `until`, and `mode` parameters, plus timezone/locale overrides identical to the ccusage library.\n\n## Testing the MCP Server\n\n### With MCP Inspector\n\n```bash\nbunx @modelcontextprotocol/inspector bunx @ccusage/mcp@latest\n# or\nnpx @modelcontextprotocol/inspector npx @ccusage/mcp@latest\n```\n\nThe Inspector lets you:\n\n- Call each tool interactively\n- Inspect the tool schemas and responses\n- Debug invalid parameters or unexpected data\n- Export ready-to-use server definitions\n\n### Manual JSON-RPC Testing\n\n```bash\nbunx @ccusage/mcp@latest\n# Now send JSON-RPC to stdin, e.g. list available tools\n{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"tools/list\"}\n```\n\n## Claude Desktop Integration\n\n![Claude Desktop MCP Configuration](/mcp-claude-desktop.avif)\n\nUpdate your Claude Desktop configuration to use direct execution:\n\n```json\n{\n\t\"mcpServers\": {\n\t\t\"ccusage\": {\n\t\t\t\"command\": \"bunx\",\n\t\t\t\"args\": [\"@ccusage/mcp@latest\"],\n\t\t\t\"env\": {}\n\t\t}\n\t}\n}\n```\n\nOr using `npx`:\n\n```json\n{\n\t\"mcpServers\": {\n\t\t\"ccusage\": {\n\t\t\t\"command\": \"npx\",\n\t\t\t\"args\": [\"@ccusage/mcp@latest\"],\n\t\t\t\"env\": {}\n\t\t}\n\t}\n}\n```\n\nNeed custom paths or cost modes? Pass them as arguments:\n\n```json\n{\n\t\"mcpServers\": {\n\t\t\"ccusage\": {\n\t\t\t\"command\": \"bunx\",\n\t\t\t\"args\": [\"@ccusage/mcp@latest\", \"--mode\", \"calculate\", \"--type\", \"http\", \"--port\", \"8080\"],\n\t\t\t\"env\": {\n\t\t\t\t\"CLAUDE_CONFIG_DIR\": \"/path/to/claude/data\"\n\t\t\t}\n\t\t}\n\t}\n}\n```\n\nAfter updating the file, restart Claude Desktop so it picks up the new MCP server.\n\n### Example prompts inside Claude Desktop\n\n- \"Ask the ccusage MCP server for today's usage report\"\n- \"Show me the sessions with the highest cost this week\"\n- \"Summarize my current billing block\"\n\n## Library Usage\n\nPrefer to embed the MCP server directly? Import it from the library just like before:\n\n```ts\nimport { createMcpServer } from '@ccusage/mcp';\n\nconst server = createMcpServer();\n// ...connect it to the transport of your choice\n```\n\nSee the [Library Usage guide](/guide/library-usage) for more examples.\n"
  },
  {
    "path": "docs/guide/monthly-reports.md",
    "content": "# Monthly Reports\n\nMonthly reports aggregate your Claude Code usage by calendar month, providing a high-level view of your usage patterns and costs over longer time periods.\n\n:::warning NOTICE\nClaude 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.\n\n[Claude Code settings - Claude Docs](https://docs.claude.com/en/docs/claude-code/settings#settings-files)\n:::\n\n## Basic Usage\n\n```bash\nccusage monthly\n```\n\n## Example Output\n\n```\n╭─────────────────────────────────────────────╮\n│                                             │\n│  Claude Code Token Usage Report - Monthly  │\n│                                             │\n╰─────────────────────────────────────────────╯\n\n┌─────────┬──────────────────┬─────────┬──────────┬──────────────┬────────────┬──────────────┬────────────┐\n│ Month   │ Models           │ Input   │ Output   │ Cache Create │ Cache Read │ Total Tokens │ Cost (USD) │\n├─────────┼──────────────────┼─────────┼──────────┼──────────────┼────────────┼──────────────┼────────────┤\n│ 2025-06 │ • opus-4         │  45,231 │  892,456 │        2,048 │      4,096 │      943,831 │   $1,247.92│\n│         │ • sonnet-4       │         │          │              │            │              │            │\n│ 2025-05 │ • sonnet-4       │  38,917 │  756,234 │        1,536 │      3,072 │      799,759 │     $892.15│\n│ 2025-04 │ • opus-4         │  22,458 │  534,789 │        1,024 │      2,048 │      560,319 │     $678.43│\n├─────────┼──────────────────┼─────────┼──────────┼──────────────┼────────────┼──────────────┼────────────┤\n│ Total   │                  │ 106,606 │2,183,479 │        4,608 │      9,216 │    2,303,909 │   $2,818.50│\n└─────────┴──────────────────┴─────────┴──────────┴──────────────┴────────────┴──────────────┴────────────┘\n```\n\n## Understanding Monthly Data\n\n### Month Format\n\nMonths are displayed in YYYY-MM format:\n\n- `2025-06` = June 2025\n- `2025-05` = May 2025\n\n### Aggregation Logic\n\nAll usage within a calendar month is aggregated:\n\n- Input/output tokens summed across all days\n- Costs calculated from total token usage\n- Models listed if used at any point in the month\n\n## Command Options\n\n### Date Filtering\n\nFilter by month range:\n\n```bash\n# Show specific months\nccusage monthly --since 20250101 --until 20250630\n\n# Show usage from 2024\nccusage monthly --since 20240101 --until 20241231\n\n# Show last 6 months\nccusage monthly --since $(date -d '6 months ago' +%Y%m%d)\n```\n\n::: tip Date Filtering\nEven though you specify full dates (YYYYMMDD), monthly reports group by month. The filters determine which months to include.\n:::\n\n### Sort Order\n\n```bash\n# Newest months first (default)\nccusage monthly --order desc\n\n# Oldest months first\nccusage monthly --order asc\n```\n\n### Cost Calculation Modes\n\n```bash\n# Use pre-calculated costs when available (default)\nccusage monthly --mode auto\n\n# Always calculate costs from tokens\nccusage monthly --mode calculate\n\n# Only show pre-calculated costs\nccusage monthly --mode display\n```\n\n### Model Breakdown\n\nSee costs broken down by model:\n\n```bash\nccusage monthly --breakdown\n```\n\nExample with breakdown:\n\n```\n┌─────────┬──────────────────┬─────────┬──────────┬────────────┐\n│ Month   │ Models           │ Input   │ Output   │ Cost (USD) │\n├─────────┼──────────────────┼─────────┼──────────┼────────────┤\n│ 2025-06 │ opus-4, sonnet-4 │  45,231 │  892,456 │  $1,247.92 │\n├─────────┼──────────────────┼─────────┼──────────┼────────────┤\n│  └─ opus-4                 │  20,000 │  400,000 │    $750.50 │\n├─────────┼──────────────────┼─────────┼──────────┼────────────┤\n│  └─ sonnet-4               │  25,231 │  492,456 │    $497.42 │\n└─────────┴──────────────────┴─────────┴──────────┴────────────┘\n```\n\n### JSON Output\n\n```bash\nccusage monthly --json\n```\n\n```json\n[\n\t{\n\t\t\"month\": \"2025-06\",\n\t\t\"models\": [\"opus-4\", \"sonnet-4\"],\n\t\t\"inputTokens\": 45231,\n\t\t\"outputTokens\": 892456,\n\t\t\"cacheCreationTokens\": 2048,\n\t\t\"cacheReadTokens\": 4096,\n\t\t\"totalTokens\": 943831,\n\t\t\"totalCost\": 1247.92\n\t}\n]\n```\n\n### Offline Mode\n\n```bash\nccusage monthly --offline\n```\n\n## Analysis Use Cases\n\n### Budget Planning\n\nMonthly reports help with subscription planning:\n\n```bash\n# Check last year's usage\nccusage monthly --since 20240101 --until 20241231\n```\n\nLook at the total cost to understand what you'd pay on usage-based pricing.\n\n### Usage Trends\n\nTrack how your usage changes over time:\n\n```bash\n# Compare year over year\nccusage monthly --since 20230101 --until 20231231  # 2023\nccusage monthly --since 20240101 --until 20241231  # 2024\n```\n\n### Model Migration Analysis\n\nSee how your model usage evolves:\n\n```bash\nccusage monthly --breakdown\n```\n\nThis helps track transitions between Opus, Sonnet, and other models.\n\n### Seasonal Patterns\n\nIdentify busy/slow periods:\n\n```bash\n# Academic year analysis\nccusage monthly --since 20240901 --until 20250630\n```\n\n### Export for Business Analysis\n\n```bash\n# Create quarterly reports\nccusage monthly --since 20241001 --until 20241231 --json > q4-2024.json\n```\n\n## Tips for Monthly Analysis\n\n### 1. Cost Context\n\nMonthly totals show:\n\n- **Subscription Value**: How much you'd pay with usage-based billing\n- **Usage Intensity**: Months with heavy Claude usage\n- **Model Preferences**: Which models you favor over time\n\n### 2. Trend Analysis\n\nLook for patterns:\n\n- Increasing usage over time\n- Seasonal variations\n- Model adoption curves\n\n### 3. Business Planning\n\nUse monthly data for:\n\n- Team budget planning\n- Usage forecasting\n- Subscription optimization\n\n### 4. Comparative Analysis\n\nCompare monthly reports with:\n\n- Team productivity metrics\n- Project timelines\n- Business outcomes\n\n## Related Commands\n\n- [Daily Reports](/guide/daily-reports) - Day-by-day breakdown\n- [Session Reports](/guide/session-reports) - Individual conversations\n- [Blocks Reports](/guide/blocks-reports) - 5-hour billing periods\n\n## Next Steps\n\nAfter analyzing monthly trends, consider:\n\n1. [Session Reports](/guide/session-reports) to identify high-cost conversations\n2. [Live Monitoring](/guide/live-monitoring) to track real-time usage\n3. [Library Usage](/guide/library-usage) for programmatic analysis\n"
  },
  {
    "path": "docs/guide/opencode/index.md",
    "content": "# OpenCode CLI Overview (Beta)\n\n> The OpenCode companion CLI is experimental. Expect breaking changes while both ccusage and [OpenCode](https://github.com/sst/opencode) continue to evolve.\n\nThe `@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.\n\n## Installation & Launch\n\n::: code-group\n\n```bash [bunx (Recommended)]\nbunx @ccusage/opencode@latest --help\n```\n\n```bash [npx]\nnpx @ccusage/opencode@latest --help\n```\n\n```bash [pnpm]\npnpm dlx @ccusage/opencode --help\n```\n\n```bash [opencode x]\nBUN_BE_BUN=1 opencode x @ccusage/opencode@latest --help\n```\n\n:::\n\n::: tip opencode x option\nThe `opencode x` option requires the native version of OpenCode. If you installed OpenCode via npm, use the `bunx` or `npx` options instead.\n:::\n\n### Recommended: Shell Alias\n\n```bash\n# bash/zsh\nalias ccusage-opencode='bunx @ccusage/opencode@latest'\n\n# fish\nalias ccusage-opencode 'bunx @ccusage/opencode@latest'\n```\n\n## Data Source\n\nThe CLI reads OpenCode message and session JSON files located under `OPENCODE_DATA_DIR` (defaults to `~/.local/share/opencode`).\n\n<!-- eslint-skip -->\n\n```\n~/.local/share/opencode/\n└── storage/\n    ├── message/{sessionID}/msg_{messageID}.json\n    └── session/{projectHash}/{sessionID}.json\n```\n\n## Available Commands\n\n| Command   | Description                                          | See also                                  |\n| --------- | ---------------------------------------------------- | ----------------------------------------- |\n| `daily`   | Aggregate usage by date (YYYY-MM-DD)                 | [Daily Reports](/guide/daily-reports)     |\n| `weekly`  | Aggregate usage by ISO week (YYYY-Www)               | [Weekly Reports](/guide/weekly-reports)   |\n| `monthly` | Aggregate usage by month (YYYY-MM)                   | [Monthly Reports](/guide/monthly-reports) |\n| `session` | Per-session breakdown with parent/subagent hierarchy | [Session Reports](/guide/session-reports) |\n\nAll commands support `--json` for structured output and `--compact` for narrow terminals. See the linked ccusage documentation for detailed flag descriptions.\n\n## Session Hierarchy\n\nOpenCode supports subagent sessions. The session report displays:\n\n- **Bold titles** for parent sessions with subagents\n- **Indented rows** (`↳`) for subagent sessions\n- **Subtotal rows** combining parent + subagents\n\n## Environment Variables\n\n| Variable            | Description                                          |\n| ------------------- | ---------------------------------------------------- |\n| `OPENCODE_DATA_DIR` | Override the root directory containing OpenCode data |\n| `LOG_LEVEL`         | Adjust verbosity (0 silent ... 5 trace)              |\n\n## Cost Calculation\n\nOpenCode 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.\n\n## Troubleshooting\n\n::: details No OpenCode usage data found\nEnsure the data directory exists at `~/.local/share/opencode/storage/message/`. Set `OPENCODE_DATA_DIR` for custom paths.\n:::\n\n::: details Costs showing as $0.00\nIf 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.\n:::\n"
  },
  {
    "path": "docs/guide/pi/index.md",
    "content": "# Pi-Agent Integration\n\nThe `@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).\n\n## What is Pi-Agent?\n\nPi-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.\n\n## Installation & Launch\n\n```bash\n# Recommended - always include @latest\nnpx @ccusage/pi@latest --help\nbunx @ccusage/pi@latest --help  # ⚠️ MUST include @latest with bunx\n\n# Alternative package runners\npnpm dlx @ccusage/pi --help\npnpx @ccusage/pi --help\n```\n\n::: warning ⚠️ Critical for bunx users\nBun'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.\n:::\n\n### Recommended: Shell Alias\n\nSince `npx @ccusage/pi@latest` is quite long to type repeatedly, we strongly recommend setting up a shell alias for convenience:\n\n```bash\n# bash/zsh: alias ccusage-pi='bunx @ccusage/pi@latest'\n# fish:     alias ccusage-pi 'bunx @ccusage/pi@latest'\n\n# Then simply run:\nccusage-pi daily\nccusage-pi monthly --json\n```\n\n::: tip\nAfter 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.\n:::\n\n## Data Source\n\nThe CLI reads usage data from pi-agent:\n\n| Source   | Default Path            |\n| -------- | ----------------------- |\n| Pi-agent | `~/.pi/agent/sessions/` |\n\n## Available Commands\n\n```bash\n# Show daily pi-agent usage\nccusage-pi daily\n\n# Show monthly pi-agent usage\nccusage-pi monthly\n\n# Show session-based pi-agent usage\nccusage-pi session\n\n# JSON output for automation\nccusage-pi daily --json\n\n# Custom pi-agent path\nccusage-pi daily --pi-path /path/to/sessions\n\n# Filter by date range\nccusage-pi daily --since 2025-12-01 --until 2025-12-19\n\n# Show model breakdown\nccusage-pi daily --breakdown\n```\n\n## Environment Variables\n\n| Variable       | Description                                   |\n| -------------- | --------------------------------------------- |\n| `PI_AGENT_DIR` | Custom path to pi-agent sessions directory    |\n| `LOG_LEVEL`    | Adjust logging verbosity (0 silent … 5 trace) |\n\n## Daily Report\n\nThe `daily` command shows daily usage from pi-agent.\n\n```bash\n# Recommended (fastest)\nbunx @ccusage/pi@latest daily\n\n# Using npx\nnpx @ccusage/pi@latest daily\n```\n\n### Options\n\n| Flag          | Short | Description                                   |\n| ------------- | ----- | --------------------------------------------- |\n| `--since`     |       | Start date filter (YYYY-MM-DD or YYYYMMDD)    |\n| `--until`     |       | End date filter (YYYY-MM-DD or YYYYMMDD)      |\n| `--timezone`  | `-z`  | Override timezone for date grouping           |\n| `--json`      |       | Emit structured JSON instead of a table       |\n| `--breakdown` | `-b`  | Show per-model token breakdown                |\n| `--pi-path`   |       | Custom path to pi-agent sessions directory    |\n| `--order`     |       | Sort order: `asc` or `desc` (default: `desc`) |\n\n### Example Output\n\n```\n┌────────────┬────────────┬─────────────┬───────────┬───────────┬────────┬─────────┐\n│ Date       │ Input      │ Output      │ Cache Cr. │ Cache Rd. │ Cost   │ Models  │\n├────────────┼────────────┼─────────────┼───────────┼───────────┼────────┼─────────┤\n│ 2025-01-09 │ 567,890    │ 123,456     │ 5,678     │ 45,678    │ $0.89  │ opus-4  │\n├────────────┼────────────┼─────────────┼───────────┼───────────┼────────┼─────────┤\n│ Total      │ 567,890    │ 123,456     │ 5,678     │ 45,678    │ $0.89  │         │\n└────────────┴────────────┴─────────────┴───────────┴───────────┴────────┴─────────┘\n```\n\n### JSON Output\n\nUse `--json` for automation and scripting:\n\n```bash\nccusage-pi daily --json\n```\n\nReturns structured data:\n\n<!-- eslint-skip -->\n\n```json\n{\n  \"daily\": [\n    {\n      \"date\": \"2025-01-09\",\n      \"source\": \"pi-agent\",\n      \"inputTokens\": 567890,\n      \"outputTokens\": 123456,\n      \"cacheCreationTokens\": 5678,\n      \"cacheReadTokens\": 45678,\n      \"totalCost\": 0.89,\n      \"modelsUsed\": [\"claude-opus-4-5-20251101\"],\n      \"modelBreakdowns\": [...]\n    }\n  ],\n  \"totals\": {\n    \"inputTokens\": 567890,\n    \"outputTokens\": 123456,\n    \"cacheCreationTokens\": 5678,\n    \"cacheReadTokens\": 45678,\n    \"totalCost\": 0.89\n  }\n}\n```\n\n### Date Filtering\n\nFilter to a specific date range:\n\n```bash\n# Last week\nccusage-pi daily --since 2025-01-02 --until 2025-01-09\n\n# Single day\nccusage-pi daily --since 2025-01-09 --until 2025-01-09\n```\n\n## Monthly Report\n\nThe `monthly` command shows monthly usage from pi-agent.\n\n```bash\n# Recommended (fastest)\nbunx @ccusage/pi@latest monthly\n\n# Using npx\nnpx @ccusage/pi@latest monthly\n```\n\n### Options\n\n| Flag          | Short | Description                                   |\n| ------------- | ----- | --------------------------------------------- |\n| `--since`     |       | Start date filter (YYYY-MM-DD or YYYYMMDD)    |\n| `--until`     |       | End date filter (YYYY-MM-DD or YYYYMMDD)      |\n| `--timezone`  | `-z`  | Override timezone for date grouping           |\n| `--json`      |       | Emit structured JSON instead of a table       |\n| `--breakdown` | `-b`  | Show per-model token breakdown                |\n| `--pi-path`   |       | Custom path to pi-agent sessions directory    |\n| `--order`     |       | Sort order: `asc` or `desc` (default: `desc`) |\n\n### Example Output\n\n```\n┌─────────┬────────────┬─────────────┬───────────┬───────────┬─────────┬─────────┐\n│ Month   │ Input      │ Output      │ Cache Cr. │ Cache Rd. │ Cost    │ Models  │\n├─────────┼────────────┼─────────────┼───────────┼───────────┼─────────┼─────────┤\n│ 2025-01 │ 12,345,678 │ 2,345,678   │ 123,456   │ 987,654   │ $12.34  │ opus-4  │\n├─────────┼────────────┼─────────────┼───────────┼───────────┼─────────┼─────────┤\n│ Total   │ 12,345,678 │ 2,345,678   │ 123,456   │ 987,654   │ $12.34  │         │\n└─────────┴────────────┴─────────────┴───────────┴───────────┴─────────┴─────────┘\n```\n\n### JSON Output\n\nUse `--json` for automation and scripting:\n\n```bash\nccusage-pi monthly --json\n```\n\nReturns structured data:\n\n<!-- eslint-skip -->\n\n```json\n{\n  \"monthly\": [\n    {\n      \"month\": \"2025-01\",\n      \"source\": \"pi-agent\",\n      \"inputTokens\": 12345678,\n      \"outputTokens\": 2345678,\n      \"cacheCreationTokens\": 123456,\n      \"cacheReadTokens\": 987654,\n      \"totalCost\": 12.34,\n      \"modelsUsed\": [\"claude-opus-4-5-20251101\"],\n      \"modelBreakdowns\": [...]\n    }\n  ],\n  \"totals\": {\n    \"inputTokens\": 12345678,\n    \"outputTokens\": 2345678,\n    \"cacheCreationTokens\": 123456,\n    \"cacheReadTokens\": 987654,\n    \"totalCost\": 12.34\n  }\n}\n```\n\n### Filtering by Date Range\n\nYou can filter the data to specific months:\n\n```bash\n# Current year only\nccusage-pi monthly --since 2025-01-01\n\n# Specific quarter\nccusage-pi monthly --since 2024-10-01 --until 2024-12-31\n```\n\n## Session Report\n\nThe `session` command shows usage grouped by individual pi-agent sessions.\n\n```bash\n# Recommended (fastest)\nbunx @ccusage/pi@latest session\n\n# Using npx\nnpx @ccusage/pi@latest session\n```\n\n### Options\n\n| Flag          | Short | Description                                   |\n| ------------- | ----- | --------------------------------------------- |\n| `--since`     |       | Start date filter (YYYY-MM-DD or YYYYMMDD)    |\n| `--until`     |       | End date filter (YYYY-MM-DD or YYYYMMDD)      |\n| `--timezone`  | `-z`  | Override timezone for date grouping           |\n| `--json`      |       | Emit structured JSON instead of a table       |\n| `--breakdown` | `-b`  | Show per-model token breakdown                |\n| `--pi-path`   |       | Custom path to pi-agent sessions directory    |\n| `--order`     |       | Sort order: `asc` or `desc` (default: `desc`) |\n\n### Example Output\n\nSessions are sorted by last activity:\n\n```\n┌──────────────────────────────┬────────────┬───────────┬───────────┬───────────┬────────┬─────────┐\n│ Session                      │ Input      │ Output    │ Cache Cr. │ Cache Rd. │ Cost   │ Models  │\n├──────────────────────────────┼────────────┼───────────┼───────────┼───────────┼────────┼─────────┤\n│ my-project                   │ 123,456    │ 23,456    │ 1,234     │ 9,876     │ $0.12  │ opus-4  │\n│ another-repo                 │ 345,678    │ 67,890    │ 3,456     │ 29,876    │ $0.34  │ sonnet-4│\n├──────────────────────────────┼────────────┼───────────┼───────────┼───────────┼────────┼─────────┤\n│ Total                        │ 469,134    │ 91,346    │ 4,690     │ 39,752    │ $0.46  │         │\n└──────────────────────────────┴────────────┴───────────┴───────────┴───────────┴────────┴─────────┘\n```\n\n### Session Identification\n\nSessions are identified by the project folder name from `~/.pi/agent/sessions/{project}/`.\n\nLong project names are truncated to 25 characters with `...` suffix for readability.\n\n### JSON Output\n\nUse `--json` for detailed session data:\n\n```bash\nccusage-pi session --json\n```\n\nReturns structured data including full paths:\n\n<!-- eslint-skip -->\n\n```json\n{\n  \"sessions\": [\n    {\n      \"sessionId\": \"abc123-def456\",\n      \"projectPath\": \"my-project\",\n      \"source\": \"pi-agent\",\n      \"inputTokens\": 123456,\n      \"outputTokens\": 23456,\n      \"cacheCreationTokens\": 1234,\n      \"cacheReadTokens\": 9876,\n      \"totalCost\": 0.12,\n      \"lastActivity\": \"2025-01-09\",\n      \"modelsUsed\": [\"claude-opus-4-5-20251101\"],\n      \"modelBreakdowns\": [...]\n    }\n  ],\n  \"totals\": {\n    \"inputTokens\": 123456,\n    \"outputTokens\": 23456,\n    \"cacheCreationTokens\": 1234,\n    \"cacheReadTokens\": 9876,\n    \"totalCost\": 0.12\n  }\n}\n```\n\n### Filtering Sessions\n\nFilter sessions by their last activity date:\n\n```bash\n# Sessions active today\nccusage-pi session --since 2025-01-09 --until 2025-01-09\n\n# Sessions from the past week\nccusage-pi session --since 2025-01-02\n```\n\n## Related\n\n- [ccusage](https://github.com/ryoppippi/ccusage) - Main usage analysis tool for Claude Code\n- [pi-agent](https://github.com/badlogic/pi-mono) - Alternative Claude coding agent\n"
  },
  {
    "path": "docs/guide/related-projects.md",
    "content": "# Related Projects\n\nProjects that use ccusage internally or extend its functionality:\n\n## Desktop Applications\n\n- [claude-usage-tracker-for-mac](https://github.com/penicillin0/claude-usage-tracker-for-mac) - macOS menu bar app for tracking Claude usage\n- [ClaudeCode_Dashboard](https://github.com/m-sigepon/ClaudeCode_Dashboard) - Web dashboard with charts and visualizations\n- [Ccusage App](https://github.com/EthanBarlo/ccusage-app) - Native application to display ccusage data in graphs and visualizations\n- [CCOwl](https://github.com/sivchari/ccowl) - A cross-platform status bar application that monitors Claude Code usage in real-time.\n\n## Extensions & Integrations\n\n- [ccusage Raycast Extension](https://www.raycast.com/nyatinte/ccusage) - Raycast integration for quick usage checks\n- [ccusage.nvim](https://github.com/S1M0N38/ccusage.nvim) - Track Claude Code usage in Neovim\n\n## Web Applications\n\n- [viberank](https://viberank.app) - A community-driven leaderboard for Claude Code usage. ([GitHub](https://github.com/sculptdotfun/viberank))\n\n## Contributing\n\nIf you've built something that uses ccusage, please feel free to open a pull request to add it to this list!\n"
  },
  {
    "path": "docs/guide/session-reports.md",
    "content": "# Session Reports\n\nSession 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.\n\n## Basic Usage\n\n```bash\nccusage session\n```\n\n## Specific Session Lookup\n\nQuery individual session details by providing a session ID:\n\n```bash\nccusage session --id <session-id>\n```\n\nThis is particularly useful for:\n\n- **Custom statuslines**: Integrate specific session data into your development environment\n- **Programmatic usage**: Extract session metrics for scripts and automation\n- **Detailed analysis**: Get comprehensive data about a single conversation\n\n### Examples\n\n```bash\n# Get session data in table format\nccusage session --id session-abc123-def456\n\n# Get session data as JSON for scripting\nccusage session --id session-abc123-def456 --json\n\n# Extract just the cost using jq\nccusage session --id session-abc123-def456 --json --jq '.totalCost'\n\n# Use in a custom statusline script\nCOST=$(ccusage session --id \"$SESSION_ID\" --json --jq '.totalCost')\necho \"Current session: \\$${COST}\"\n```\n\n### Session ID Format\n\nSession IDs are the actual filenames (without `.jsonl` extension) stored in Claude's data directories. They typically look like:\n\n- `session-20250621-abc123-def456`\n- `project-conversation-xyz789`\n\nYou can find session IDs by running `ccusage session` and looking for the files in your Claude data directory.\n\n## Example Output\n\n```\n╭───────────────────────────────────────────────╮\n│                                               │\n│  Claude Code Token Usage Report - By Session  │\n│                                               │\n╰───────────────────────────────────────────────╯\n\n┌────────────┬──────────────────┬────────┬─────────┬──────────────┬────────────┬──────────────┬────────────┬───────────────┐\n│ Session    │ Models           │ Input  │ Output  │ Cache Create │ Cache Read │ Total Tokens │ Cost (USD) │ Last Activity │\n├────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┼───────────────┤\n│ abc123-def │ • opus-4         │  4,512 │ 350,846 │          512 │      1,024 │      356,894 │    $156.40 │ 2025-06-21    │\n│            │ • sonnet-4       │        │         │              │            │              │            │               │\n│ ghi456-jkl │ • sonnet-4       │  2,775 │ 186,645 │          256 │        768 │      190,444 │     $98.45 │ 2025-06-20    │\n│ mno789-pqr │ • opus-4         │  1,887 │ 183,055 │          128 │        512 │      185,582 │     $81.73 │ 2025-06-19    │\n├────────────┼──────────────────┼────────┼─────────┼──────────────┼────────────┼──────────────┼────────────┼───────────────┤\n│ Total      │                  │  9,174 │ 720,546 │          896 │      2,304 │      732,920 │    $336.58 │               │\n└────────────┴──────────────────┴────────┴─────────┴──────────────┴────────────┴──────────────┴────────────┴───────────────┘\n```\n\n## Understanding Session Data\n\n### Session Identification\n\nSessions are displayed using the last two segments of their full identifier:\n\n- Full session ID: `project-20250621-session-abc123-def456`\n- Displayed as: `abc123-def`\n\n### Session Metrics\n\n- **Input/Output Tokens**: Total tokens exchanged in the conversation\n- **Cache Tokens**: Cache creation and read tokens for context efficiency\n- **Cost**: Estimated USD cost for the entire conversation\n- **Last Activity**: Date of the most recent message in the session\n\n### Sorting\n\nSessions are sorted by cost (highest first) by default, making it easy to identify your most expensive conversations.\n\n## Command Options\n\n### Session ID Lookup\n\nGet detailed information about a specific session:\n\n```bash\n# Query a specific session by ID\nccusage session --id <session-id>\n\n# Get JSON output for a specific session\nccusage session --id <session-id> --json\n\n# Short form using -i flag\nccusage session -i <session-id>\n```\n\n**Use cases:**\n\n- Building custom statuslines that show current session costs\n- Creating scripts that monitor specific conversation expenses\n- Debugging or analyzing individual conversation patterns\n- Integrating session data into development workflows\n\n### Date Filtering\n\nFilter sessions by their last activity date:\n\n```bash\n# Show sessions active since May 25th\nccusage session --since 20250525\n\n# Show sessions active in a specific date range\nccusage session --since 20250520 --until 20250530\n\n# Show only recent sessions (last week)\nccusage session --since $(date -d '7 days ago' +%Y%m%d)\n```\n\n### Cost Calculation Modes\n\n```bash\n# Use pre-calculated costs when available (default)\nccusage session --mode auto\n\n# Always calculate costs from tokens\nccusage session --mode calculate\n\n# Only show pre-calculated costs\nccusage session --mode display\n```\n\n### Model Breakdown\n\nSee per-model cost breakdown within each session:\n\n```bash\nccusage session --breakdown\n```\n\nExample with breakdown:\n\n```\n┌────────────┬──────────────────┬────────┬─────────┬────────────┬───────────────┐\n│ Session    │ Models           │ Input  │ Output  │ Cost (USD) │ Last Activity │\n├────────────┼──────────────────┼────────┼─────────┼────────────┼───────────────┤\n│ abc123-def │ opus-4, sonnet-4 │  4,512 │ 350,846 │    $156.40 │ 2025-06-21    │\n├────────────┼──────────────────┼────────┼─────────┼────────────┼───────────────┤\n│   └─ opus-4│                  │  2,000 │ 200,000 │     $95.50 │               │\n├────────────┼──────────────────┼────────┼─────────┼────────────┼───────────────┤\n│   └─ sonnet-4                 │  2,512 │ 150,846 │     $60.90 │               │\n└────────────┴──────────────────┴────────┴─────────┴────────────┴───────────────┘\n```\n\n### JSON Output\n\nExport session data as JSON for further analysis:\n\n```bash\nccusage session --json\n```\n\n```json\n{\n\t\"sessions\": [\n\t\t{\n\t\t\t\"sessionId\": \"abc123-def\",\n\t\t\t\"inputTokens\": 4512,\n\t\t\t\"outputTokens\": 350846,\n\t\t\t\"cacheCreationTokens\": 512,\n\t\t\t\"cacheReadTokens\": 1024,\n\t\t\t\"totalTokens\": 356894,\n\t\t\t\"totalCost\": 156.4,\n\t\t\t\"lastActivity\": \"2025-06-21\",\n\t\t\t\"modelsUsed\": [\"opus-4\", \"sonnet-4\"],\n\t\t\t\"modelBreakdowns\": [\n\t\t\t\t{\n\t\t\t\t\t\"model\": \"opus-4\",\n\t\t\t\t\t\"inputTokens\": 2000,\n\t\t\t\t\t\"outputTokens\": 200000,\n\t\t\t\t\t\"totalCost\": 95.5\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t],\n\t\"totals\": {\n\t\t\"inputTokens\": 9174,\n\t\t\"outputTokens\": 720546,\n\t\t\"totalCost\": 336.58\n\t}\n}\n```\n\n### Offline Mode\n\nUse cached pricing data without network access:\n\n```bash\nccusage session --offline\n# or short form:\nccusage session -O\n```\n\n## Analysis Use Cases\n\n### Identify Expensive Conversations\n\nSession reports help you understand which conversations are most costly:\n\n```bash\nccusage session\n```\n\nLook at the top sessions to understand:\n\n- Which types of conversations cost the most\n- Whether long coding sessions or research tasks are more expensive\n- How model choice (Opus vs Sonnet) affects costs\n\n### Track Conversation Patterns\n\n```bash\n# See recent conversation activity\nccusage session --since 20250615\n\n# Compare different time periods\nccusage session --since 20250601 --until 20250615  # First half of month\nccusage session --since 20250616 --until 20250630  # Second half of month\n```\n\n### Model Usage Analysis\n\n```bash\n# See which models you use in different conversations\nccusage session --breakdown\n```\n\nThis helps understand:\n\n- Whether you prefer Opus for complex tasks\n- If Sonnet is sufficient for routine work\n- How model mixing affects total costs\n\n### Budget Optimization\n\n```bash\n# Export data for spreadsheet analysis\nccusage session --json > sessions.json\n\n# Find sessions above a certain cost threshold\nccusage session --json | jq '.sessions[] | select(.totalCost > 50)'\n```\n\n## Tips for Session Analysis\n\n### 1. Cost Context Understanding\n\nSession costs help you understand:\n\n- **Conversation Value**: High-cost sessions should provide proportional value\n- **Efficiency Patterns**: Some conversation styles may be more token-efficient\n- **Model Selection**: Whether your model choices align with task complexity\n\n### 2. Usage Optimization\n\nUse session data to:\n\n- **Identify expensive patterns**: What makes some conversations cost more?\n- **Optimize conversation flow**: Break long sessions into smaller focused chats\n- **Choose appropriate models**: Use Sonnet for simpler tasks, Opus for complex ones\n\n### 3. Budget Planning\n\nSession analysis helps with:\n\n- **Conversation budgeting**: Understanding typical session costs\n- **Usage forecasting**: Predicting monthly costs based on session patterns\n- **Value assessment**: Ensuring expensive sessions provide good value\n\n### 4. Comparative Analysis\n\nCompare sessions to understand:\n\n- **Task types**: Coding vs writing vs research costs\n- **Model effectiveness**: Whether Opus provides value over Sonnet\n- **Time patterns**: Whether longer sessions are more or less efficient\n\n## Responsive Display\n\nSession reports adapt to your terminal width:\n\n- **Wide terminals (≥100 chars)**: Shows all columns including cache metrics\n- **Narrow terminals (<100 chars)**: Compact mode with essential columns (Session, Models, Input, Output, Cost, Last Activity)\n\nWhen in compact mode, ccusage displays a message explaining how to see the full data.\n\n## Related Commands\n\n- [Daily Reports](/guide/daily-reports) - Usage aggregated by date\n- [Monthly Reports](/guide/monthly-reports) - Monthly summaries\n- [Blocks Reports](/guide/blocks-reports) - 5-hour billing windows\n- [Live Monitoring](/guide/live-monitoring) - Real-time session tracking\n\n## Next Steps\n\nAfter analyzing session patterns, consider:\n\n1. [Blocks Reports](/guide/blocks-reports) to understand timing within 5-hour windows\n2. [Live Monitoring](/guide/live-monitoring) to track active conversations in real-time\n3. [Daily Reports](/guide/daily-reports) to see how session patterns vary by day\n"
  },
  {
    "path": "docs/guide/sponsors.md",
    "content": "# Sponsors\n\nSupport ccusage development by becoming a sponsor! Your contribution helps maintain and improve this tool.\n\n## Featured Sponsor\n\nCheck out [ccusage: The Claude Code cost scorecard that went viral](https://www.youtube.com/watch?v=Ak6qpQ5qdgk)\n\n<p align=\"center\">\n    <a href=\"https://www.youtube.com/watch?v=Ak6qpQ5qdgk\">\n        <img src=\"/ccusage_thumbnail.png\" alt=\"ccusage: The Claude Code cost scorecard that went viral\" width=\"600\">\n    </a>\n</p>\n\n<p align=\"center\">\n    <a href=\"https://github.com/sponsors/ryoppippi\">\n        <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/sponsors@main/sponsors.svg\">\n    </a>\n</p>\n\n## How to Sponsor\n\nVisit [GitHub Sponsors - @ryoppippi](https://github.com/sponsors/ryoppippi) to support the development of ccusage and other open source projects.\n\n## Star History\n\n<a href=\"https://www.star-history.com/#ryoppippi/ccusage&Date\">\n    <picture>\n        <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=ryoppippi/ccusage&type=Date&theme=dark\" />\n        <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=ryoppippi/ccusage&type=Date\" />\n        <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=ryoppippi/ccusage&type=Date\" />\n    </picture>\n</a>\n"
  },
  {
    "path": "docs/guide/statusline.md",
    "content": "# Statusline Integration (Beta) 🚀\n\nDisplay real-time usage statistics in your Claude Code status line.\n\n## Overview\n\nThe `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:\n\n- 💬 **Current session cost** - Cost for your active conversation session\n- 💰 **Today's total cost** - Your cumulative spending for the current day\n- 🚀 **Current session block** - Cost and time remaining in your active 5-hour billing block\n- 🔥 **Burn rate** - Token consumption rate with visual indicators\n- 🤖 **Active model** - The Claude model you're currently using\n\n## Setup\n\n### Configure settings.json\n\nAdd this to your `~/.claude/settings.json` or `~/.config/claude/settings.json`:\n\n::: code-group\n\n```json [bun x (Recommended)]\n{\n\t\"statusLine\": {\n\t\t\"type\": \"command\",\n\t\t\"command\": \"bun x ccusage statusline\",\n\t\t\"padding\": 0\n\t}\n}\n```\n\n```json [claude x]\n{\n\t\"statusLine\": {\n\t\t\"type\": \"command\",\n\t\t\"command\": \"BUN_BE_BUN=1 claude x ccusage statusline\",\n\t\t\"padding\": 0\n\t}\n}\n```\n\n```json [npx]\n{\n\t\"statusLine\": {\n\t\t\"type\": \"command\",\n\t\t\"command\": \"npx -y ccusage statusline\",\n\t\t\"padding\": 0\n\t}\n}\n```\n\n:::\n\n::: tip claude x option\nThe `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.\n:::\n\nBy default, statusline uses **offline mode** with cached pricing data for optimal performance.\n\n### Online Mode (Optional)\n\nIf you need the latest pricing data from LiteLLM API, you can explicitly enable online mode:\n\n```json\n{\n\t\"statusLine\": {\n\t\t\"type\": \"command\",\n\t\t\"command\": \"bun x ccusage statusline --no-offline\", // Fetches latest pricing from API\n\t\t\"padding\": 0\n\t}\n}\n```\n\n### With Visual Burn Rate (Optional)\n\nYou can enhance the burn rate display with visual indicators:\n\n```json\n{\n\t\"statusLine\": {\n\t\t\"type\": \"command\",\n\t\t\"command\": \"bun x ccusage statusline --visual-burn-rate emoji\", // Add emoji indicators\n\t\t\"padding\": 0\n\t}\n}\n```\n\nSee [Visual Burn Rate](#visual-burn-rate) section for all available options.\n\n### With Cost Source Options (Optional)\n\nYou can control how session costs are calculated and displayed:\n\n```json\n{\n\t\"statusLine\": {\n\t\t\"type\": \"command\",\n\t\t\"command\": \"bun x ccusage statusline --cost-source both\", // Show both CC and ccusage costs\n\t\t\"padding\": 0\n\t}\n}\n```\n\nSee [Cost Source Options](#cost-source-options) section for all available modes.\n\n## Output Format\n\nThe statusline displays a compact, single-line summary:\n\n```\n🤖 Opus | 💰 $0.23 session / $1.23 today / $0.45 block (2h 45m left) | 🔥 $0.12/hr | 🧠 25,000 (12%)\n```\n\nWhen using `--cost-source both`, the session cost shows both Claude Code and ccusage calculations:\n\n```\n🤖 Opus | 💰 ($0.25 cc / $0.23 ccusage) session / $1.23 today / $0.45 block (2h 45m left) | 🔥 $0.12/hr | 🧠 25,000 (12%)\n```\n\n### Components Explained\n\n- **Model** (`🤖 Opus`): Currently active Claude model\n- **Session Cost** (`💰 $0.23 session`): Cost for the current conversation session (see [Cost Source Options](#cost-source-options) for different calculation modes)\n- **Today's Cost** (`$1.23 today`): Total cost for the current day across all sessions\n- **Session Block** (`$0.45 block (2h 45m left)`): Current 5-hour block cost with remaining time\n- **Burn Rate** (`🔥 $0.12/hr`): Cost burn rate per hour with color-coded indicators:\n  - Green text: Normal (< 2,000 tokens/min)\n  - Yellow text: Moderate (2,000-5,000 tokens/min)\n  - Red text: High (> 5,000 tokens/min)\n  - Optional visual status indicators (see [Visual Burn Rate](#visual-burn-rate))\n- **Context Usage** (`🧠 25,000 (12%)`): Shows input tokens with percentage of context limit:\n  - Green text: Low usage (< 50% by default)\n  - Yellow text: Medium usage (50-80% by default)\n  - Red text: High usage (> 80% by default)\n  - Uses Claude Code's [`context_window` data](https://code.claude.com/docs/en/statusline) when available for accurate token counts\n\nWhen no active block exists:\n\n```\n🤖 Opus | 💰 $0.00 session / $0.00 today / No active block\n```\n\n## Technical Details\n\nThe statusline command:\n\n- Reads session information from stdin (provided by Claude Code hooks)\n- Identifies the active 5-hour billing block\n- Calculates real-time burn rates and projections\n- Outputs a single line suitable for status bar display\n- **Uses offline mode by default** for instant response times without network dependencies\n- Can be configured to use online mode with `--no-offline` for latest pricing data\n\n## Beta Notice\n\n⚠️ This feature is currently in **beta**. More customization options and features are coming soon:\n\n- Custom format templates\n- Configurable burn rate thresholds\n- Additional metrics display options\n- Session-specific cost tracking\n\n### Cost Source Options\n\nThe `--cost-source` option controls how session costs are calculated and displayed:\n\n**Available modes:**\n\n- `auto` (default): Prefer Claude Code's pre-calculated cost when available, fallback to ccusage calculation\n- `ccusage`: Always calculate costs using ccusage's token-based calculation with LiteLLM pricing\n- `cc`: Always use Claude Code's pre-calculated cost from session data\n- `both`: Display both Claude Code and ccusage costs side by side for comparison\n\n**Command-line usage:**\n\n```bash\n# Default auto mode\nbun x ccusage statusline\n\n# Always use ccusage calculation\nbun x ccusage statusline --cost-source ccusage\n\n# Always use Claude Code cost\nbun x ccusage statusline --cost-source cc\n\n# Show both costs for comparison\nbun x ccusage statusline --cost-source both\n```\n\n**Settings.json configuration:**\n\n```json\n{\n\t\"statusLine\": {\n\t\t\"type\": \"command\",\n\t\t\"command\": \"bun x ccusage statusline --cost-source both\",\n\t\t\"padding\": 0\n\t}\n}\n```\n\n**When to use each mode:**\n\n- **`auto`**: Best for most users, provides accurate costs with fallback reliability\n- **`ccusage`**: When you want consistent calculation methods across all ccusage commands\n- **`cc`**: When you trust Claude Code's cost calculations and want minimal processing\n- **`both`**: For debugging cost discrepancies or comparing calculation methods\n\n**Output differences:**\n\n- **Single cost modes** (`auto`, `ccusage`, `cc`): `💰 $0.23 session`\n- **Both mode**: `💰 ($0.25 cc / $0.23 ccusage) session`\n\n## Configuration\n\n### Context Usage Thresholds\n\nYou can customize the context usage color thresholds using command-line options or configuration files:\n\n- `--context-low-threshold` - Percentage below which context usage is shown in green (default: 50)\n- `--context-medium-threshold` - Percentage below which context usage is shown in yellow (default: 80)\n\n**Validation and Safety Features:**\n\n- Values are automatically validated to be integers in the 0-100 range\n- The `LOW` threshold must be less than the `MEDIUM` threshold\n- Invalid configurations will show clear error messages\n\n**Command-line usage:**\n\n```bash\nbun x ccusage statusline --context-low-threshold 60 --context-medium-threshold 90\n```\n\n**Configuration file usage:**\nYou can also set these options in your configuration file. See the [Configuration Guide](/guide/configuration) for more details.\n\nWith these settings:\n\n- Green: < 60%\n- Yellow: 60-90%\n- Red: > 90%\n\n**Example usage in Claude Code settings:**\n\n```json\n{\n\t\"command\": \"bun x ccusage statusline --context-low-threshold 60 --context-medium-threshold 90\",\n\t\"timeout\": 5000\n}\n```\n\n### Visual Burn Rate\n\nYou can enhance the burn rate display with visual status indicators using the `--visual-burn-rate` option:\n\n```bash\n# Add to your settings.json command\nbun x ccusage statusline --visual-burn-rate emoji\n```\n\n**Available options:**\n\n- `off` (default): No visual indicators, only colored text\n- `emoji`: Add emoji indicators (🟢/⚠️/🚨)\n- `text`: Add text status in parentheses (Normal/Moderate/High)\n- `emoji-text`: Combine both emoji and text indicators\n\n**Examples:**\n\n```bash\n# Default (off)\n🔥 $0.12/hr\n\n# With emoji\n🔥 $0.12/hr 🟢\n\n# With text\n🔥 $0.12/hr (Normal)\n\n# With both emoji and text\n🔥 $0.12/hr 🟢 (Normal)\n```\n\n**Status Indicators:**\n\n- 🟢 Normal (Green)\n- ⚠️ Moderate (Yellow)\n- 🚨 High (Red)\n\n## Troubleshooting\n\n### No Output Displayed\n\nIf the statusline doesn't show:\n\n1. Verify `ccusage` is in your PATH\n2. Check Claude Code logs for any errors\n3. Ensure you have valid usage data in your Claude data directory\n\n### Incorrect Costs\n\nIf costs seem incorrect:\n\n- The command uses the same cost calculation as other ccusage commands\n- Verify with `ccusage daily` or `ccusage blocks` for detailed breakdowns\n\n## Related Commands\n\n- [`blocks`](./blocks-reports.md) - Detailed 5-hour billing block analysis\n- [`daily`](./daily-reports.md) - Daily usage reports\n- [`session`](./session-reports.md) - Session-based usage analysis\n"
  },
  {
    "path": "docs/guide/weekly-reports.md",
    "content": "# Weekly Reports\n\nWeekly 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.\n\n## Basic Usage\n\nShow all weekly usage:\n\n```bash\nccusage weekly\n```\n\n## Example Output\n\n```\n┌────────────────┬──────────────────┬────────┬─────────┬─────────────┬────────────┬──────────────┬────────────┐\n│ Week           │ Models           │ Input  │ Output  │ Cache Create│ Cache Read │ Total Tokens │ Cost (USD) │\n├────────────────┼──────────────────┼────────┼─────────┼─────────────┼────────────┼──────────────┼────────────┤\n│ 2025-06-16     │ • opus-4         │  1,234 │ 156,789 │       2,048 │      4,096 │      164,167 │     $87.56 │\n│                │ • sonnet-4       │        │         │             │            │              │            │\n├────────────────┼──────────────────┼────────┼─────────┼─────────────┼────────────┼──────────────┼────────────┤\n│ 2025-06-23     │ • sonnet-4       │  2,456 │ 234,567 │       3,072 │      6,144 │      246,239 │    $104.33 │\n├────────────────┼──────────────────┼────────┼─────────┼─────────────┼────────────┼──────────────┼────────────┤\n│ 2025-06-30     │ • opus-4         │  3,789 │ 345,678 │       4,096 │      8,192 │      361,755 │    $156.78 │\n│                │ • sonnet-4       │        │         │             │            │              │            │\n└────────────────┴──────────────────┴────────┴─────────┴─────────────┴────────────┴──────────────┴────────────┘\n```\n\n## Understanding the Columns\n\nThe columns are identical to daily reports but aggregated by week:\n\n- **Week**: Start date of the week (configurable)\n- **Models**: All Claude models used during the week\n- **Input/Output**: Total tokens for the week\n- **Cache Create/Read**: Cache token usage\n- **Total Tokens**: Sum of all token types\n- **Cost (USD)**: Estimated cost for the week\n\n## Command Options\n\n### Week Start Day\n\nConfigure which day starts the week:\n\n```bash\n# Start week on Sunday (default)\nccusage weekly --start-of-week sunday\n\n# Start week on Monday\nccusage weekly --start-of-week monday\nccusage weekly -w monday\n\n# Other options: tuesday, wednesday, thursday, friday, saturday\n```\n\n### Date Filtering\n\nFilter by date range:\n\n```bash\n# Show specific period\nccusage weekly --since 20250601 --until 20250630\n\n# Show last 4 weeks\nccusage weekly --since 20250501\n```\n\n### Sort Order\n\nControl the order of weeks:\n\n```bash\n# Newest weeks first (default)\nccusage weekly --order desc\n\n# Oldest weeks first\nccusage weekly --order asc\n```\n\n### Model Breakdown\n\nSee per-model weekly costs:\n\n```bash\nccusage weekly --breakdown\n```\n\n```\n┌────────────────┬──────────────────┬────────┬─────────┬────────────┐\n│ Week           │ Models           │ Input  │ Output  │ Cost (USD) │\n├────────────────┼──────────────────┼────────┼─────────┼────────────┤\n│ 2025-06-16     │ opus-4, sonnet-4 │  1,234 │ 156,789 │     $87.56 │\n├────────────────┼──────────────────┼────────┼─────────┼────────────┤\n│   └─ opus-4    │                  │    800 │  80,000 │     $54.80 │\n├────────────────┼──────────────────┼────────┼─────────┼────────────┤\n│   └─ sonnet-4  │                  │    434 │  76,789 │     $32.76 │\n└────────────────┴──────────────────┴────────┴─────────┴────────────┘\n```\n\n### JSON Output\n\nExport weekly data as JSON:\n\n```bash\nccusage weekly --json\n```\n\n```json\n{\n\t\"weekly\": [\n\t\t{\n\t\t\t\"week\": \"2025-06-16\",\n\t\t\t\"inputTokens\": 1234,\n\t\t\t\"outputTokens\": 156789,\n\t\t\t\"cacheCreationTokens\": 2048,\n\t\t\t\"cacheReadTokens\": 4096,\n\t\t\t\"totalTokens\": 164167,\n\t\t\t\"totalCost\": 87.56,\n\t\t\t\"modelsUsed\": [\"claude-opus-4-20250514\", \"claude-sonnet-4-20250514\"],\n\t\t\t\"modelBreakdowns\": {\n\t\t\t\t\"claude-opus-4-20250514\": {\n\t\t\t\t\t\"inputTokens\": 800,\n\t\t\t\t\t\"outputTokens\": 80000,\n\t\t\t\t\t\"totalCost\": 54.8\n\t\t\t\t},\n\t\t\t\t\"claude-sonnet-4-20250514\": {\n\t\t\t\t\t\"inputTokens\": 434,\n\t\t\t\t\t\"outputTokens\": 76789,\n\t\t\t\t\t\"totalCost\": 32.76\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t],\n\t\"totals\": {\n\t\t\"inputTokens\": 7479,\n\t\t\"outputTokens\": 737034,\n\t\t\"cacheCreationTokens\": 9216,\n\t\t\"cacheReadTokens\": 18432,\n\t\t\"totalTokens\": 772161,\n\t\t\"totalCost\": 348.67\n\t}\n}\n```\n\n### Project Analysis\n\nGroup weekly usage by project:\n\n```bash\n# Show weekly usage per project\nccusage weekly --instances\n\n# Filter to specific project\nccusage weekly --project my-project\n```\n\n### Cost Calculation Modes\n\nControl cost calculation:\n\n```bash\n# Auto mode (default)\nccusage weekly --mode auto\n\n# Always calculate from tokens\nccusage weekly --mode calculate\n\n# Only use pre-calculated costs\nccusage weekly --mode display\n```\n\n### Offline Mode\n\nUse cached pricing data:\n\n```bash\nccusage weekly --offline\n```\n\n## Common Use Cases\n\n### Weekly Trends\n\n```bash\n# See usage trends over past months\nccusage weekly --since 20250401\n```\n\n### Sprint Analysis\n\n```bash\n# Track usage during 2-week sprints (Monday start)\nccusage weekly --start-of-week monday --since 20250601\n```\n\n### Budget Planning\n\n```bash\n# Export for weekly budget tracking\nccusage weekly --json > weekly-budget.json\n```\n\n### Compare Workweeks\n\n```bash\n# Monday-Friday work pattern analysis\nccusage weekly --start-of-week monday --breakdown\n```\n\n### Team Reporting\n\n```bash\n# Weekly team usage report\nccusage weekly --instances --start-of-week monday\n```\n\n## Tips\n\n1. **Week Start**: Choose a start day that aligns with your work schedule\n2. **Breakdown View**: Use `--breakdown` to identify which models drive costs\n3. **JSON Export**: Weekly JSON data is perfect for creating trend charts\n4. **Project Tracking**: Use `--instances` to track project-specific weekly usage\n\n## Related Commands\n\n- [Daily Reports](/guide/daily-reports) - Day-by-day analysis\n- [Monthly Reports](/guide/monthly-reports) - Monthly aggregates\n- [Session Reports](/guide/session-reports) - Per-conversation analysis\n- [Blocks Reports](/guide/blocks-reports) - 5-hour billing windows\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: ccusage\n  text: Claude Code Usage Analysis\n  tagline: A powerful CLI tool for analyzing Claude Code usage from local JSONL files\n  image:\n    src: /logo.svg\n    alt: ccusage logo\n  actions:\n    - theme: brand\n      text: Get Started\n      link: /guide/\n    - theme: alt\n      text: View on GitHub\n      link: https://github.com/ryoppippi/ccusage\n\nfeatures:\n  - icon: 📊\n    title: Daily Reports\n    details: View token usage and costs aggregated by date with detailed breakdowns\n    link: /guide/daily-reports\n  - icon: 📆\n    title: Weekly Reports\n    details: Track usage patterns by week with configurable start day\n    link: /guide/weekly-reports\n  - icon: 📅\n    title: Monthly Reports\n    details: Analyze usage patterns over monthly periods with cost tracking\n    link: /guide/monthly-reports\n  - icon: 💬\n    title: Session Reports\n    details: Group usage by conversation sessions for detailed analysis\n    link: /guide/session-reports\n  - icon: ⏰\n    title: 5-Hour Blocks\n    details: Track usage within Claude's billing windows with active monitoring\n    link: /guide/blocks-reports\n  - icon: 🤖\n    title: Model Tracking\n    details: See which Claude models you're using (Opus, Sonnet, etc.)\n  - icon: 📋\n    title: Enhanced Display\n    details: Beautiful tables with responsive layout and smart formatting\n  - icon: 📄\n    title: JSON Output\n    details: Export data in structured JSON format for programmatic use\n    link: /guide/json-output\n  - icon: 💰\n    title: Cost Analysis\n    details: Shows estimated costs in USD for each day/month/session\n  - icon: 🔄\n    title: Cache Support\n    details: Tracks cache creation and cache read tokens separately\n  - icon: 🌐\n    title: Offline Mode\n    details: Use pre-cached pricing data without network connectivity\n  - icon: 🔌\n    title: MCP Integration\n    details: Built-in Model Context Protocol server for tool integration\n    link: /guide/mcp-server\n---\n\n<div style=\"text-align: center; margin: 2rem 0;\">\n  <h2 style=\"margin-bottom: 1rem;\">Support ccusage</h2>\n  <p style=\"margin-bottom: 1.5rem;\">If you find ccusage helpful, please consider sponsoring the development!</p>\n\n  <h3 style=\"margin-bottom: 1rem;\">Featured Sponsor</h3>\n  <p style=\"margin-bottom: 1rem;\">Check out <a href=\"https://www.youtube.com/watch?v=Ak6qpQ5qdgk\" target=\"_blank\">ccusage: The Claude Code cost scorecard that went viral</a></p>\n  <a href=\"https://www.youtube.com/watch?v=Ak6qpQ5qdgk\" target=\"_blank\">\n    <img src=\"/ccusage_thumbnail.png\" alt=\"ccusage: The Claude Code cost scorecard that went viral\" style=\"max-width: 600px; height: auto;\">\n  </a>\n\n  <div style=\"margin-top: 2rem;\">\n    <a href=\"https://github.com/sponsors/ryoppippi\" target=\"_blank\">\n      <img src=\"https://cdn.jsdelivr.net/gh/ryoppippi/sponsors@main/sponsors.svg\" alt=\"Sponsors\" style=\"max-width: 100%; height: auto;\">\n    </a>\n  </div>\n</div>\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n\t\"name\": \"@ccusage/docs\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"private\": true,\n\t\"description\": \"Documentation for ccusage\",\n\t\"engines\": {\n\t\t\"node\": \">=20.19.4\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"pnpm run docs:api && cp ../apps/ccusage/config-schema.json public/config-schema.json && vitepress build\",\n\t\t\"deploy\": \"wrangler deploy\",\n\t\t\"dev\": \"pnpm run docs:api && cp ../apps/ccusage/config-schema.json public/config-schema.json && vitepress dev\",\n\t\t\"docs:api\": \"bun ./update-api-index.ts\",\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"preview\": \"vitepress preview\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"@ryoppippi/vite-plugin-cloudflare-redirect\": \"catalog:docs\",\n\t\t\"@types/bun\": \"catalog:types\",\n\t\t\"@types/react\": \"catalog:types\",\n\t\t\"@typescript/native-preview\": \"catalog:types\",\n\t\t\"ccusage\": \"workspace:^\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"eslint-plugin-format\": \"catalog:lint\",\n\t\t\"tinyglobby\": \"catalog:runtime\",\n\t\t\"typedoc\": \"catalog:docs\",\n\t\t\"typedoc-plugin-markdown\": \"catalog:docs\",\n\t\t\"typedoc-vitepress-theme\": \"catalog:docs\",\n\t\t\"vitepress\": \"catalog:docs\",\n\t\t\"vitepress-plugin-group-icons\": \"catalog:docs\",\n\t\t\"vitepress-plugin-llms\": \"catalog:docs\",\n\t\t\"wrangler\": \"catalog:docs\"\n\t}\n}\n"
  },
  {
    "path": "docs/tsconfig.json",
    "content": "{\n\t\"extends\": \"./node_modules/ccusage/tsconfig.json\",\n\t\"compilerOptions\": {\n\t\t\"resolveJsonModule\": true,\n\t\t\"types\": [\"bun\"],\n\t\t\"allowImportingTsExtensions\": false,\n\t\t\"allowJs\": true,\n\t\t\"noEmit\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"include\": [\".vitepress/**/*\", \"**/*.ts\", \"**/*.md\"],\n\t\"exclude\": [\"node_modules\", \".vitepress/cache\", \".vitepress/dist\"]\n}\n"
  },
  {
    "path": "docs/typedoc.config.ts",
    "content": "import type { TypeDocOptions } from 'typedoc';\nimport type { PluginOptions } from 'typedoc-plugin-markdown';\nimport { globSync } from 'tinyglobby';\n\ntype TypedocConfig = TypeDocOptions & PluginOptions & { docsRoot?: string };\n\nconst entryPoints = [\n\t...globSync(\n\t\t[\n\t\t\t'./node_modules/ccusage/src/*.ts',\n\t\t\t'!./node_modules/ccusage/src/**/*.test.ts', // Exclude test files\n\t\t\t'!./node_modules/ccusage/src/_*.ts', // Exclude internal files with underscore prefix\n\t\t],\n\t\t{\n\t\t\tabsolute: false,\n\t\t\tonlyFiles: true,\n\t\t},\n\t),\n\t'./node_modules/ccusage/src/_consts.ts', // Include constants for documentation\n];\n\nexport default {\n\t// typedoc options\n\t// ref: https://typedoc.org/documents/Options.html\n\tentryPoints,\n\ttsconfig: './node_modules/ccusage/tsconfig.json',\n\tout: 'api',\n\tplugin: ['typedoc-plugin-markdown', 'typedoc-vitepress-theme'],\n\treadme: 'none',\n\texcludeInternal: true,\n\tgroupOrder: ['Variables', 'Functions', 'Class'],\n\tcategoryOrder: ['*', 'Other'],\n\tsort: ['source-order'],\n\n\t// typedoc-plugin-markdown options\n\t// ref: https://typedoc-plugin-markdown.org/docs/options\n\tentryFileName: 'index',\n\thidePageTitle: false,\n\tuseCodeBlocks: true,\n\tdisableSources: true,\n\tindexFormat: 'table',\n\tparametersFormat: 'table',\n\tinterfacePropertiesFormat: 'table',\n\tclassPropertiesFormat: 'table',\n\tpropertyMembersFormat: 'table',\n\ttypeAliasPropertiesFormat: 'table',\n\tenumMembersFormat: 'table',\n\n\t// typedoc-vitepress-theme options\n\t// ref: https://typedoc-plugin-markdown.org/plugins/vitepress/options\n\tdocsRoot: '.',\n} satisfies TypedocConfig;\n"
  },
  {
    "path": "docs/update-api-index.ts",
    "content": "#!/usr/bin/env bun\n/* eslint-disable antfu/no-top-level-await */\n/* eslint-disable no-console */\n\n/**\n * Post-processing script to update API index with module descriptions\n */\n\nimport { join } from 'node:path';\nimport process from 'node:process';\n\nconst descriptions = {\n\t'\\\\_consts': 'Internal constants (not exported in public API)',\n\t'calculate-cost': 'Cost calculation utilities for usage data analysis',\n\t'data-loader': 'Data loading utilities for Claude Code usage analysis',\n\tdebug: 'Debug utilities for cost calculation validation',\n\tindex: 'Main entry point for ccusage CLI tool',\n\tlogger: 'Logging utilities for the ccusage application',\n\t'pricing-fetcher': 'Model pricing data fetcher for cost calculations',\n} as const;\n\nasync function updateApiIndex() {\n\tconst apiIndexPath = join(import.meta.dirname, 'api', 'index.md');\n\n\ttry {\n\t\tlet content = await Bun.file(apiIndexPath).text();\n\n\t\t// Replace empty descriptions with actual ones\n\t\tfor (const [module, description] of Object.entries(descriptions)) {\n\t\t\tlet linkPath = `${module}/index.md`;\n\t\t\t// Special case for _consts which links to consts/\n\t\t\tif (module === '\\\\_consts') {\n\t\t\t\tlinkPath = 'consts/index.md';\n\t\t\t}\n\n\t\t\tconst oldPattern = new RegExp(\n\t\t\t\t`\\\\|\\\\s*\\\\[${module}\\\\]\\\\(${linkPath}\\\\)\\\\s*\\\\|\\\\s*-\\\\s*\\\\|`,\n\t\t\t\t'g',\n\t\t\t);\n\t\t\tcontent = content.replace(oldPattern, `| [${module}](${linkPath}) | ${description} |`);\n\t\t}\n\n\t\tawait Bun.write(apiIndexPath, content);\n\t\tconsole.log('✅ Updated API index with module descriptions');\n\t} catch (error) {\n\t\tconsole.error('❌ Failed to update API index:', error);\n\t\tprocess.exit(1);\n\t}\n}\n\nasync function updateConstsPage() {\n\tconst constsIndexPath = join(import.meta.dirname, 'api', 'consts', 'index.md');\n\n\ttry {\n\t\tlet content = await Bun.file(constsIndexPath).text();\n\n\t\t// Add note about constants not being exported (only if not already present)\n\t\tconst noteText =\n\t\t\t'> **Note**: These constants are internal implementation details and are not exported in the public API. They are documented here for reference purposes only.';\n\n\t\tif (!content.includes(noteText)) {\n\t\t\tconst oldHeader = '# \\\\_consts';\n\t\t\tconst newHeader = `# \\\\_consts\n\n${noteText}`;\n\n\t\t\tcontent = content.replace(oldHeader, newHeader);\n\t\t}\n\n\t\tawait Bun.write(constsIndexPath, content);\n\t\tconsole.log('✅ Updated constants page with disclaimer');\n\t} catch (error) {\n\t\tconsole.error('❌ Failed to update constants page:', error);\n\t\t// Don't exit here as this is optional\n\t}\n}\n\nif (import.meta.main) {\n\tawait Bun.$`bun -b typedoc --excludeInternal --options ./typedoc.config.ts`;\n\tawait updateApiIndex();\n\tawait updateConstsPage();\n}\n"
  },
  {
    "path": "docs/wrangler.jsonc",
    "content": "{\n\t\"$schema\": \"../node_modules/wrangler/config-schema.json\",\n\t\"name\": \"ccusage-guide\",\n\t\"compatibility_date\": \"2025-07-21\",\n\t\"build\": {\n\t\t\"command\": \"pnpm run build\",\n\t},\n\t\"assets\": {\n\t\t\"directory\": \".vitepress/dist/\",\n\t\t\"not_found_handling\": \"404-page\",\n\t},\n}\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\nexport default ryoppippi({\n\ttype: 'lib',\n\tstylistic: false,\n\tignores: ['apps', 'packages', 'docs', '.claude/settings.local.json'],\n});\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"Usage analysis tool for Claude Code\";\n\n  inputs.nixpkgs.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\";\n\n  outputs = { nixpkgs, ... }:\n    let\n      systems = [ \"x86_64-linux\" \"aarch64-linux\" \"x86_64-darwin\" \"aarch64-darwin\" ];\n      forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});\n    in {\n      devShells = forAllSystems (pkgs: {\n        default = pkgs.mkShellNoCC {\n          buildInputs = with pkgs; [\n            # Package manager\n            pnpm_10\n\n            # Development tools\n            typos\n            typos-lsp\n            jq\n            git\n            gh\n          ];\n\n          shellHook = ''\n            # Install dependencies only if node_modules/.pnpm/lock.yaml is older than pnpm-lock.yaml\n            if [ ! -f node_modules/.pnpm/lock.yaml ] || [ pnpm-lock.yaml -nt node_modules/.pnpm/lock.yaml ]; then\n              echo \"📦 Installing dependencies...\"\n              pnpm install --frozen-lockfile\n            fi\n          '';\n        };\n      });\n    };\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"ccusage-monorepo\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"private\": true,\n\t\"workspaces\": [\n\t\t\"apps/*\",\n\t\t\"docs\"\n\t],\n\t\"packageManager\": \"pnpm@10.30.1\",\n\t\"engines\": {\n\t\t\"runtime\": [\n\t\t\t{\n\t\t\t\t\"name\": \"node\",\n\t\t\t\t\"version\": \"^24.13.0\",\n\t\t\t\t\"onFail\": \"download\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"name\": \"bun\",\n\t\t\t\t\"version\": \"^1.3.9\",\n\t\t\t\t\"onFail\": \"download\"\n\t\t\t}\n\t\t]\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"pnpm run --filter '*' build\",\n\t\t\"docs:dev\": \"pnpm run --filter docs dev\",\n\t\t\"format\": \"pnpm --aggregate-output /^format:/\",\n\t\t\"format:oxfmt\": \"oxfmt --write .\",\n\t\t\"format:root\": \"pnpm run lint:root --fix\",\n\t\t\"format:submodules\": \"pnpm --parallel -r --aggregate-output /^format/\",\n\t\t\"preinstall\": \"npx only-allow pnpm\",\n\t\t\"lint\": \"pnpm --no-bail --aggregate-output /^lint:/\",\n\t\t\"lint:oxfmt\": \"oxfmt --check .\",\n\t\t\"lint:root\": \"eslint --cache eslint.config.js .\",\n\t\t\"lint:submodules\": \"pnpm --parallel -r --no-bail /^lint/\",\n\t\t\"prepare\": \"pnpm run /^prepare:/\",\n\t\t\"prepare:git\": \"git config --local core.hooksPath .githooks\",\n\t\t\"prerelease\": \"pnpm run --filter '*' prerelease\",\n\t\t\"release\": \"pnpm bumpp -r\",\n\t\t\"postrelease\": \"git checkout ./**/package.json package.json\",\n\t\t\"test\": \"pnpm run --filter '*' test\",\n\t\t\"typecheck\": \"pnpm run --filter '*' typecheck\"\n\t},\n\t\"dependencies\": {},\n\t\"devDependencies\": {\n\t\t\"@gunshi/docs\": \"catalog:llm-docs\",\n\t\t\"@praha/byethrow-docs\": \"catalog:llm-docs\",\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"@typescript/native-preview\": \"catalog:types\",\n\t\t\"bumpp\": \"catalog:release\",\n\t\t\"changelogithub\": \"catalog:release\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"lint-staged\": \"catalog:release\",\n\t\t\"oxfmt\": \"catalog:lint\",\n\t\t\"pkg-pr-new\": \"catalog:release\"\n\t},\n\t\"lint-staged\": {\n\t\t\"*\": [\n\t\t\t\"pnpm run format\"\n\t\t]\n\t}\n}\n"
  },
  {
    "path": "packages/internal/CLAUDE.md",
    "content": "# CLAUDE.md - Internal Package\n\nThis package contains shared internal utilities for the ccusage monorepo.\n\n## Package Overview\n\n**Name**: `@ccusage/internal`\n**Description**: Shared internal utilities for ccusage toolchain\n**Type**: Internal library (private package)\n\n## Important Notes\n\n**CRITICAL**: This is an internal package that gets bundled into the final applications. Therefore:\n\n- **Always add this package as a `devDependency`** in apps that use it, NOT as a regular dependency\n- Apps in this monorepo (ccusage, mcp, codex) are bundled CLIs, so all their runtime dependencies should be in `devDependencies`\n- The bundler will include the code from this package in the final output\n\n## Available Exports\n\n**Utilities:**\n\n- `./pricing` - LiteLLM pricing fetcher and utilities\n- `./pricing-fetch-utils` - Pricing fetch helper functions\n- `./logger` - Logger factory using consola with LOG_LEVEL support\n- `./format` - Number formatting utilities (formatTokens, formatCurrency)\n- `./constants` - Shared constants (DEFAULT_LOCALE, MILLION)\n\n## Development Commands\n\n- `pnpm run test` - Run tests\n- `pnpm run lint` - Lint code\n- `pnpm run format` - Format and auto-fix code\n- `pnpm typecheck` - Type check with TypeScript\n\n## Adding New Utilities\n\nWhen adding new shared utilities:\n\n1. Create the utility file in `src/`\n2. Add the export to `package.json` exports field\n3. Import in consuming apps as `devDependencies`:\n   <!-- eslint-skip -->\n   ```json\n   \"devDependencies\": {\n     \"@ccusage/internal\": \"workspace:*\"\n   }\n   ```\n4. Use the utility:\n   ```typescript\n   import { createLogger } from '@ccusage/internal/logger';\n   ```\n\n## Dependencies\n\nThis package has minimal runtime dependencies that get bundled:\n\n- `@praha/byethrow` - Functional error handling\n- `consola` - Logging\n- `valibot` - Schema validation\n\n## Pricing Implementation Notes\n\n### Tiered Pricing Support\n\nLiteLLM supports tiered pricing for large context window models. Not all models use tiered pricing:\n\n**Models WITH tiered pricing:**\n\n- **Claude/Anthropic models**: 200k token threshold\n  - Fields: `input_cost_per_token_above_200k_tokens`, `output_cost_per_token_above_200k_tokens`\n  - Cache fields: `cache_creation_input_token_cost_above_200k_tokens`, `cache_read_input_token_cost_above_200k_tokens`\n  - ✅ Currently implemented in cost calculation logic\n\n- **Gemini models**: 128k token threshold\n  - Fields: `input_cost_per_token_above_128k_tokens`, `output_cost_per_token_above_128k_tokens`\n  - ⚠️ Schema supports these fields but calculation logic NOT implemented\n  - Would require different threshold handling if Gemini support is added\n\n**Models WITHOUT tiered pricing:**\n\n- **GPT/OpenAI models**: Flat rate pricing (no token-based tiers)\n  - Note: OpenAI has \"tier levels\" but these are for API rate limits, not pricing\n\n### ⚠️ IMPORTANT for Future Development\n\nWhen adding support for new models:\n\n1. **Check if the model has tiered pricing** in LiteLLM's schema\n2. **Verify the threshold value** (200k for Claude, 128k for Gemini, etc.)\n3. **Update calculation logic** if threshold differs from currently implemented 200k\n4. **Add comprehensive tests** for boundary conditions at the threshold\n5. **Document the pricing structure** in relevant CLAUDE.md files\n6. **If cache-specific rates are missing**, fall back to the corresponding input rates (base and above-threshold) to avoid under-charging cached tokens\n\nThe current implementation in `pricing.ts` only handles 200k threshold. Adding models with different thresholds would require refactoring the `calculateTieredCost` helper function.\n\n## Code Style\n\nFollow the same conventions as the main ccusage package:\n\n- Use `.ts` extensions for local imports\n- Prefer `@praha/byethrow Result` type over try-catch\n- Only export what's actually used by other modules\n- Use vitest in-source testing with `if (import.meta.vitest != null)` blocks\n"
  },
  {
    "path": "packages/internal/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\n/** @type {import('eslint').Linter.FlatConfig[]} */\nconst config = ryoppippi(\n\t{\n\t\ttype: 'lib',\n\t\tstylistic: false,\n\t},\n\t{\n\t\trules: {\n\t\t\t'test/no-importing-vitest-globals': 'error',\n\t\t},\n\t},\n);\n\nexport default config;\n"
  },
  {
    "path": "packages/internal/package.json",
    "content": "{\n\t\"name\": \"@ccusage/internal\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"private\": true,\n\t\"description\": \"Shared internal utilities for ccusage toolchain\",\n\t\"exports\": {\n\t\t\"./pricing\": \"./src/pricing.ts\",\n\t\t\"./pricing-fetch-utils\": \"./src/pricing-fetch-utils.ts\",\n\t\t\"./logger\": \"./src/logger.ts\",\n\t\t\"./format\": \"./src/format.ts\",\n\t\t\"./constants\": \"./src/constants.ts\"\n\t},\n\t\"scripts\": {\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"test\": \"TZ=UTC vitest\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"dependencies\": {\n\t\t\"@praha/byethrow\": \"catalog:runtime\",\n\t\t\"consola\": \"catalog:runtime\",\n\t\t\"valibot\": \"catalog:runtime\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"fs-fixture\": \"catalog:testing\",\n\t\t\"vitest\": \"catalog:testing\"\n\t}\n}\n"
  },
  {
    "path": "packages/internal/src/constants.ts",
    "content": "/**\n * Default locale for date formatting (en-CA provides YYYY-MM-DD ISO format)\n * @constant\n */\nexport const DEFAULT_LOCALE = 'en-CA';\n\n/**\n * Common million constant for token calculations\n * @constant\n */\nexport const MILLION = 1_000_000;\n"
  },
  {
    "path": "packages/internal/src/format.ts",
    "content": "/**\n * Format a number as tokens with locale-specific formatting\n * @param value - Token count to format\n * @returns Formatted token string\n */\nexport function formatTokens(value: number): string {\n\treturn new Intl.NumberFormat('en-US').format(Math.round(value));\n}\n\n/**\n * Format a number as USD currency\n * @param value - Amount in USD\n * @param locale - Locale for formatting (default: 'en-US')\n * @returns Formatted currency string\n */\nexport function formatCurrency(value: number, locale?: string): string {\n\treturn new Intl.NumberFormat(locale ?? 'en-US', {\n\t\tstyle: 'currency',\n\t\tcurrency: 'USD',\n\t\tminimumFractionDigits: 4,\n\t\tmaximumFractionDigits: 4,\n\t}).format(value);\n}\n"
  },
  {
    "path": "packages/internal/src/logger.ts",
    "content": "import type { ConsolaInstance } from 'consola';\nimport process from 'node:process';\nimport { consola } from 'consola';\n\nexport function createLogger(name: string): ConsolaInstance {\n\tconst logger: ConsolaInstance = consola.withTag(name);\n\n\t// Apply LOG_LEVEL environment variable if set\n\tif (process.env.LOG_LEVEL != null) {\n\t\tconst level = Number.parseInt(process.env.LOG_LEVEL, 10);\n\t\tif (!Number.isNaN(level)) {\n\t\t\tlogger.level = level;\n\t\t}\n\t}\n\n\treturn logger;\n}\n\n// eslint-disable-next-line no-console\nexport const log = console.log;\n"
  },
  {
    "path": "packages/internal/src/pricing-fetch-utils.ts",
    "content": "import type { LiteLLMModelPricing } from './pricing.ts';\nimport * as v from 'valibot';\nimport { LITELLM_PRICING_URL, liteLLMModelPricingSchema } from './pricing.ts';\n\nexport type PricingDataset = Record<string, LiteLLMModelPricing>;\n\nexport function createPricingDataset(): PricingDataset {\n\treturn Object.create(null) as PricingDataset;\n}\n\nexport async function fetchLiteLLMPricingDataset(): Promise<PricingDataset> {\n\tconst response = await fetch(LITELLM_PRICING_URL);\n\tif (!response.ok) {\n\t\tthrow new Error(`Failed to fetch pricing data: ${response.status} ${response.statusText}`);\n\t}\n\n\tconst rawDataset = (await response.json()) as Record<string, unknown>;\n\tconst dataset = createPricingDataset();\n\n\tfor (const [modelName, modelData] of Object.entries(rawDataset)) {\n\t\tif (modelData == null || typeof modelData !== 'object') {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst parsed = v.safeParse(liteLLMModelPricingSchema, modelData);\n\t\tif (!parsed.success) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tdataset[modelName] = parsed.output;\n\t}\n\n\treturn dataset;\n}\n\nexport function filterPricingDataset(\n\tdataset: PricingDataset,\n\tpredicate: (modelName: string, pricing: LiteLLMModelPricing) => boolean,\n): PricingDataset {\n\tconst filtered = createPricingDataset();\n\tfor (const [modelName, pricing] of Object.entries(dataset)) {\n\t\tif (predicate(modelName, pricing)) {\n\t\t\tfiltered[modelName] = pricing;\n\t\t}\n\t}\n\treturn filtered;\n}\n"
  },
  {
    "path": "packages/internal/src/pricing.ts",
    "content": "import { Result } from '@praha/byethrow';\nimport * as v from 'valibot';\n\nexport const LITELLM_PRICING_URL =\n\t'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';\n\n/**\n * Default token threshold for tiered pricing in 1M context window models.\n * LiteLLM's pricing schema hard-codes this threshold in field names\n * (e.g., `input_cost_per_token_above_200k_tokens`).\n * The threshold parameter in calculateTieredCost allows flexibility for\n * future models that may use different thresholds.\n */\nconst DEFAULT_TIERED_THRESHOLD = 200_000;\n\n/**\n * LiteLLM Model Pricing Schema\n *\n * ⚠️ TIERED PRICING NOTE:\n * Different models use different token thresholds for tiered pricing:\n * - Claude/Anthropic: 200k tokens (implemented in calculateTieredCost)\n * - Gemini: 128k tokens (schema fields only, NOT implemented in calculations)\n * - GPT/OpenAI: No tiered pricing (flat rate)\n *\n * When adding support for new models:\n * 1. Check if model has tiered pricing in LiteLLM data\n * 2. Verify the threshold value\n * 3. Update calculateTieredCost logic if threshold differs from 200k\n * 4. Add tests for tiered pricing boundaries\n */\nexport const liteLLMModelPricingSchema = v.object({\n\tinput_cost_per_token: v.optional(v.number()),\n\toutput_cost_per_token: v.optional(v.number()),\n\tcache_creation_input_token_cost: v.optional(v.number()),\n\tcache_read_input_token_cost: v.optional(v.number()),\n\tmax_tokens: v.optional(v.number()),\n\tmax_input_tokens: v.optional(v.number()),\n\tmax_output_tokens: v.optional(v.number()),\n\t// Claude/Anthropic: 1M context window pricing (200k threshold)\n\tinput_cost_per_token_above_200k_tokens: v.optional(v.number()),\n\toutput_cost_per_token_above_200k_tokens: v.optional(v.number()),\n\tcache_creation_input_token_cost_above_200k_tokens: v.optional(v.number()),\n\tcache_read_input_token_cost_above_200k_tokens: v.optional(v.number()),\n\t// Gemini: Tiered pricing (128k threshold) - NOT implemented in calculations\n\tinput_cost_per_token_above_128k_tokens: v.optional(v.number()),\n\toutput_cost_per_token_above_128k_tokens: v.optional(v.number()),\n\t// Provider-specific pricing multipliers (e.g., fast mode, regional pricing)\n\tprovider_specific_entry: v.optional(\n\t\tv.object({\n\t\t\tfast: v.optional(v.number()),\n\t\t}),\n\t),\n});\n\nexport type LiteLLMModelPricing = v.InferOutput<typeof liteLLMModelPricingSchema>;\n\nexport type PricingLogger = {\n\tdebug: (...args: unknown[]) => void;\n\terror: (...args: unknown[]) => void;\n\tinfo: (...args: unknown[]) => void;\n\twarn: (...args: unknown[]) => void;\n};\n\nexport type LiteLLMPricingFetcherOptions = {\n\tlogger?: PricingLogger;\n\toffline?: boolean;\n\tofflineLoader?: () => Promise<Record<string, LiteLLMModelPricing>>;\n\turl?: string;\n\tproviderPrefixes?: string[];\n};\n\nconst DEFAULT_PROVIDER_PREFIXES = [\n\t'anthropic/',\n\t'claude-3-5-',\n\t'claude-3-',\n\t'claude-',\n\t'openai/',\n\t'azure/',\n\t'openrouter/openai/',\n];\n\nfunction createLogger(logger?: PricingLogger): PricingLogger {\n\tif (logger != null) {\n\t\treturn logger;\n\t}\n\n\treturn {\n\t\tdebug: () => {},\n\t\terror: () => {},\n\t\tinfo: () => {},\n\t\twarn: () => {},\n\t};\n}\n\nexport class LiteLLMPricingFetcher implements Disposable {\n\tprivate cachedPricing: Map<string, LiteLLMModelPricing> | null = null;\n\tprivate readonly logger: PricingLogger;\n\tprivate readonly offline: boolean;\n\tprivate readonly offlineLoader?: () => Promise<Record<string, LiteLLMModelPricing>>;\n\tprivate readonly url: string;\n\tprivate readonly providerPrefixes: string[];\n\n\tconstructor(options: LiteLLMPricingFetcherOptions = {}) {\n\t\tthis.logger = createLogger(options.logger);\n\t\tthis.offline = Boolean(options.offline);\n\t\tthis.offlineLoader = options.offlineLoader;\n\t\tthis.url = options.url ?? LITELLM_PRICING_URL;\n\t\tthis.providerPrefixes = options.providerPrefixes ?? DEFAULT_PROVIDER_PREFIXES;\n\t}\n\n\t[Symbol.dispose](): void {\n\t\tthis.clearCache();\n\t}\n\n\tclearCache(): void {\n\t\tthis.cachedPricing = null;\n\t}\n\n\tprivate loadOfflinePricing = Result.try({\n\t\ttry: async () => {\n\t\t\tif (this.offlineLoader == null) {\n\t\t\t\tthrow new Error('Offline loader was not provided');\n\t\t\t}\n\n\t\t\tconst pricing = new Map(Object.entries(await this.offlineLoader()));\n\t\t\tthis.cachedPricing = pricing;\n\t\t\treturn pricing;\n\t\t},\n\t\tcatch: (error) => new Error('Failed to load offline pricing data', { cause: error }),\n\t});\n\n\tprivate async handleFallbackToCachedPricing(\n\t\toriginalError: unknown,\n\t): Result.ResultAsync<Map<string, LiteLLMModelPricing>, Error> {\n\t\tthis.logger.warn(\n\t\t\t'Failed to fetch model pricing from LiteLLM, falling back to cached pricing data',\n\t\t);\n\t\tthis.logger.debug('Fetch error details:', originalError);\n\t\treturn Result.pipe(\n\t\t\tthis.loadOfflinePricing(),\n\t\t\tResult.inspect((pricing) => {\n\t\t\t\tthis.logger.info(`Using cached pricing data for ${pricing.size} models`);\n\t\t\t}),\n\t\t\tResult.inspectError((error) => {\n\t\t\t\tthis.logger.error('Failed to load cached pricing data as fallback:', error);\n\t\t\t\tthis.logger.error('Original fetch error:', originalError);\n\t\t\t}),\n\t\t);\n\t}\n\n\tprivate async ensurePricingLoaded(): Result.ResultAsync<Map<string, LiteLLMModelPricing>, Error> {\n\t\treturn Result.pipe(\n\t\t\tthis.cachedPricing != null\n\t\t\t\t? Result.succeed(this.cachedPricing)\n\t\t\t\t: Result.fail(new Error('Cached pricing not available')),\n\t\t\tResult.orElse(async () => {\n\t\t\t\tif (this.offline) {\n\t\t\t\t\treturn this.loadOfflinePricing();\n\t\t\t\t}\n\n\t\t\t\tthis.logger.warn('Fetching latest model pricing from LiteLLM...');\n\t\t\t\treturn Result.pipe(\n\t\t\t\t\tResult.try({\n\t\t\t\t\t\ttry: fetch(this.url),\n\t\t\t\t\t\tcatch: (error) =>\n\t\t\t\t\t\t\tnew Error('Failed to fetch model pricing from LiteLLM', { cause: error }),\n\t\t\t\t\t}),\n\t\t\t\t\tResult.andThrough((response) => {\n\t\t\t\t\t\tif (!response.ok) {\n\t\t\t\t\t\t\treturn Result.fail(new Error(`Failed to fetch pricing data: ${response.statusText}`));\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn Result.succeed();\n\t\t\t\t\t}),\n\t\t\t\t\tResult.andThen(async (response) =>\n\t\t\t\t\t\tResult.try({\n\t\t\t\t\t\t\ttry: response.json() as Promise<Record<string, unknown>>,\n\t\t\t\t\t\t\tcatch: (error) => new Error('Failed to parse pricing data', { cause: error }),\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\tResult.map((data) => {\n\t\t\t\t\t\tconst pricing = new Map<string, LiteLLMModelPricing>();\n\t\t\t\t\t\tfor (const [modelName, modelData] of Object.entries(data)) {\n\t\t\t\t\t\t\tif (typeof modelData !== 'object' || modelData == null) {\n\t\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tconst parsed = v.safeParse(liteLLMModelPricingSchema, modelData);\n\t\t\t\t\t\t\tif (!parsed.success) {\n\t\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tpricing.set(modelName, parsed.output);\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn pricing;\n\t\t\t\t\t}),\n\t\t\t\t\tResult.inspect((pricing) => {\n\t\t\t\t\t\tthis.cachedPricing = pricing;\n\t\t\t\t\t\tthis.logger.info(`Loaded pricing for ${pricing.size} models`);\n\t\t\t\t\t}),\n\t\t\t\t\tResult.orElse(async (error) => this.handleFallbackToCachedPricing(error)),\n\t\t\t\t);\n\t\t\t}),\n\t\t);\n\t}\n\n\tasync fetchModelPricing(): Result.ResultAsync<Map<string, LiteLLMModelPricing>, Error> {\n\t\treturn this.ensurePricingLoaded();\n\t}\n\n\tprivate createMatchingCandidates(modelName: string): string[] {\n\t\tconst candidates = new Set<string>();\n\t\tcandidates.add(modelName);\n\n\t\tfor (const prefix of this.providerPrefixes) {\n\t\t\tcandidates.add(`${prefix}${modelName}`);\n\t\t}\n\n\t\treturn Array.from(candidates);\n\t}\n\n\tasync getModelPricing(modelName: string): Result.ResultAsync<LiteLLMModelPricing | null, Error> {\n\t\treturn Result.pipe(\n\t\t\tthis.ensurePricingLoaded(),\n\t\t\tResult.map((pricing) => {\n\t\t\t\tfor (const candidate of this.createMatchingCandidates(modelName)) {\n\t\t\t\t\tconst direct = pricing.get(candidate);\n\t\t\t\t\tif (direct != null) {\n\t\t\t\t\t\treturn direct;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst lower = modelName.toLowerCase();\n\t\t\t\tfor (const [key, value] of pricing) {\n\t\t\t\t\tconst comparison = key.toLowerCase();\n\t\t\t\t\tif (comparison.includes(lower) || lower.includes(comparison)) {\n\t\t\t\t\t\treturn value;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn null;\n\t\t\t}),\n\t\t);\n\t}\n\n\tasync getModelContextLimit(modelName: string): Result.ResultAsync<number | null, Error> {\n\t\treturn Result.pipe(\n\t\t\tthis.getModelPricing(modelName),\n\t\t\tResult.map((pricing) => pricing?.max_input_tokens ?? null),\n\t\t);\n\t}\n\n\t/**\n\t * Calculate the total cost for token usage based on model pricing\n\t *\n\t * Supports tiered pricing for 1M context window models where tokens\n\t * above a threshold (default 200k) are charged at a different rate.\n\t * Handles all token types: input, output, cache creation, and cache read.\n\t *\n\t * @param tokens - Token counts for different types\n\t * @param tokens.input_tokens - Number of input tokens\n\t * @param tokens.output_tokens - Number of output tokens\n\t * @param tokens.cache_creation_input_tokens - Number of cache creation input tokens\n\t * @param tokens.cache_read_input_tokens - Number of cache read input tokens\n\t * @param pricing - Model pricing information from LiteLLM\n\t * @returns Total cost in USD\n\t */\n\tcalculateCostFromPricing(\n\t\ttokens: {\n\t\t\tinput_tokens: number;\n\t\t\toutput_tokens: number;\n\t\t\tcache_creation_input_tokens?: number;\n\t\t\tcache_read_input_tokens?: number;\n\t\t},\n\t\tpricing: LiteLLMModelPricing,\n\t): number {\n\t\t/**\n\t\t * Calculate cost with tiered pricing for 1M context window models\n\t\t *\n\t\t * @param totalTokens - Total number of tokens to calculate cost for\n\t\t * @param basePrice - Price per token for tokens up to the threshold\n\t\t * @param tieredPrice - Price per token for tokens above the threshold\n\t\t * @param threshold - Token threshold for tiered pricing (default 200k)\n\t\t * @returns Total cost applying tiered pricing when applicable\n\t\t *\n\t\t * @example\n\t\t * // 300k tokens with base price $3/M and tiered price $6/M\n\t\t * calculateTieredCost(300_000, 3e-6, 6e-6)\n\t\t * // Returns: (200_000 * 3e-6) + (100_000 * 6e-6) = $1.2\n\t\t */\n\t\tconst calculateTieredCost = (\n\t\t\ttotalTokens: number | undefined,\n\t\t\tbasePrice: number | undefined,\n\t\t\ttieredPrice: number | undefined,\n\t\t\tthreshold: number = DEFAULT_TIERED_THRESHOLD,\n\t\t): number => {\n\t\t\tif (totalTokens == null || totalTokens <= 0) {\n\t\t\t\treturn 0;\n\t\t\t}\n\n\t\t\tif (totalTokens > threshold && tieredPrice != null) {\n\t\t\t\tconst tokensBelowThreshold = Math.min(totalTokens, threshold);\n\t\t\t\tconst tokensAboveThreshold = Math.max(0, totalTokens - threshold);\n\n\t\t\t\tlet tieredCost = tokensAboveThreshold * tieredPrice;\n\t\t\t\tif (basePrice != null) {\n\t\t\t\t\ttieredCost += tokensBelowThreshold * basePrice;\n\t\t\t\t}\n\t\t\t\treturn tieredCost;\n\t\t\t}\n\n\t\t\tif (basePrice != null) {\n\t\t\t\treturn totalTokens * basePrice;\n\t\t\t}\n\n\t\t\treturn 0;\n\t\t};\n\n\t\tconst inputCost = calculateTieredCost(\n\t\t\ttokens.input_tokens,\n\t\t\tpricing.input_cost_per_token,\n\t\t\tpricing.input_cost_per_token_above_200k_tokens,\n\t\t);\n\n\t\tconst outputCost = calculateTieredCost(\n\t\t\ttokens.output_tokens,\n\t\t\tpricing.output_cost_per_token,\n\t\t\tpricing.output_cost_per_token_above_200k_tokens,\n\t\t);\n\n\t\tconst cacheCreationCost = calculateTieredCost(\n\t\t\ttokens.cache_creation_input_tokens,\n\t\t\tpricing.cache_creation_input_token_cost,\n\t\t\tpricing.cache_creation_input_token_cost_above_200k_tokens,\n\t\t);\n\n\t\tconst cacheReadCost = calculateTieredCost(\n\t\t\ttokens.cache_read_input_tokens,\n\t\t\tpricing.cache_read_input_token_cost,\n\t\t\tpricing.cache_read_input_token_cost_above_200k_tokens,\n\t\t);\n\n\t\treturn inputCost + outputCost + cacheCreationCost + cacheReadCost;\n\t}\n\n\tasync calculateCostFromTokens(\n\t\ttokens: {\n\t\t\tinput_tokens: number;\n\t\t\toutput_tokens: number;\n\t\t\tcache_creation_input_tokens?: number;\n\t\t\tcache_read_input_tokens?: number;\n\t\t},\n\t\tmodelName?: string,\n\t\toptions?: { speed?: 'standard' | 'fast' },\n\t): Result.ResultAsync<number, Error> {\n\t\tif (modelName == null || modelName === '') {\n\t\t\treturn Result.succeed(0);\n\t\t}\n\n\t\treturn Result.pipe(\n\t\t\tthis.getModelPricing(modelName),\n\t\t\tResult.andThen((pricing) => {\n\t\t\t\tif (pricing == null) {\n\t\t\t\t\treturn Result.fail(new Error(`Model pricing not found for ${modelName}`));\n\t\t\t\t}\n\t\t\t\tconst baseCost = this.calculateCostFromPricing(tokens, pricing);\n\t\t\t\tconst multiplier =\n\t\t\t\t\toptions?.speed === 'fast' ? (pricing.provider_specific_entry?.fast ?? 1) : 1;\n\t\t\t\treturn Result.succeed(baseCost * multiplier);\n\t\t\t}),\n\t\t);\n\t}\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('LiteLLMPricingFetcher', () => {\n\t\tit('returns pricing data from LiteLLM dataset', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'gpt-5': {\n\t\t\t\t\t\tinput_cost_per_token: 1.25e-6,\n\t\t\t\t\t\toutput_cost_per_token: 1e-5,\n\t\t\t\t\t\tcache_read_input_token_cost: 1.25e-7,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst pricing = await Result.unwrap(fetcher.fetchModelPricing());\n\t\t\texpect(pricing.size).toBe(1);\n\t\t});\n\n\t\tit('calculates cost using pricing information', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'gpt-5': {\n\t\t\t\t\t\tinput_cost_per_token: 1.25e-6,\n\t\t\t\t\t\toutput_cost_per_token: 1e-5,\n\t\t\t\t\t\tcache_read_input_token_cost: 1.25e-7,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst cost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(\n\t\t\t\t\t{\n\t\t\t\t\t\tinput_tokens: 1000,\n\t\t\t\t\t\toutput_tokens: 500,\n\t\t\t\t\t\tcache_read_input_tokens: 200,\n\t\t\t\t\t},\n\t\t\t\t\t'gpt-5',\n\t\t\t\t),\n\t\t\t);\n\n\t\t\texpect(cost).toBeCloseTo(1000 * 1.25e-6 + 500 * 1e-5 + 200 * 1.25e-7);\n\t\t});\n\n\t\tit('calculates tiered pricing for tokens exceeding 200k threshold (300k input, 250k output, 300k cache creation, 250k cache read)', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'anthropic/claude-4-sonnet-20250514': {\n\t\t\t\t\t\tinput_cost_per_token: 3e-6,\n\t\t\t\t\t\toutput_cost_per_token: 1.5e-5,\n\t\t\t\t\t\tinput_cost_per_token_above_200k_tokens: 6e-6,\n\t\t\t\t\t\toutput_cost_per_token_above_200k_tokens: 2.25e-5,\n\t\t\t\t\t\tcache_creation_input_token_cost: 3.75e-6,\n\t\t\t\t\t\tcache_read_input_token_cost: 3e-7,\n\t\t\t\t\t\tcache_creation_input_token_cost_above_200k_tokens: 7.5e-6,\n\t\t\t\t\t\tcache_read_input_token_cost_above_200k_tokens: 6e-7,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\t// Test comprehensive scenario with all token types above 200k threshold\n\t\t\tconst cost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(\n\t\t\t\t\t{\n\t\t\t\t\t\tinput_tokens: 300_000,\n\t\t\t\t\t\toutput_tokens: 250_000,\n\t\t\t\t\t\tcache_creation_input_tokens: 300_000,\n\t\t\t\t\t\tcache_read_input_tokens: 250_000,\n\t\t\t\t\t},\n\t\t\t\t\t'anthropic/claude-4-sonnet-20250514',\n\t\t\t\t),\n\t\t\t);\n\n\t\t\tconst expectedCost =\n\t\t\t\t200_000 * 3e-6 +\n\t\t\t\t100_000 * 6e-6 + // input\n\t\t\t\t200_000 * 1.5e-5 +\n\t\t\t\t50_000 * 2.25e-5 + // output\n\t\t\t\t200_000 * 3.75e-6 +\n\t\t\t\t100_000 * 7.5e-6 + // cache creation\n\t\t\t\t200_000 * 3e-7 +\n\t\t\t\t50_000 * 6e-7; // cache read\n\t\t\texpect(cost).toBeCloseTo(expectedCost);\n\t\t});\n\n\t\tit('uses standard pricing for 300k/250k tokens when model lacks tiered pricing', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'gpt-5': {\n\t\t\t\t\t\tinput_cost_per_token: 1e-6,\n\t\t\t\t\t\toutput_cost_per_token: 2e-6,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\t// Should use normal pricing for all tokens\n\t\t\tconst cost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(\n\t\t\t\t\t{\n\t\t\t\t\t\tinput_tokens: 300_000,\n\t\t\t\t\t\toutput_tokens: 250_000,\n\t\t\t\t\t},\n\t\t\t\t\t'gpt-5',\n\t\t\t\t),\n\t\t\t);\n\n\t\t\texpect(cost).toBeCloseTo(300_000 * 1e-6 + 250_000 * 2e-6);\n\t\t});\n\n\t\tit('correctly applies pricing at 200k boundary (200k uses base, 200,001 uses tiered, 0 returns 0)', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'claude-4-sonnet-20250514': {\n\t\t\t\t\t\tinput_cost_per_token: 3e-6,\n\t\t\t\t\t\tinput_cost_per_token_above_200k_tokens: 6e-6,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\t// Test with exactly 200k tokens (should use only base price)\n\t\t\tconst cost200k = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(\n\t\t\t\t\t{\n\t\t\t\t\t\tinput_tokens: 200_000,\n\t\t\t\t\t\toutput_tokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\t'claude-4-sonnet-20250514',\n\t\t\t\t),\n\t\t\t);\n\t\t\texpect(cost200k).toBeCloseTo(200_000 * 3e-6);\n\n\t\t\t// Test with 200,001 tokens (should use tiered pricing for 1 token)\n\t\t\tconst cost200k1 = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(\n\t\t\t\t\t{\n\t\t\t\t\t\tinput_tokens: 200_001,\n\t\t\t\t\t\toutput_tokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\t'claude-4-sonnet-20250514',\n\t\t\t\t),\n\t\t\t);\n\t\t\texpect(cost200k1).toBeCloseTo(200_000 * 3e-6 + 1 * 6e-6);\n\n\t\t\t// Test with 0 tokens (should return 0)\n\t\t\tconst costZero = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(\n\t\t\t\t\t{\n\t\t\t\t\t\tinput_tokens: 0,\n\t\t\t\t\t\toutput_tokens: 0,\n\t\t\t\t\t},\n\t\t\t\t\t'claude-4-sonnet-20250514',\n\t\t\t\t),\n\t\t\t);\n\t\t\texpect(costZero).toBe(0);\n\t\t});\n\n\t\tit('charges only for tokens above 200k when base price is missing (300k→100k charged, 100k→0 charged)', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'theoretical-model': {\n\t\t\t\t\t\t// No base price, only tiered pricing\n\t\t\t\t\t\tinput_cost_per_token_above_200k_tokens: 6e-6,\n\t\t\t\t\t\toutput_cost_per_token_above_200k_tokens: 2.25e-5,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\t// Test with 300k tokens - should only charge for tokens above 200k\n\t\t\tconst cost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(\n\t\t\t\t\t{\n\t\t\t\t\t\tinput_tokens: 300_000,\n\t\t\t\t\t\toutput_tokens: 250_000,\n\t\t\t\t\t},\n\t\t\t\t\t'theoretical-model',\n\t\t\t\t),\n\t\t\t);\n\n\t\t\t// Only 100k input tokens above 200k are charged\n\t\t\t// Only 50k output tokens above 200k are charged\n\t\t\texpect(cost).toBeCloseTo(100_000 * 6e-6 + 50_000 * 2.25e-5);\n\n\t\t\t// Test with tokens below threshold - should return 0 (no base price)\n\t\t\tconst costBelow = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(\n\t\t\t\t\t{\n\t\t\t\t\t\tinput_tokens: 100_000,\n\t\t\t\t\t\toutput_tokens: 100_000,\n\t\t\t\t\t},\n\t\t\t\t\t'theoretical-model',\n\t\t\t\t),\n\t\t\t);\n\t\t\texpect(costBelow).toBe(0);\n\t\t});\n\n\t\tit('applies fast speed multiplier from provider_specific_entry', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'claude-opus-4-6': {\n\t\t\t\t\t\tinput_cost_per_token: 5e-6,\n\t\t\t\t\t\toutput_cost_per_token: 2.5e-5,\n\t\t\t\t\t\tprovider_specific_entry: { fast: 6.0 },\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst tokens = { input_tokens: 1000, output_tokens: 500 };\n\n\t\t\tconst standardCost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(tokens, 'claude-opus-4-6'),\n\t\t\t);\n\t\t\tconst fastCost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(tokens, 'claude-opus-4-6', { speed: 'fast' }),\n\t\t\t);\n\n\t\t\tconst expectedStandard = 1000 * 5e-6 + 500 * 2.5e-5;\n\t\t\texpect(standardCost).toBeCloseTo(expectedStandard);\n\t\t\texpect(fastCost).toBeCloseTo(expectedStandard * 6);\n\t\t});\n\n\t\tit('defaults to 1x multiplier when provider_specific_entry has no fast field', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'claude-sonnet-4-6': {\n\t\t\t\t\t\tinput_cost_per_token: 3e-6,\n\t\t\t\t\t\toutput_cost_per_token: 1.5e-5,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst tokens = { input_tokens: 1000, output_tokens: 500 };\n\n\t\t\tconst standardCost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(tokens, 'claude-sonnet-4-6'),\n\t\t\t);\n\t\t\tconst fastCost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(tokens, 'claude-sonnet-4-6', { speed: 'fast' }),\n\t\t\t);\n\n\t\t\texpect(fastCost).toBeCloseTo(standardCost);\n\t\t});\n\n\t\tit('does not apply multiplier when speed is standard', async () => {\n\t\t\tusing fetcher = new LiteLLMPricingFetcher({\n\t\t\t\toffline: true,\n\t\t\t\tofflineLoader: async () => ({\n\t\t\t\t\t'claude-opus-4-6': {\n\t\t\t\t\t\tinput_cost_per_token: 5e-6,\n\t\t\t\t\t\toutput_cost_per_token: 2.5e-5,\n\t\t\t\t\t\tprovider_specific_entry: { fast: 6.0 },\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst tokens = { input_tokens: 1000, output_tokens: 500 };\n\n\t\t\tconst noSpeedCost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(tokens, 'claude-opus-4-6'),\n\t\t\t);\n\t\t\tconst standardCost = await Result.unwrap(\n\t\t\t\tfetcher.calculateCostFromTokens(tokens, 'claude-opus-4-6', { speed: 'standard' }),\n\t\t\t);\n\n\t\t\texpect(standardCost).toBeCloseTo(noSpeedCost);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "packages/internal/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"moduleDetection\": \"force\",\n\t\t\"module\": \"Preserve\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"types\": [\"vitest/globals\", \"vitest/importMeta\"],\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noPropertyAccessFromIndexSignature\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t\"noUnusedLocals\": false,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noEmit\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"exclude\": [\"dist\"]\n}\n"
  },
  {
    "path": "packages/internal/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\twatch: false,\n\t\tincludeSource: ['src/**/*.{js,ts}'],\n\t\tglobals: true,\n\t},\n});\n"
  },
  {
    "path": "packages/terminal/CLAUDE.md",
    "content": "# CLAUDE.md - Terminal Package\n\nThis package provides terminal utilities for the ccusage toolchain.\n\n## Package Overview\n\n**Name**: `@ccusage/terminal`\n**Description**: Terminal utilities for ccusage\n**Type**: Internal library package (private)\n\n## Development Commands\n\n**Testing and Quality:**\n\n- `pnpm run test` - Run all tests using vitest\n- `pnpm run lint` - Lint code using ESLint\n- `pnpm run format` - Format and auto-fix code with ESLint\n- `pnpm typecheck` - Type check with TypeScript\n\n## Architecture\n\nThis package contains terminal utilities used across the ccusage monorepo:\n\n**Key Modules:**\n\n- `src/table.ts` - Table formatting and rendering utilities\n- `src/utils.ts` - General terminal utilities\n\n**Exports:**\n\n- `./table` - Table formatting utilities\n- `./utils` - Terminal utility functions\n\n## Dependencies\n\n**Runtime Dependencies:**\n\n- `@oxc-project/runtime` - Runtime utilities\n- `ansi-escapes` - ANSI escape sequences for terminal manipulation\n- `cli-table3` - Table formatting for terminal output\n- `es-toolkit` - Modern JavaScript utility library\n- `picocolors` - Terminal color support\n- `string-width` - Get the visual width of strings\n\n**Dev Dependencies:**\n\n- `vitest` - Testing framework\n- `eslint` - Linting and formatting\n\n## Testing Guidelines\n\n- **In-Source Testing**: Tests are written in the same files using `if (import.meta.vitest != null)` blocks\n- **Vitest Globals Enabled**: Use `describe`, `it`, `expect` directly without imports\n- **CRITICAL**: NEVER use `await import()` dynamic imports anywhere, especially in test blocks\n\n## Code Style\n\nFollow the same code style guidelines as the main ccusage package:\n\n- **Error Handling**: Prefer functional error handling patterns\n- **Imports**: Use `.ts` extensions for local imports\n- **Exports**: Only export what's actually used\n- **No console.log**: Terminal output should be handled through proper utilities\n\n**Post-Change Workflow:**\nAlways run these commands in parallel after code changes:\n\n- `pnpm run format` - Auto-fix and format\n- `pnpm typecheck` - Type checking\n- `pnpm run test` - Run tests\n\n## Important Notes\n\nThis 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.\n"
  },
  {
    "path": "packages/terminal/eslint.config.js",
    "content": "import { ryoppippi } from '@ryoppippi/eslint-config';\n\nexport default ryoppippi({\n\tstylistic: false,\n});\n"
  },
  {
    "path": "packages/terminal/package.json",
    "content": "{\n\t\"name\": \"@ccusage/terminal\",\n\t\"type\": \"module\",\n\t\"version\": \"18.0.10\",\n\t\"private\": true,\n\t\"description\": \"Terminal utilities for ccusage\",\n\t\"exports\": {\n\t\t\"./table\": \"./src/table.ts\",\n\t\t\"./utils\": \"./src/utils.ts\"\n\t},\n\t\"scripts\": {\n\t\t\"format\": \"pnpm run lint --fix\",\n\t\t\"lint\": \"eslint --cache .\",\n\t\t\"test\": \"TZ=UTC vitest\",\n\t\t\"typecheck\": \"tsgo --noEmit\"\n\t},\n\t\"dependencies\": {\n\t\t\"@oxc-project/runtime\": \"catalog:build\",\n\t\t\"ansi-escapes\": \"catalog:runtime\",\n\t\t\"cli-table3\": \"catalog:runtime\",\n\t\t\"es-toolkit\": \"catalog:runtime\",\n\t\t\"picocolors\": \"catalog:runtime\",\n\t\t\"string-width\": \"catalog:runtime\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@ryoppippi/eslint-config\": \"catalog:lint\",\n\t\t\"eslint\": \"catalog:lint\",\n\t\t\"eslint-plugin-format\": \"catalog:lint\",\n\t\t\"vitest\": \"catalog:testing\"\n\t}\n}\n"
  },
  {
    "path": "packages/terminal/src/table.ts",
    "content": "import process from 'node:process';\nimport Table from 'cli-table3';\nimport { uniq } from 'es-toolkit';\nimport pc from 'picocolors';\nimport stringWidth from 'string-width';\n\n/**\n * Default locale used for date formatting when not specified\n * en-CA provides YYYY-MM-DD ISO format\n */\nconst DEFAULT_LOCALE = 'en-CA';\n\n/**\n * Creates a date parts formatter with the specified timezone and locale\n * @param timezone - Timezone to use\n * @param locale - Locale to use for formatting\n * @returns Intl.DateTimeFormat instance\n */\nfunction createDatePartsFormatter(\n\ttimezone: string | undefined,\n\tlocale: string,\n): Intl.DateTimeFormat {\n\treturn new Intl.DateTimeFormat(locale, {\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t\ttimeZone: timezone,\n\t});\n}\n\n/**\n * Formats a date string to compact format with year on first line and month-day on second\n * @param dateStr - Input date string (YYYY-MM-DD or ISO timestamp)\n * @param timezone - Timezone to use for formatting (pass undefined to use system timezone)\n * @param locale - Locale to use for formatting (defaults to sv-SE for YYYY-MM-DD format)\n * @returns Formatted date string with newline separator (YYYY\\nMM-DD)\n */\nexport function formatDateCompact(dateStr: string, timezone?: string, locale?: string): string {\n\t// Check if input is in YYYY-MM-DD format\n\tconst isSimpleDateFormat = /^\\d{4}-\\d{2}-\\d{2}$/.test(dateStr);\n\t// For YYYY-MM-DD format, append T00:00:00 to parse as local date\n\t// Without this, new Date('YYYY-MM-DD') interprets as UTC midnight\n\tconst date = isSimpleDateFormat\n\t\t? timezone != null\n\t\t\t? new Date(`${dateStr}T00:00:00Z`)\n\t\t\t: new Date(`${dateStr}T00:00:00`)\n\t\t: new Date(dateStr);\n\tconst formatter = createDatePartsFormatter(timezone, locale ?? DEFAULT_LOCALE);\n\tconst parts = formatter.formatToParts(date);\n\tconst year = parts.find((p) => p.type === 'year')?.value ?? '';\n\tconst month = parts.find((p) => p.type === 'month')?.value ?? '';\n\tconst day = parts.find((p) => p.type === 'day')?.value ?? '';\n\treturn `${year}\\n${month}-${day}`;\n}\n\n/**\n * Horizontal alignment options for table cells\n */\nexport type TableCellAlign = 'left' | 'right' | 'center';\n\n/**\n * Table row data type supporting strings, numbers, and formatted cell objects\n */\nexport type TableRow = (string | number | { content: string; hAlign?: TableCellAlign })[];\n\n/**\n * Configuration options for creating responsive tables\n */\nexport type TableOptions = {\n\thead: string[];\n\tcolAligns?: TableCellAlign[];\n\tstyle?: {\n\t\thead?: string[];\n\t};\n\tdateFormatter?: (dateStr: string) => string;\n\tcompactHead?: string[];\n\tcompactColAligns?: TableCellAlign[];\n\tcompactThreshold?: number;\n\tforceCompact?: boolean;\n\tlogger?: (message: string) => void;\n};\n\n/**\n * Responsive table class that adapts column widths based on terminal size\n * Automatically adjusts formatting and layout for different screen sizes\n */\nexport class ResponsiveTable {\n\tprivate head: string[];\n\tprivate rows: TableRow[] = [];\n\tprivate colAligns: TableCellAlign[];\n\tprivate style?: { head?: string[] };\n\tprivate dateFormatter?: (dateStr: string) => string;\n\tprivate compactHead?: string[];\n\tprivate compactColAligns?: TableCellAlign[];\n\tprivate compactThreshold: number;\n\tprivate compactMode = false;\n\tprivate forceCompact: boolean;\n\tprivate logger: (message: string) => void;\n\n\t/**\n\t * Creates a new responsive table instance\n\t * @param options - Table configuration options\n\t */\n\tconstructor(options: TableOptions) {\n\t\tthis.head = options.head;\n\t\tthis.colAligns = options.colAligns ?? Array.from({ length: this.head.length }, () => 'left');\n\t\tthis.style = options.style;\n\t\tthis.dateFormatter = options.dateFormatter;\n\t\tthis.compactHead = options.compactHead;\n\t\tthis.compactColAligns = options.compactColAligns;\n\t\tthis.compactThreshold = options.compactThreshold ?? 100;\n\t\tthis.forceCompact = options.forceCompact ?? false;\n\t\tthis.logger = options.logger ?? console.warn;\n\t}\n\n\t/**\n\t * Adds a row to the table\n\t * @param row - Row data to add\n\t */\n\tpush(row: TableRow): void {\n\t\tthis.rows.push(row);\n\t}\n\n\t/**\n\t * Filters a row to compact mode columns\n\t * @param row - Row to filter\n\t * @param compactIndices - Indices of columns to keep in compact mode\n\t * @returns Filtered row\n\t */\n\tprivate filterRowToCompact(row: TableRow, compactIndices: number[]): TableRow {\n\t\treturn compactIndices.map((index) => row[index] ?? '');\n\t}\n\n\t/**\n\t * Gets the current table head and col aligns based on compact mode\n\t * @returns Current head and colAligns arrays\n\t */\n\tprivate getCurrentTableConfig(): { head: string[]; colAligns: TableCellAlign[] } {\n\t\tif (this.compactMode && this.compactHead != null && this.compactColAligns != null) {\n\t\t\treturn { head: this.compactHead, colAligns: this.compactColAligns };\n\t\t}\n\t\treturn { head: this.head, colAligns: this.colAligns };\n\t}\n\n\t/**\n\t * Gets indices mapping from full table to compact table\n\t * @returns Array of column indices to keep in compact mode\n\t */\n\tprivate getCompactIndices(): number[] {\n\t\tif (this.compactHead == null || !this.compactMode) {\n\t\t\treturn Array.from({ length: this.head.length }, (_, i) => i);\n\t\t}\n\n\t\t// Map compact headers to original indices\n\t\treturn this.compactHead.map((compactHeader) => {\n\t\t\tconst index = this.head.indexOf(compactHeader);\n\t\t\tif (index < 0) {\n\t\t\t\t// Log warning for debugging configuration issues\n\t\t\t\tthis.logger(\n\t\t\t\t\t`Warning: Compact header \"${compactHeader}\" not found in table headers [${this.head.join(', ')}]. Using first column as fallback.`,\n\t\t\t\t);\n\t\t\t\treturn 0; // fallback to first column if not found\n\t\t\t}\n\t\t\treturn index;\n\t\t});\n\t}\n\n\t/**\n\t * Returns whether the table is currently in compact mode\n\t * @returns True if compact mode is active\n\t */\n\tisCompactMode(): boolean {\n\t\treturn this.compactMode;\n\t}\n\n\t/**\n\t * Renders the table as a formatted string\n\t * Automatically adjusts layout based on terminal width\n\t * @returns Formatted table string\n\t */\n\ttoString(): string {\n\t\t// Check environment variable first, then process.stdout.columns, then default\n\t\tconst terminalWidth =\n\t\t\tNumber.parseInt(process.env.COLUMNS ?? '', 10) || process.stdout.columns || 120;\n\n\t\t// Determine if we should use compact mode\n\t\tthis.compactMode =\n\t\t\tthis.forceCompact || (terminalWidth < this.compactThreshold && this.compactHead != null);\n\n\t\t// Get current table configuration\n\t\tconst { head, colAligns } = this.getCurrentTableConfig();\n\t\tconst compactIndices = this.getCompactIndices();\n\n\t\t// Calculate actual content widths first (excluding separator rows)\n\t\tconst dataRows = this.rows.filter((row) => !this.isSeparatorRow(row));\n\n\t\t// Filter rows to compact mode if needed\n\t\tconst processedDataRows = this.compactMode\n\t\t\t? dataRows.map((row) => this.filterRowToCompact(row, compactIndices))\n\t\t\t: dataRows;\n\n\t\tconst allRows = [\n\t\t\thead.map(String),\n\t\t\t...processedDataRows.map((row) =>\n\t\t\t\trow.map((cell) => {\n\t\t\t\t\tif (typeof cell === 'object' && cell != null && 'content' in cell) {\n\t\t\t\t\t\treturn String(cell.content);\n\t\t\t\t\t}\n\t\t\t\t\treturn String(cell ?? '');\n\t\t\t\t}),\n\t\t\t),\n\t\t];\n\n\t\tconst contentWidths = head.map((_, colIndex) => {\n\t\t\tconst maxLength = Math.max(...allRows.map((row) => stringWidth(String(row[colIndex] ?? ''))));\n\t\t\treturn maxLength;\n\t\t});\n\n\t\t// Calculate table overhead\n\t\tconst numColumns = head.length;\n\t\tconst tableOverhead = 3 * numColumns + 1; // borders and separators\n\t\tconst availableWidth = terminalWidth - tableOverhead;\n\n\t\t// Always use content-based widths with generous padding for numeric columns\n\t\tconst columnWidths = contentWidths.map((width, index) => {\n\t\t\tconst align = colAligns[index];\n\t\t\t// For numeric columns, ensure generous width to prevent truncation\n\t\t\tif (align === 'right') {\n\t\t\t\treturn Math.max(width + 3, 11); // At least 11 chars for numbers, +3 padding\n\t\t\t} else if (index === 1) {\n\t\t\t\t// Models column - can be longer\n\t\t\t\treturn Math.max(width + 2, 15);\n\t\t\t}\n\t\t\treturn Math.max(width + 2, 10); // Other columns\n\t\t});\n\n\t\t// Check if this fits in the terminal\n\t\tconst totalRequiredWidth = columnWidths.reduce((sum, width) => sum + width, 0) + tableOverhead;\n\n\t\tif (totalRequiredWidth > terminalWidth) {\n\t\t\t// Apply responsive resizing and use compact date format if available\n\t\t\tconst scaleFactor = availableWidth / columnWidths.reduce((sum, width) => sum + width, 0);\n\t\t\tconst adjustedWidths = columnWidths.map((width, index) => {\n\t\t\t\tconst align = colAligns[index];\n\t\t\t\tlet adjustedWidth = Math.floor(width * scaleFactor);\n\n\t\t\t\t// Apply minimum widths based on column type\n\t\t\t\tif (align === 'right') {\n\t\t\t\t\tadjustedWidth = Math.max(adjustedWidth, 10);\n\t\t\t\t} else if (index === 0) {\n\t\t\t\t\tadjustedWidth = Math.max(adjustedWidth, 10);\n\t\t\t\t} else if (index === 1) {\n\t\t\t\t\tadjustedWidth = Math.max(adjustedWidth, 12);\n\t\t\t\t} else {\n\t\t\t\t\tadjustedWidth = Math.max(adjustedWidth, 8);\n\t\t\t\t}\n\n\t\t\t\treturn adjustedWidth;\n\t\t\t});\n\n\t\t\tconst table = new Table({\n\t\t\t\thead,\n\t\t\t\tstyle: this.style,\n\t\t\t\tcolAligns,\n\t\t\t\tcolWidths: adjustedWidths,\n\t\t\t\twordWrap: true,\n\t\t\t\twrapOnWordBoundary: true,\n\t\t\t});\n\n\t\t\t// Add rows with special handling for separators and date formatting\n\t\t\tfor (const row of this.rows) {\n\t\t\t\tif (this.isSeparatorRow(row)) {\n\t\t\t\t\t// Skip separator rows - cli-table3 will handle borders automatically\n\t\t\t\t\tcontinue;\n\t\t\t\t} else {\n\t\t\t\t\t// Use compact date format for first column if dateFormatter available\n\t\t\t\t\tlet processedRow = row.map((cell, index) => {\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tindex === 0 &&\n\t\t\t\t\t\t\tthis.dateFormatter != null &&\n\t\t\t\t\t\t\ttypeof cell === 'string' &&\n\t\t\t\t\t\t\tthis.isDateString(cell)\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\treturn this.dateFormatter(cell);\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn cell;\n\t\t\t\t\t});\n\n\t\t\t\t\t// Filter to compact columns if in compact mode\n\t\t\t\t\tif (this.compactMode) {\n\t\t\t\t\t\tprocessedRow = this.filterRowToCompact(processedRow, compactIndices);\n\t\t\t\t\t}\n\n\t\t\t\t\ttable.push(processedRow);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn table.toString();\n\t\t} else {\n\t\t\t// Use generous column widths with normal date format\n\t\t\tconst table = new Table({\n\t\t\t\thead,\n\t\t\t\tstyle: this.style,\n\t\t\t\tcolAligns,\n\t\t\t\tcolWidths: columnWidths,\n\t\t\t\twordWrap: true,\n\t\t\t\twrapOnWordBoundary: true,\n\t\t\t});\n\n\t\t\t// Add rows with special handling for separators\n\t\t\tfor (const row of this.rows) {\n\t\t\t\tif (this.isSeparatorRow(row)) {\n\t\t\t\t\t// Skip separator rows - cli-table3 will handle borders automatically\n\t\t\t\t\tcontinue;\n\t\t\t\t} else {\n\t\t\t\t\t// Filter to compact columns if in compact mode\n\t\t\t\t\tconst processedRow = this.compactMode\n\t\t\t\t\t\t? this.filterRowToCompact(row, compactIndices)\n\t\t\t\t\t\t: row;\n\t\t\t\t\ttable.push(processedRow);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn table.toString();\n\t\t}\n\t}\n\n\t/**\n\t * Checks if a row is a separator row (contains only empty cells or dashes)\n\t * @param row - Row to check\n\t * @returns True if the row is a separator\n\t */\n\tprivate isSeparatorRow(row: TableRow): boolean {\n\t\t// Check for both old-style separator rows (─) and new-style empty rows\n\t\treturn row.every((cell) => {\n\t\t\tif (typeof cell === 'object' && cell != null && 'content' in cell) {\n\t\t\t\treturn cell.content === '' || /^─+$/.test(cell.content);\n\t\t\t}\n\t\t\treturn typeof cell === 'string' && (cell === '' || /^─+$/.test(cell));\n\t\t});\n\t}\n\n\t/**\n\t * Checks if a string matches the YYYY-MM-DD date format\n\t * @param text - String to check\n\t * @returns True if the string is a valid date format\n\t */\n\tprivate isDateString(text: string): boolean {\n\t\t// Check if string matches date format YYYY-MM-DD\n\t\treturn /^\\d{4}-\\d{2}-\\d{2}$/.test(text);\n\t}\n}\n\n/**\n * Formats a number with locale-specific thousand separators\n * @param num - The number to format\n * @returns Formatted number string with commas as thousand separators\n */\nexport function formatNumber(num: number): string {\n\treturn num.toLocaleString('en-US');\n}\n\n/**\n * Formats a number as USD currency with dollar sign and 2 decimal places\n * @param amount - The amount to format\n * @returns Formatted currency string (e.g., \"$12.34\")\n */\nexport function formatCurrency(amount: number): string {\n\treturn `$${amount.toFixed(2)}`;\n}\n\n/**\n * Formats Claude model names into a shorter, more readable format\n * Extracts model type and generation from full model name\n * @param modelName - Full model name (e.g., \"claude-sonnet-4-20250514\" or \"claude-sonnet-4-5-20250929\")\n * @returns Shortened model name (e.g., \"sonnet-4\" or \"sonnet-4-5\") or original if pattern doesn't match\n */\nfunction formatModelName(modelName: string): string {\n\t// Handle [pi] prefix - preserve prefix, format the rest\n\tconst piMatch = modelName.match(/^\\[pi\\] (.+)$/);\n\tif (piMatch?.[1] != null) {\n\t\treturn `[pi] ${formatModelName(piMatch[1])}`;\n\t}\n\n\t// Handle anthropic/ prefix with dot notation (e.g., \"anthropic/claude-opus-4.5\" -> \"opus-4.5\")\n\tconst anthropicMatch = modelName.match(/^anthropic\\/claude-(\\w+)-([\\d.]+)$/);\n\tif (anthropicMatch != null) {\n\t\treturn `${anthropicMatch[1]}-${anthropicMatch[2]}`;\n\t}\n\n\t// Extract model type from full model name with date suffix (must check before no-date pattern)\n\t// e.g., \"claude-sonnet-4-20250514\" -> \"sonnet-4\"\n\t// e.g., \"claude-opus-4-20250514\" -> \"opus-4\"\n\t// e.g., \"claude-sonnet-4-5-20250929\" -> \"sonnet-4-5\"\n\tconst match = modelName.match(/^claude-(\\w+)-([\\d-]+)-(\\d{8})$/);\n\tif (match != null) {\n\t\treturn `${match[1]}-${match[2]}`;\n\t}\n\n\t// Handle claude- without date suffix (e.g., \"claude-opus-4-5\" -> \"opus-4-5\")\n\tconst noDateMatch = modelName.match(/^claude-(\\w+)-([\\d-]+)$/);\n\tif (noDateMatch != null) {\n\t\treturn `${noDateMatch[1]}-${noDateMatch[2]}`;\n\t}\n\n\t// Return original if pattern doesn't match\n\treturn modelName;\n}\n\n/**\n * Formats an array of model names for display as a comma-separated string\n * Removes duplicates and sorts alphabetically\n * @param models - Array of model names\n * @returns Formatted string with unique, sorted model names separated by commas\n */\nexport function formatModelsDisplay(models: string[]): string {\n\t// Format array of models for display\n\tconst uniqueModels = uniq(models.map(formatModelName));\n\treturn uniqueModels.sort().join(', ');\n}\n\n/**\n * Formats an array of model names for display with each model on a new line\n * Removes duplicates and sorts alphabetically\n * @param models - Array of model names\n * @returns Formatted string with unique, sorted model names as a bulleted list\n */\nexport function formatModelsDisplayMultiline(models: string[]): string {\n\t// Format array of models for display with newlines and bullet points\n\tconst uniqueModels = uniq(models.map(formatModelName));\n\treturn uniqueModels\n\t\t.sort()\n\t\t.map((model) => `- ${model}`)\n\t\t.join('\\n');\n}\n\n/**\n * Pushes model breakdown rows to a table\n * @param table - The table to push rows to\n * @param table.push - Method to add rows to the table\n * @param breakdowns - Array of model breakdowns\n * @param extraColumns - Number of extra empty columns before the data (default: 1 for models column)\n * @param trailingColumns - Number of extra empty columns after the data (default: 0)\n */\nexport function pushBreakdownRows(\n\ttable: { push: (row: (string | number)[]) => void },\n\tbreakdowns: Array<{\n\t\tmodelName: string;\n\t\tinputTokens: number;\n\t\toutputTokens: number;\n\t\tcacheCreationTokens: number;\n\t\tcacheReadTokens: number;\n\t\tcost: number;\n\t}>,\n\textraColumns = 1,\n\ttrailingColumns = 0,\n): void {\n\tfor (const breakdown of breakdowns) {\n\t\tconst row: (string | number)[] = [`  └─ ${formatModelName(breakdown.modelName)}`];\n\n\t\t// Add extra empty columns before data\n\t\tfor (let i = 0; i < extraColumns; i++) {\n\t\t\trow.push('');\n\t\t}\n\n\t\t// Add data columns with gray styling\n\t\tconst totalTokens =\n\t\t\tbreakdown.inputTokens +\n\t\t\tbreakdown.outputTokens +\n\t\t\tbreakdown.cacheCreationTokens +\n\t\t\tbreakdown.cacheReadTokens;\n\n\t\trow.push(\n\t\t\tpc.gray(formatNumber(breakdown.inputTokens)),\n\t\t\tpc.gray(formatNumber(breakdown.outputTokens)),\n\t\t\tpc.gray(formatNumber(breakdown.cacheCreationTokens)),\n\t\t\tpc.gray(formatNumber(breakdown.cacheReadTokens)),\n\t\t\tpc.gray(formatNumber(totalTokens)),\n\t\t\tpc.gray(formatCurrency(breakdown.cost)),\n\t\t);\n\n\t\t// Add trailing empty columns\n\t\tfor (let i = 0; i < trailingColumns; i++) {\n\t\t\trow.push('');\n\t\t}\n\n\t\ttable.push(row);\n\t}\n}\n\n/**\n * Configuration options for creating usage report tables\n */\nexport type UsageReportConfig = {\n\t/** Name for the first column (Date, Month, Week, Session, etc.) */\n\tfirstColumnName: string;\n\t/** Whether to include Last Activity column (for session reports) */\n\tincludeLastActivity?: boolean;\n\t/** Date formatter function for responsive date formatting */\n\tdateFormatter?: (dateStr: string) => string;\n\t/** Force compact mode regardless of terminal width */\n\tforceCompact?: boolean;\n};\n\n/**\n * Standard usage data structure for table rows\n */\nexport type UsageData = {\n\tinputTokens: number;\n\toutputTokens: number;\n\tcacheCreationTokens: number;\n\tcacheReadTokens: number;\n\ttotalCost: number;\n\tmodelsUsed?: string[];\n};\n\n/**\n * Creates a standard usage report table with consistent styling and layout\n * @param config - Configuration options for the table\n * @returns Configured ResponsiveTable instance\n */\nexport function createUsageReportTable(config: UsageReportConfig): ResponsiveTable {\n\tconst baseHeaders = [\n\t\tconfig.firstColumnName,\n\t\t'Models',\n\t\t'Input',\n\t\t'Output',\n\t\t'Cache Create',\n\t\t'Cache Read',\n\t\t'Total Tokens',\n\t\t'Cost (USD)',\n\t];\n\n\tconst baseAligns: TableCellAlign[] = [\n\t\t'left',\n\t\t'left',\n\t\t'right',\n\t\t'right',\n\t\t'right',\n\t\t'right',\n\t\t'right',\n\t\t'right',\n\t];\n\n\tconst compactHeaders = [config.firstColumnName, 'Models', 'Input', 'Output', 'Cost (USD)'];\n\n\tconst compactAligns: TableCellAlign[] = ['left', 'left', 'right', 'right', 'right'];\n\n\t// Add Last Activity column for session reports\n\tif (config.includeLastActivity ?? false) {\n\t\tbaseHeaders.push('Last Activity');\n\t\tbaseAligns.push('left');\n\t\tcompactHeaders.push('Last Activity');\n\t\tcompactAligns.push('left');\n\t}\n\n\treturn new ResponsiveTable({\n\t\thead: baseHeaders,\n\t\tstyle: { head: ['cyan'] },\n\t\tcolAligns: baseAligns,\n\t\tdateFormatter: config.dateFormatter,\n\t\tcompactHead: compactHeaders,\n\t\tcompactColAligns: compactAligns,\n\t\tcompactThreshold: 100,\n\t\tforceCompact: config.forceCompact,\n\t});\n}\n\n/**\n * Formats a usage data row for display in the table\n * @param firstColumnValue - Value for the first column (date, month, etc.)\n * @param data - Usage data containing tokens and cost information\n * @param lastActivity - Optional last activity value (for session reports)\n * @returns Formatted table row\n */\nexport function formatUsageDataRow(\n\tfirstColumnValue: string,\n\tdata: UsageData,\n\tlastActivity?: string,\n): (string | number)[] {\n\tconst totalTokens =\n\t\tdata.inputTokens + data.outputTokens + data.cacheCreationTokens + data.cacheReadTokens;\n\n\tconst row: (string | number)[] = [\n\t\tfirstColumnValue,\n\t\tdata.modelsUsed != null ? formatModelsDisplayMultiline(data.modelsUsed) : '',\n\t\tformatNumber(data.inputTokens),\n\t\tformatNumber(data.outputTokens),\n\t\tformatNumber(data.cacheCreationTokens),\n\t\tformatNumber(data.cacheReadTokens),\n\t\tformatNumber(totalTokens),\n\t\tformatCurrency(data.totalCost),\n\t];\n\n\tif (lastActivity !== undefined) {\n\t\trow.push(lastActivity);\n\t}\n\n\treturn row;\n}\n\n/**\n * Creates a totals row with yellow highlighting\n * @param totals - Totals data to display\n * @param includeLastActivity - Whether to include an empty last activity column\n * @returns Formatted totals row\n */\nexport function formatTotalsRow(\n\ttotals: UsageData,\n\tincludeLastActivity = false,\n): (string | number)[] {\n\tconst totalTokens =\n\t\ttotals.inputTokens + totals.outputTokens + totals.cacheCreationTokens + totals.cacheReadTokens;\n\n\tconst row: (string | number)[] = [\n\t\tpc.yellow('Total'),\n\t\t'', // Empty for Models column in totals\n\t\tpc.yellow(formatNumber(totals.inputTokens)),\n\t\tpc.yellow(formatNumber(totals.outputTokens)),\n\t\tpc.yellow(formatNumber(totals.cacheCreationTokens)),\n\t\tpc.yellow(formatNumber(totals.cacheReadTokens)),\n\t\tpc.yellow(formatNumber(totalTokens)),\n\t\tpc.yellow(formatCurrency(totals.totalCost)),\n\t];\n\n\tif (includeLastActivity) {\n\t\trow.push(''); // Empty for Last Activity column in totals\n\t}\n\n\treturn row;\n}\n\n/**\n * Adds an empty separator row to the table for visual separation\n * @param table - Table to add separator row to\n * @param columnCount - Number of columns in the table\n */\nexport function addEmptySeparatorRow(table: ResponsiveTable, columnCount: number): void {\n\tconst emptyRow = Array.from({ length: columnCount }, () => '');\n\ttable.push(emptyRow);\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('ResponsiveTable', () => {\n\t\tdescribe('compact mode behavior', () => {\n\t\t\tit('should activate compact mode when terminal width is below threshold', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'Model', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate narrow terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '80';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString(); // This triggers compact mode calculation\n\n\t\t\t\texpect(table.isCompactMode()).toBe(true);\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\n\t\t\tit('should not activate compact mode when terminal width is above threshold', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'Model', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate wide terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '120';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString(); // This triggers compact mode calculation\n\n\t\t\t\texpect(table.isCompactMode()).toBe(false);\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\n\t\t\tit('should not activate compact mode when compactHead is not provided', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate narrow terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '80';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString(); // This triggers compact mode calculation\n\n\t\t\t\texpect(table.isCompactMode()).toBe(false);\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\t\t});\n\n\t\tdescribe('getCurrentTableConfig', () => {\n\t\t\tit('should return compact config when in compact mode', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right'],\n\t\t\t\t\tcompactHead: ['Date', 'Model', 'Cost'],\n\t\t\t\t\tcompactColAligns: ['left', 'left', 'right'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate narrow terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '80';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString(); // This triggers compact mode calculation\n\n\t\t\t\t// Access private method for testing\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access\n\t\t\t\tconst config = (table as any).getCurrentTableConfig();\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\t\t\texpect(config.head).toEqual(['Date', 'Model', 'Cost']);\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\t\t\texpect(config.colAligns).toEqual(['left', 'left', 'right']);\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\n\t\t\tit('should return normal config when not in compact mode', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcolAligns: ['left', 'left', 'right', 'right', 'right'],\n\t\t\t\t\tcompactHead: ['Date', 'Model', 'Cost'],\n\t\t\t\t\tcompactColAligns: ['left', 'left', 'right'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate wide terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '120';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString(); // This triggers compact mode calculation\n\n\t\t\t\t// Access private method for testing\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access\n\t\t\t\tconst config = (table as any).getCurrentTableConfig();\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\t\t\texpect(config.head).toEqual(['Date', 'Model', 'Input', 'Output', 'Cost']);\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\t\t\texpect(config.colAligns).toEqual(['left', 'left', 'right', 'right', 'right']);\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\t\t});\n\n\t\tdescribe('getCompactIndices', () => {\n\t\t\tit('should return correct indices for existing compact headers', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'Model', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate narrow terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '80';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString(); // This triggers compact mode calculation\n\n\t\t\t\t// Access private method for testing\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access\n\t\t\t\tconst indices = (table as any).getCompactIndices();\n\t\t\t\texpect(indices).toEqual([0, 1, 4]); // Date (0), Model (1), Cost (4)\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\n\t\t\tit('should fallback to first column for non-existent headers and log warning', () => {\n\t\t\t\t// Mock logger.warn to capture warning\n\t\t\t\tconst mockLogger = vi.fn();\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'NonExistent', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t\tlogger: mockLogger,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate narrow terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '80';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString(); // This triggers compact mode calculation\n\n\t\t\t\t// Access private method for testing\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access\n\t\t\t\tconst indices = (table as any).getCompactIndices();\n\t\t\t\texpect(indices).toEqual([0, 0, 4]); // Date (0), fallback to first (0), Cost (4)\n\n\t\t\t\t// Verify warning was logged\n\t\t\t\texpect(mockLogger).toHaveBeenCalledWith(\n\t\t\t\t\t'Warning: Compact header \"NonExistent\" not found in table headers [Date, Model, Input, Output, Cost]. Using first column as fallback.',\n\t\t\t\t);\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\n\t\t\tit('should return all indices when not in compact mode', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'Model', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate wide terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '120';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString(); // This triggers compact mode calculation\n\n\t\t\t\t// Access private method for testing\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access\n\t\t\t\tconst indices = (table as any).getCompactIndices();\n\t\t\t\texpect(indices).toEqual([0, 1, 2, 3, 4]); // All columns\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\n\t\t\tit('should return all indices when compactHead is null', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Access private method for testing\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call, ts/no-unsafe-member-access\n\t\t\t\tconst indices = (table as any).getCompactIndices();\n\t\t\t\texpect(indices).toEqual([0, 1, 2, 3, 4]); // All columns\n\t\t\t});\n\t\t});\n\n\t\tdescribe('toString with mocked terminal widths', () => {\n\t\t\tit('should filter columns in compact mode', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate narrow terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '80';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\tconst output = table.toString();\n\n\t\t\t\t// Should be in compact mode\n\t\t\t\texpect(table.isCompactMode()).toBe(true);\n\t\t\t\t// Should contain compact headers\n\t\t\t\texpect(output).toContain('Date');\n\t\t\t\texpect(output).toContain('Cost');\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\n\t\t\tit('should show all columns in normal mode', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS to simulate wide terminal\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tprocess.env.COLUMNS = '150';\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\tconst output = table.toString();\n\n\t\t\t\t// Should contain all headers\n\t\t\t\texpect(output).toContain('Date');\n\t\t\t\texpect(output).toContain('Model');\n\t\t\t\texpect(output).toContain('Input');\n\t\t\t\texpect(output).toContain('Output');\n\t\t\t\texpect(output).toContain('Cost');\n\n\t\t\t\t// Restore original value\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t});\n\n\t\t\tit('should handle process.stdout.columns fallback when COLUMNS env var is not set', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS and process.stdout.columns\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tconst originalStdoutColumns = process.stdout.columns;\n\n\t\t\t\tprocess.env.COLUMNS = undefined;\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\t\t\t(process.stdout as any).columns = 80;\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString();\n\n\t\t\t\texpect(table.isCompactMode()).toBe(true);\n\n\t\t\t\t// Restore original values\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t\tprocess.stdout.columns = originalStdoutColumns;\n\t\t\t});\n\n\t\t\tit('should use default width when both COLUMNS and process.stdout.columns are unavailable', () => {\n\t\t\t\tconst table = new ResponsiveTable({\n\t\t\t\t\thead: ['Date', 'Model', 'Input', 'Output', 'Cost'],\n\t\t\t\t\tcompactHead: ['Date', 'Cost'],\n\t\t\t\t\tcompactThreshold: 100,\n\t\t\t\t});\n\n\t\t\t\t// Mock process.env.COLUMNS and process.stdout.columns\n\t\t\t\tconst originalColumns = process.env.COLUMNS;\n\t\t\t\tconst originalStdoutColumns = process.stdout.columns;\n\n\t\t\t\tprocess.env.COLUMNS = undefined;\n\t\t\t\t// eslint-disable-next-line ts/no-unsafe-member-access\n\t\t\t\t(process.stdout as any).columns = undefined;\n\n\t\t\t\ttable.push(['2024-01-01', 'sonnet-4', '1000', '500', '$1.50']);\n\t\t\t\ttable.toString();\n\n\t\t\t\t// Default width is 120, which is above threshold of 100\n\t\t\t\texpect(table.isCompactMode()).toBe(false);\n\n\t\t\t\t// Restore original values\n\t\t\t\tprocess.env.COLUMNS = originalColumns;\n\t\t\t\tprocess.stdout.columns = originalStdoutColumns;\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('formatNumber', () => {\n\t\tit('formats positive numbers with comma separators', () => {\n\t\t\texpect(formatNumber(1000)).toBe('1,000');\n\t\t\texpect(formatNumber(1000000)).toBe('1,000,000');\n\t\t\texpect(formatNumber(1234567.89)).toBe('1,234,567.89');\n\t\t});\n\n\t\tit('formats small numbers without separators', () => {\n\t\t\texpect(formatNumber(0)).toBe('0');\n\t\t\texpect(formatNumber(1)).toBe('1');\n\t\t\texpect(formatNumber(999)).toBe('999');\n\t\t});\n\n\t\tit('formats negative numbers', () => {\n\t\t\texpect(formatNumber(-1000)).toBe('-1,000');\n\t\t\texpect(formatNumber(-1234567.89)).toBe('-1,234,567.89');\n\t\t});\n\n\t\tit('formats decimal numbers', () => {\n\t\t\texpect(formatNumber(1234.56)).toBe('1,234.56');\n\t\t\texpect(formatNumber(0.123)).toBe('0.123');\n\t\t});\n\n\t\tit('handles edge cases', () => {\n\t\t\texpect(formatNumber(Number.MAX_SAFE_INTEGER)).toBe('9,007,199,254,740,991');\n\t\t\texpect(formatNumber(Number.MIN_SAFE_INTEGER)).toBe('-9,007,199,254,740,991');\n\t\t});\n\t});\n\n\tdescribe('formatCurrency', () => {\n\t\tit('formats positive amounts', () => {\n\t\t\texpect(formatCurrency(10)).toBe('$10.00');\n\t\t\texpect(formatCurrency(100.5)).toBe('$100.50');\n\t\t\texpect(formatCurrency(1234.56)).toBe('$1234.56');\n\t\t});\n\n\t\tit('formats zero', () => {\n\t\t\texpect(formatCurrency(0)).toBe('$0.00');\n\t\t});\n\n\t\tit('formats negative amounts', () => {\n\t\t\texpect(formatCurrency(-10)).toBe('$-10.00');\n\t\t\texpect(formatCurrency(-100.5)).toBe('$-100.50');\n\t\t});\n\n\t\tit('rounds to two decimal places', () => {\n\t\t\texpect(formatCurrency(10.999)).toBe('$11.00');\n\t\t\texpect(formatCurrency(10.994)).toBe('$10.99');\n\t\t\texpect(formatCurrency(10.995)).toBe('$10.99'); // JavaScript's toFixed uses banker's rounding\n\t\t});\n\n\t\tit('handles small decimal values', () => {\n\t\t\texpect(formatCurrency(0.01)).toBe('$0.01');\n\t\t\texpect(formatCurrency(0.001)).toBe('$0.00');\n\t\t\texpect(formatCurrency(0.009)).toBe('$0.01');\n\t\t});\n\n\t\tit('handles large numbers', () => {\n\t\t\texpect(formatCurrency(1000000)).toBe('$1000000.00');\n\t\t\texpect(formatCurrency(9999999.99)).toBe('$9999999.99');\n\t\t});\n\t});\n\n\tdescribe('formatModelsDisplayMultiline', () => {\n\t\tit('formats single model with bullet point', () => {\n\t\t\texpect(formatModelsDisplayMultiline(['claude-sonnet-4-20250514'])).toBe('- sonnet-4');\n\t\t});\n\n\t\tit('formats multiple models with newlines and bullet points', () => {\n\t\t\tconst models = ['claude-sonnet-4-20250514', 'claude-opus-4-20250514'];\n\t\t\texpect(formatModelsDisplayMultiline(models)).toBe('- opus-4\\n- sonnet-4');\n\t\t});\n\n\t\tit('removes duplicates and sorts with bullet points', () => {\n\t\t\tconst models = [\n\t\t\t\t'claude-sonnet-4-20250514',\n\t\t\t\t'claude-opus-4-20250514',\n\t\t\t\t'claude-sonnet-4-20250514',\n\t\t\t];\n\t\t\texpect(formatModelsDisplayMultiline(models)).toBe('- opus-4\\n- sonnet-4');\n\t\t});\n\n\t\tit('handles empty array', () => {\n\t\t\texpect(formatModelsDisplayMultiline([])).toBe('');\n\t\t});\n\n\t\tit('handles models that do not match pattern with bullet points', () => {\n\t\t\tconst models = ['custom-model', 'claude-sonnet-4-20250514'];\n\t\t\texpect(formatModelsDisplayMultiline(models)).toBe('- custom-model\\n- sonnet-4');\n\t\t});\n\n\t\tit('formats Claude 4.5 models correctly', () => {\n\t\t\texpect(formatModelsDisplayMultiline(['claude-sonnet-4-5-20250929'])).toBe('- sonnet-4-5');\n\t\t});\n\n\t\tit('formats mixed model versions', () => {\n\t\t\tconst models = [\n\t\t\t\t'claude-sonnet-4-20250514',\n\t\t\t\t'claude-sonnet-4-5-20250929',\n\t\t\t\t'claude-opus-4-1-20250805',\n\t\t\t];\n\t\t\texpect(formatModelsDisplayMultiline(models)).toBe('- opus-4-1\\n- sonnet-4\\n- sonnet-4-5');\n\t\t});\n\n\t\tit('formats pi-agent prefixed models', () => {\n\t\t\texpect(formatModelsDisplayMultiline(['[pi] claude-opus-4-5'])).toBe('- [pi] opus-4-5');\n\t\t});\n\n\t\tit('formats anthropic/ prefixed models with dot notation', () => {\n\t\t\texpect(formatModelsDisplayMultiline(['anthropic/claude-opus-4.5'])).toBe('- opus-4.5');\n\t\t});\n\n\t\tit('formats models without date suffix', () => {\n\t\t\texpect(formatModelsDisplayMultiline(['claude-opus-4-5'])).toBe('- opus-4-5');\n\t\t\texpect(formatModelsDisplayMultiline(['claude-haiku-4-5'])).toBe('- haiku-4-5');\n\t\t});\n\n\t\tit('formats pi-agent model with anthropic prefix', () => {\n\t\t\texpect(formatModelsDisplayMultiline(['[pi] anthropic/claude-opus-4.5'])).toBe(\n\t\t\t\t'- [pi] opus-4.5',\n\t\t\t);\n\t\t});\n\t});\n\n\tdescribe('formatDateCompact', () => {\n\t\tit('should format date to compact format with newline', () => {\n\t\t\tconst result = formatDateCompact('2024-08-04', undefined, 'en-US');\n\t\t\texpect(result).toBe('2024\\n08-04');\n\t\t});\n\n\t\tit('should handle timezone parameter', () => {\n\t\t\tconst result = formatDateCompact('2024-08-04T12:00:00Z', 'UTC', 'en-US');\n\t\t\texpect(result).toBe('2024\\n08-04');\n\t\t});\n\n\t\tit('should handle YYYY-MM-DD format dates', () => {\n\t\t\tconst result = formatDateCompact('2024-08-04', undefined, 'en-US');\n\t\t\texpect(result).toBe('2024\\n08-04');\n\t\t});\n\n\t\tit('should handle timezone with YYYY-MM-DD format', () => {\n\t\t\tconst result = formatDateCompact('2024-08-04', 'UTC', 'en-US');\n\t\t\texpect(result).toBe('2024\\n08-04');\n\t\t});\n\n\t\tit('should use default locale when not specified', () => {\n\t\t\tconst result = formatDateCompact('2024-08-04');\n\t\t\texpect(result).toBe('2024\\n08-04');\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "packages/terminal/src/utils.ts",
    "content": "import type { WriteStream } from 'node:tty';\nimport process from 'node:process';\nimport * as ansiEscapes from 'ansi-escapes';\nimport stringWidth from 'string-width';\n\n// DEC synchronized output mode - prevents screen flickering by buffering all terminal writes\n// until flush() is called. Think of it like double-buffering in graphics programming.\n// Reference: https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036\nconst SYNC_START = '\\x1B[?2026h'; // Start sync mode\nconst SYNC_END = '\\x1B[?2026l'; // End sync mode\n\n// Line wrap control sequences\nconst DISABLE_LINE_WRAP = '\\x1B[?7l'; // Disable automatic line wrapping\nconst ENABLE_LINE_WRAP = '\\x1B[?7h'; // Enable automatic line wrapping\n\n// ANSI reset sequence\nconst ANSI_RESET = '\\u001B[0m'; // Reset all formatting and colors\n\n/**\n * Manages terminal state for live updates\n * Provides a clean interface for terminal operations with automatic TTY checking\n * and cursor state management for live monitoring displays\n */\nexport class TerminalManager {\n\tprivate stream: WriteStream;\n\tprivate cursorHidden = false;\n\tprivate buffer: string[] = [];\n\tprivate useBuffering = false;\n\tprivate alternateScreenActive = false;\n\tprivate syncMode = false;\n\n\tconstructor(stream: WriteStream = process.stdout) {\n\t\tthis.stream = stream;\n\t}\n\n\t/**\n\t * Hides the terminal cursor for cleaner live updates\n\t * Only works in TTY environments (real terminals)\n\t */\n\thideCursor(): void {\n\t\tif (!this.cursorHidden && this.stream.isTTY) {\n\t\t\t// Only hide cursor in TTY environments to prevent issues with non-interactive streams\n\t\t\tthis.stream.write(ansiEscapes.cursorHide);\n\t\t\tthis.cursorHidden = true;\n\t\t}\n\t}\n\n\t/**\n\t * Shows the terminal cursor\n\t * Should be called during cleanup to restore normal terminal behavior\n\t */\n\tshowCursor(): void {\n\t\tif (this.cursorHidden && this.stream.isTTY) {\n\t\t\tthis.stream.write(ansiEscapes.cursorShow);\n\t\t\tthis.cursorHidden = false;\n\t\t}\n\t}\n\n\t/**\n\t * Clears the entire screen and moves cursor to top-left corner\n\t * Essential for live monitoring displays that need to refresh completely\n\t */\n\tclearScreen(): void {\n\t\tif (this.stream.isTTY) {\n\t\t\t// Only clear screen in TTY environments to prevent issues with non-interactive streams\n\t\t\tthis.stream.write(ansiEscapes.clearScreen);\n\t\t\tthis.stream.write(ansiEscapes.cursorTo(0, 0));\n\t\t}\n\t}\n\n\t/**\n\t * Writes text to the terminal stream\n\t * Supports buffering mode for performance optimization\n\t */\n\twrite(text: string): void {\n\t\tif (this.useBuffering) {\n\t\t\tthis.buffer.push(text);\n\t\t} else {\n\t\t\tthis.stream.write(text);\n\t\t}\n\t}\n\n\t/**\n\t * Enables buffering mode - collects all writes in memory instead of sending immediately\n\t * This prevents flickering when doing many rapid updates\n\t */\n\tstartBuffering(): void {\n\t\tthis.useBuffering = true;\n\t\tthis.buffer = [];\n\t}\n\n\t/**\n\t * Sends all buffered content to terminal at once\n\t * This creates smooth, atomic updates without flickering\n\t */\n\tflush(): void {\n\t\tif (this.useBuffering && this.buffer.length > 0) {\n\t\t\t// Wrap output in sync mode for truly atomic screen updates\n\t\t\tif (this.syncMode && this.stream.isTTY) {\n\t\t\t\tthis.stream.write(SYNC_START + this.buffer.join('') + SYNC_END);\n\t\t\t} else {\n\t\t\t\tthis.stream.write(this.buffer.join(''));\n\t\t\t}\n\t\t\tthis.buffer = [];\n\t\t}\n\t\tthis.useBuffering = false;\n\t}\n\n\t/**\n\t * Switches to alternate screen buffer (like vim/less does)\n\t * This preserves what was on screen before and allows full-screen apps\n\t */\n\tenterAlternateScreen(): void {\n\t\tif (!this.alternateScreenActive && this.stream.isTTY) {\n\t\t\tthis.stream.write(ansiEscapes.enterAlternativeScreen);\n\t\t\t// Turn off line wrapping to prevent text from breaking badly\n\t\t\tthis.stream.write(DISABLE_LINE_WRAP);\n\t\t\tthis.alternateScreenActive = true;\n\t\t}\n\t}\n\n\t/**\n\t * Returns to normal screen, restoring what was there before\n\t */\n\texitAlternateScreen(): void {\n\t\tif (this.alternateScreenActive && this.stream.isTTY) {\n\t\t\t// Re-enable line wrap\n\t\t\tthis.stream.write(ENABLE_LINE_WRAP);\n\t\t\tthis.stream.write(ansiEscapes.exitAlternativeScreen);\n\t\t\tthis.alternateScreenActive = false;\n\t\t}\n\t}\n\n\t/**\n\t * Enables sync mode - terminal will wait for END signal before showing updates\n\t * Prevents the user from seeing partial/torn screen updates\n\t */\n\tenableSyncMode(): void {\n\t\tthis.syncMode = true;\n\t}\n\n\t/**\n\t * Disables synchronized output mode\n\t */\n\tdisableSyncMode(): void {\n\t\tthis.syncMode = false;\n\t}\n\n\t/**\n\t * Gets terminal width in columns\n\t * Falls back to 80 columns if detection fails\n\t */\n\tget width(): number {\n\t\treturn this.stream.columns || 80;\n\t}\n\n\t/**\n\t * Gets terminal height in rows\n\t * Falls back to 24 rows if detection fails\n\t */\n\tget height(): number {\n\t\treturn this.stream.rows || 24;\n\t}\n\n\t/**\n\t * Returns true if output goes to a real terminal (not a file or pipe)\n\t * We only send fancy ANSI codes to real terminals\n\t */\n\tget isTTY(): boolean {\n\t\treturn this.stream.isTTY ?? false;\n\t}\n\n\t/**\n\t * Restores terminal to normal state - MUST call before program exits\n\t * Otherwise user's terminal might be left in a broken state\n\t */\n\tcleanup(): void {\n\t\tthis.showCursor();\n\t\tthis.exitAlternateScreen();\n\t\tthis.disableSyncMode();\n\t}\n}\n\n/**\n * Creates a progress bar string with customizable appearance\n *\n * Example: createProgressBar(75, 100, 20) -> \"[████████████████░░░░] 75.0%\"\n *\n * @param value - Current progress value\n * @param max - Maximum value (100% point)\n * @param width - Character width of the progress bar (excluding brackets and text)\n * @param options - Customization options for appearance and display\n * @param options.showPercentage - Whether to show percentage after the bar\n * @param options.showValues - Whether to show current/max values\n * @param options.fillChar - Character for filled portion (default: '█')\n * @param options.emptyChar - Character for empty portion (default: '░')\n * @param options.leftBracket - Left bracket character (default: '[')\n * @param options.rightBracket - Right bracket character (default: ']')\n * @param options.colors - Color configuration for different thresholds\n * @param options.colors.low - Color for low percentage values\n * @param options.colors.medium - Color for medium percentage values\n * @param options.colors.high - Color for high percentage values\n * @param options.colors.critical - Color for critical percentage values\n * @returns Formatted progress bar string with optional percentage/values\n */\nexport function createProgressBar(\n\tvalue: number,\n\tmax: number,\n\twidth: number,\n\toptions: {\n\t\tshowPercentage?: boolean;\n\t\tshowValues?: boolean;\n\t\tfillChar?: string;\n\t\temptyChar?: string;\n\t\tleftBracket?: string;\n\t\trightBracket?: string;\n\t\tcolors?: {\n\t\t\tlow?: string;\n\t\t\tmedium?: string;\n\t\t\thigh?: string;\n\t\t\tcritical?: string;\n\t\t};\n\t} = {},\n): string {\n\tconst {\n\t\tshowPercentage = true,\n\t\tshowValues = false,\n\t\tfillChar = '█',\n\t\temptyChar = '░',\n\t\tleftBracket = '[',\n\t\trightBracket = ']',\n\t\tcolors = {},\n\t} = options;\n\n\tconst percentage = max > 0 ? Math.min(100, (value / max) * 100) : 0;\n\tconst fillWidth = Math.round((percentage / 100) * width);\n\tconst emptyWidth = width - fillWidth;\n\n\t// Determine color based on percentage\n\tlet color = '';\n\tif (colors.critical != null && percentage >= 90) {\n\t\tcolor = colors.critical;\n\t} else if (colors.high != null && percentage >= 80) {\n\t\tcolor = colors.high;\n\t} else if (colors.medium != null && percentage >= 50) {\n\t\tcolor = colors.medium;\n\t} else if (colors.low != null) {\n\t\tcolor = colors.low;\n\t}\n\n\t// Build progress bar\n\tlet bar = leftBracket;\n\tif (color !== '') {\n\t\tbar += color;\n\t}\n\tbar += fillChar.repeat(fillWidth);\n\tbar += emptyChar.repeat(emptyWidth);\n\tif (color !== '') {\n\t\tbar += ANSI_RESET; // Reset color\n\t}\n\tbar += rightBracket;\n\n\t// Add percentage or values\n\tif (showPercentage) {\n\t\tbar += ` ${percentage.toFixed(1)}%`;\n\t}\n\tif (showValues) {\n\t\tbar += ` (${value}/${max})`;\n\t}\n\n\treturn bar;\n}\n\n/**\n * Centers text within a specified width using spaces for padding\n *\n * Uses string-width to handle Unicode characters and ANSI escape codes properly.\n * If text is longer than width, returns original text without truncation.\n *\n * Example: centerText(\"Hello\", 10) -> \"  Hello   \"\n *\n * @param text - Text to center (may contain ANSI color codes)\n * @param width - Total character width including padding\n * @returns Text with spaces added for centering\n */\nexport function centerText(text: string, width: number): string {\n\tconst textLength = stringWidth(text);\n\tif (textLength >= width) {\n\t\treturn text;\n\t}\n\n\tconst leftPadding = Math.floor((width - textLength) / 2);\n\tconst rightPadding = width - textLength - leftPadding;\n\n\treturn ' '.repeat(leftPadding) + text + ' '.repeat(rightPadding);\n}\n\n// Using below sequences causes issues in some terminals such as NeoVim.\n// - ansiEscapes.cursorSavePosition (\\u001B[s = Save Cursor)\n// - ansiEscapes.cursorRestorePosition (\\u001B[u = Unsave Cursor)\n//\n// Instead, we use:\n// - \\u001B7 = Save Cursor & Attrs\n// - \\u001B8 = Restore Cursor & Attrs\n//\n// see: https://www2.ccs.neu.edu/research/gpc/VonaUtils/vona/terminal/vtansi.htm\nconst SAVE_CURSOR = '\\u001B7';\nconst RESTORE_CURSOR = '\\u001B8';\n/**\n * Draws an emoji with consistent 2-character width regardless of terminal behavior\n * @param emoji The emoji to draw\n * @returns A string containing ANSI escape sequences and the emoji\n */\nexport function drawEmoji(emoji: string): string {\n\treturn `${SAVE_CURSOR}${emoji}${RESTORE_CURSOR}${ansiEscapes.cursorForward(stringWidth(emoji))}`;\n}\n\nif (import.meta.vitest != null) {\n\tdescribe('drawEmoji', () => {\n\t\tit('should always return a string with width as same as original', () => {\n\t\t\t// 2-width emojis\n\t\t\texpect(stringWidth(drawEmoji('⏱️'))).toBe(2);\n\t\t\texpect(stringWidth(drawEmoji('🔥'))).toBe(2);\n\t\t\texpect(stringWidth(drawEmoji('📈'))).toBe(2);\n\t\t\texpect(stringWidth(drawEmoji('⚙️'))).toBe(2);\n\t\t\texpect(stringWidth(drawEmoji('❌'))).toBe(2);\n\t\t\texpect(stringWidth(drawEmoji('⚠️'))).toBe(2);\n\t\t\texpect(stringWidth(drawEmoji('⚡'))).toBe(2);\n\n\t\t\t// 1-width emojis\n\t\t\texpect(stringWidth(drawEmoji('✓'))).toBe(1);\n\t\t\texpect(stringWidth(drawEmoji('↻'))).toBe(1);\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "packages/terminal/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"jsx\": \"react-jsx\",\n\t\t// Environment setup & latest features\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"moduleDetection\": \"force\",\n\t\t\"module\": \"Preserve\",\n\t\t// Bundler mode\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"types\": [\"vitest/globals\", \"vitest/importMeta\"],\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"allowJs\": true,\n\t\t// Best practices\n\t\t\"strict\": true,\n\t\t\"noFallthroughCasesInSwitch\": true,\n\t\t\"noImplicitOverride\": true,\n\t\t\"noPropertyAccessFromIndexSignature\": false,\n\t\t\"noUncheckedIndexedAccess\": true,\n\t\t// Some stricter flags (disabled by default)\n\t\t\"noUnusedLocals\": false,\n\t\t\"noUnusedParameters\": false,\n\t\t\"noEmit\": true,\n\t\t\"verbatimModuleSyntax\": true,\n\t\t\"erasableSyntaxOnly\": true,\n\t\t\"skipLibCheck\": true\n\t},\n\t\"include\": [\"src/**/*.ts\", \"vitest.config.ts\"],\n\t\"exclude\": [\"dist\"]\n}\n"
  },
  {
    "path": "packages/terminal/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\twatch: false,\n\t\tglobals: true,\n\t\tincludeSource: ['src/**/*.ts'],\n\t},\n\tdefine: {\n\t\t'import.meta.vitest': 'undefined',\n\t},\n});\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - apps/*\n  - docs\n  - packages/*\n\ncatalogMode: strict\n\ncatalogs:\n  build:\n    '@oxc-project/runtime': ^0.82.3\n    tsdown: ^0.16.6\n    unplugin-macros: ^0.18.0\n    unplugin-unused: ^0.5.2\n  docs:\n    '@ryoppippi/vite-plugin-cloudflare-redirect': ^1.1.2\n    typedoc: ^0.28.10\n    typedoc-plugin-markdown: ^4.8.1\n    typedoc-vitepress-theme: ^1.1.2\n    vitepress: ^1.6.4\n    vitepress-plugin-group-icons: ^1.6.3\n    vitepress-plugin-llms: ^1.7.3\n    wrangler: ^4.32.0\n  lint:\n    '@ryoppippi/eslint-config': ^0.4.0\n    eslint: ^9.33.0\n    eslint-plugin-format: ^1.0.2\n    oxfmt: ^0.23.0\n    publint: ^0.3.12\n  llm-docs:\n    '@gunshi/docs': ^0.27.5\n    '@praha/byethrow-docs': ^0.9.0\n  release:\n    bumpp: ^10.2.3\n    changelogithub: ^13.16.1\n    clean-pkg-json: ^1.3.0\n    lint-staged: ^16.1.5\n    pkg-pr-new: ^0.0.60\n  runtime:\n    '@antfu/utils': ^9.2.1\n    '@hono/mcp': ^0.1.5\n    '@hono/node-server': ^1.19.7\n    '@modelcontextprotocol/sdk': ^1.24.3\n    '@praha/byethrow': ^0.6.3\n    '@ryoppippi/limo': jsr:^0.2.2\n    '@std/async': jsr:^1.0.14\n    ansi-escapes: ^7.0.0\n    cli-table3: ^0.6.5\n    consola: ^3.4.2\n    es-toolkit: ^1.39.10\n    fast-sort: ^3.4.1\n    get-stdin: ^9.0.0\n    gunshi: ^0.26.3\n    hono: ^4.9.2\n    nano-spawn: ^1.0.3\n    p-limit: ^7.1.0\n    path-type: ^6.0.0\n    picocolors: ^1.1.1\n    pretty-ms: ^9.2.0\n    string-width: ^7.2.0\n    tinyglobby: ^0.2.14\n    type-fest: ^4.41.0\n    valibot: ^1.1.0\n    xdg-basedir: ^5.1.0\n    zod: ^4.1.13\n  testing:\n    fs-fixture: ^2.8.1\n    vitest: ^4.0.15\n  types:\n    '@types/bun': ^1.3.5\n    '@types/node': ^24.10.1\n    '@types/react': ^19.1.13\n    '@typescript/native-preview': ^7.0.0-dev.20251225.1\n\nenablePrePostScripts: true\n\nminimumReleaseAge: 2880\n\n# Security settings for supply chain attack prevention\nstrictDepBuilds: true\nblockExoticSubdeps: true\ntrustPolicy: no-downgrade\n\n# Explicitly allow build scripts for packages that require them\n# (replaces onlyBuiltDependencies)\nallowBuilds:\n  esbuild: true\n  sharp: true\n  sqlite3: true\n  workerd: true\n\nshellEmulator: true\n\nenableGlobalVirtualStore: true\n"
  },
  {
    "path": "typos.toml",
    "content": "[default]\nlocale = 'en-us'\nextend-ignore-re = [\n\t\"(?s)(#|//)\\\\s*spellchecker:off.*?\\\\n\\\\s*(#|//)\\\\s*spellchecker:on\",\n\t\"(?s)<!--\\\\s*spellchecker:off.*?\\\\n\\\\s*spellchecker:on\\\\s*-->\",\n\t\"(?Rm)^.*#\\\\s*spellchecker:disable-line$\",\n\t\"(?m)^.*<!--\\\\s*spellchecker:disable-line\\\\s*-->\\\\n.*$\",\n]\n\n[default.extend-words]\ncolor = \"color\"\n\n[files]\nignore-hidden = false\nextend-exclude = [\".git\", \"node_modules\", \"pnpm-lock.yaml\", \"pnpm-workspace.yaml\"]\n"
  }
]