diff --git a/.gitignore b/.gitignore index 5ef6a52..43ab50f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +**/node_modules/ /.pnp .pnp.* .yarn/* @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +.vscode/ # debug npm-debug.log* @@ -36,6 +37,13 @@ yarn-error.log* # vercel .vercel +# token-tracker local blob mock +/.token-tracker-local/ + # typescript *.tsbuildinfo next-env.d.ts + +# compiled output for standalone packages +/cli/dist +/mcp/dist diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..b0ad710 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,64 @@ +# silver-token-tracker + +Reads local logs from Claude Code, Codex CLI, and Gemini CLI and prints a terminal table showing token usage and estimated cost per model. + +No account, no server, no network calls — everything runs locally. + +## Install + +This package will be published to npm by the silver-dev-org maintainers. Install instructions will be added once the official package is live. + +For now, you can run the CLI directly from this repo: + +```sh +git clone https://github.com/silver-dev-org/open-silver.git +cd open-silver/cli +bun install && bun run build +node dist/index.js run +``` + +## Usage + +```sh +silver-token-tracker run # show the visual table (default when run with no args) +silver-token-tracker run --json # output raw JSON (useful for piping into other tools) +silver-token-tracker --help # show help and supported sources +``` + +## What it looks like + +``` + Claude Code + ──────────────────────────────────────────────────────────────────────── + ┌───────────────────────────┬───────┬────────┬────────────┬─────────────┬────────────┐ + │ Model │ Input │ Output │ Cache Read │ Cache Write │ Cost (USD) │ + ├───────────────────────────┼───────┼────────┼────────────┼─────────────┼────────────┤ + │ claude-sonnet-4-6 │ 8.7K │ 1.0M │ 52.2M │ 3.5M │ $44.57 │ + │ claude-haiku-4-5-20251001 │ 4.4K │ 67.6K │ 5.2M │ 571.3K │ $1.26 │ + └───────────────────────────┴───────┴────────┴────────────┴─────────────┴────────────┘ + + ──────────────────────────────────────────────────────────────────────── + TOTAL 1 source · 62.6M tokens · $45.83 + ──────────────────────────────────────────────────────────────────────── +``` + +Source headers and the cost column are color-highlighted in the terminal. + +## Supported sources + +| Source | Log location | +|--------|-------------| +| Claude Code | `~/.claude/projects/**/*.jsonl` | +| Codex CLI | `~/.codex/history/*.json` | +| Gemini CLI | `~/.gemini/logs/*.json` | + +**Cursor is not supported.** The usage schema changed in Cursor v3.1+ and the new format does not expose per-model token counts in a stable way. + +## Notes + +- Token counts are aggregated across all sessions found on disk (all-time, not filtered by date). +- Costs are estimated using public API pricing — they may differ from what you are actually billed if you are on a subscription plan. + +--- + +**Reference implementation:** A working build is published on npm under `@ftaboadac/silver-token-tracker` for early testing purposes. This is not the canonical install path — the official package will be published by the silver-dev-org maintainers once the repo is transferred. diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 0000000..0ce1bf7 --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,122 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "silver-tracker", + "dependencies": { + "cli-table3": "^0.6.5", + "picocolors": "^1.1.1", + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", + "typescript": "^5.4.0", + }, + "optionalDependencies": { + "better-sqlite3": "^12.9.0", + }, + }, + }, + "packages": { + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-sqlite3": ["better-sqlite3@12.9.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.90.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..7c16e9d --- /dev/null +++ b/cli/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ftaboadac/silver-token-tracker", + "version": "0.2.0", + "description": "Read local Claude Code, Codex CLI, and Gemini CLI logs and print token usage + cost in the terminal", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ftaboadac/open-silver.git", + "directory": "cli" + }, + "type": "module", + "bin": { + "silver-token-tracker": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18.17.0" + }, + "dependencies": { + "cli-table3": "^0.6.5", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", + "typescript": "^5.4.0" + }, + "optionalDependencies": { + "better-sqlite3": "^12.9.0" + } +} diff --git a/cli/src/collector.ts b/cli/src/collector.ts new file mode 100644 index 0000000..c94120a --- /dev/null +++ b/cli/src/collector.ts @@ -0,0 +1,34 @@ +import { parseClaudeCodeUsage } from "./parsers/claude-code.js"; +import { parseCodexUsage } from "./parsers/codex.js"; +import { parseGeminiUsage } from "./parsers/gemini.js"; +import type { ModelUsage, SourceReport, UsageSource } from "./types.js"; + +const PARSERS: Array<{ source: UsageSource; parse: () => Promise }> = [ + { source: "claude-code", parse: parseClaudeCodeUsage }, + { source: "codex", parse: parseCodexUsage }, + { source: "gemini-cli", parse: parseGeminiUsage }, +]; + +export async function collectUsage(): Promise { + const now = new Date().toISOString(); + const results = await Promise.allSettled(PARSERS.map(({ parse }) => parse())); + const sources: SourceReport[] = []; + + for (let i = 0; i < PARSERS.length; i++) { + const { source } = PARSERS[i]; + const result = results[i]; + + if (result.status === "rejected") { + const message = result.reason instanceof Error ? result.reason.message : String(result.reason); + console.error(`Warning (${source}): ${message}`); + continue; + } + + const models = result.value; + if (models.length === 0) continue; + + sources.push({ source, models, lastSyncedAt: now }); + } + + return sources; +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..28ccfdf --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import pc from "picocolors"; +import Table from "cli-table3"; +import { collectUsage } from "./collector.js"; +import type { SourceReport } from "./types.js"; + +const args = process.argv.slice(2); +const jsonMode = args.includes("--json"); +const helpMode = args.includes("--help") || args.includes("-h"); +const command = args.find((a) => !a.startsWith("-")) ?? "run"; + +const SOURCE_LABELS: Record = { + "claude-code": "Claude Code", + codex: "Codex CLI", + "gemini-cli": "Gemini CLI", +}; + +const HELP = ` +${pc.bold("silver-token-tracker")} — local AI token usage reporter + +${pc.bold("Usage:")} + silver-token-tracker [run] [--json] [--help] + +${pc.bold("Commands:")} + run Read local logs and display token usage (default) + +${pc.bold("Flags:")} + --json Output raw JSON instead of the visual table + --help, -h Show this help message + +${pc.bold("Supported sources:")} + • Claude Code (~/.claude/projects/**/*.jsonl) + • Codex CLI (~/.codex/history/*.json) + • Gemini CLI (~/.gemini/logs/*.json) + + Cursor is not supported (usage schema changed in v3.1+). +`.trimStart(); + +function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +function fmtCost(n: number): string { + if (n >= 0.01) return `$${n.toFixed(2)}`; + return `$${n.toFixed(4)}`; +} + +function renderVisual(sources: SourceReport[]): void { + const rule = pc.dim("─".repeat(72)); + + let totalTokens = 0; + let totalCost = 0; + + for (const { source, models } of sources) { + const label = SOURCE_LABELS[source] ?? source; + console.log(); + console.log(pc.cyan(pc.bold(` ${label}`))); + console.log(rule); + + const table = new Table({ + head: [ + pc.bold("Model"), + pc.bold("Input"), + pc.bold("Output"), + pc.bold("Cache Read"), + pc.bold("Cache Write"), + pc.bold("Cost (USD)"), + ], + colAligns: ["left", "right", "right", "right", "right", "right"], + style: { head: [], border: [], compact: true }, + }); + + for (const m of models) { + totalTokens += m.inputTokens + m.outputTokens + m.cacheReadTokens + m.cacheWriteTokens; + totalCost += m.costUsd; + table.push([ + m.model, + fmtTokens(m.inputTokens), + fmtTokens(m.outputTokens), + fmtTokens(m.cacheReadTokens), + fmtTokens(m.cacheWriteTokens), + pc.yellow(fmtCost(m.costUsd)), + ]); + } + + console.log(table.toString()); + } + + console.log(); + console.log(rule); + const sourcePart = `${sources.length} source${sources.length !== 1 ? "s" : ""}`; + console.log( + ` ${pc.bold("TOTAL")} ${sourcePart} · ${pc.bold(fmtTokens(totalTokens) + " tokens")} · ${pc.yellow(pc.bold(fmtCost(totalCost)))}`, + ); + console.log(rule); + console.log(); +} + +async function main() { + if (helpMode) { + process.stdout.write(HELP + "\n"); + return; + } + + if (command !== "run") { + console.error(`Unknown command: ${command}`); + console.error("Run silver-token-tracker --help for usage."); + process.exit(1); + } + + const sources = await collectUsage(); + + if (sources.length === 0) { + console.log( + "No usage data found. Make sure you have used Claude Code, Codex CLI, or Gemini CLI on this machine.", + ); + return; + } + + if (jsonMode) { + console.log(JSON.stringify(sources, null, 2)); + return; + } + + renderVisual(sources); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/cli/src/parsers/claude-code.ts b/cli/src/parsers/claude-code.ts new file mode 100644 index 0000000..1fabb30 --- /dev/null +++ b/cli/src/parsers/claude-code.ts @@ -0,0 +1,111 @@ +import type { Dirent } from "fs"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { aggregateToModelUsage } from "../pricing.js"; +import type { ModelUsage } from "../types.js"; + +const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); +const SKIP_IF_MODIFIED_WITHIN_MS = 30_000; + +type MessageUsage = { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; +}; + +type AssistantMessage = { + id: string; + model: string; + usage: MessageUsage; +}; + +type AssistantEntry = { + type: "assistant"; + message: AssistantMessage; +}; + +function isAssistantEntry(value: unknown): value is AssistantEntry { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + if (v["type"] !== "assistant") return false; + const msg = v["message"]; + if (typeof msg !== "object" || msg === null) return false; + const m = msg as Record; + if (typeof m["id"] !== "string" || typeof m["model"] !== "string") return false; + const usage = m["usage"]; + return typeof usage === "object" && usage !== null; +} + +async function findJsonlFiles(dir: string): Promise { + const files: string[] = []; + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return files; + } + await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = await findJsonlFiles(fullPath); + files.push(...nested); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + files.push(fullPath); + } + }), + ); + return files; +} + +async function parseFile( + filePath: string, + seen: Map, +): Promise { + try { + const stat = await fs.stat(filePath); + if (Date.now() - stat.mtimeMs < SKIP_IF_MODIFIED_WITHIN_MS) return; + } catch { + return; + } + + let text: string; + try { + text = await fs.readFile(filePath, "utf8"); + } catch { + return; + } + + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + if (!isAssistantEntry(parsed)) continue; + // Overwrite on duplicate id — last occurrence has the most complete usage snapshot + seen.set(parsed.message.id, parsed.message); + } +} + +export async function parseClaudeCodeUsage(): Promise { + const files = await findJsonlFiles(CLAUDE_PROJECTS_DIR); + const seen = new Map(); + + await Promise.all(files.map((f) => parseFile(f, seen))); + + const rawUsages = Array.from(seen.values()).map((msg) => ({ + model: msg.model, + inputTokens: msg.usage.input_tokens, + outputTokens: msg.usage.output_tokens, + cacheWriteTokens: msg.usage.cache_creation_input_tokens ?? 0, + cacheReadTokens: msg.usage.cache_read_input_tokens ?? 0, + })); + + return aggregateToModelUsage(rawUsages); +} diff --git a/cli/src/parsers/codex.ts b/cli/src/parsers/codex.ts new file mode 100644 index 0000000..964edeb --- /dev/null +++ b/cli/src/parsers/codex.ts @@ -0,0 +1,219 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { aggregateToModelUsage } from "../pricing.js"; +import type { ModelUsage } from "../types.js"; + +type CodexTokenUsage = { + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + reasoning_output_tokens?: number; + total_tokens?: number; +}; + +type CodexEntry = { + type: string; + timestamp?: string; + payload?: { + type?: string; + model?: string; + session_id?: string; + originator?: string; + info?: { + model?: string; + model_name?: string; + last_token_usage?: CodexTokenUsage; + total_token_usage?: CodexTokenUsage; + }; + }; +}; + +function getCodexSessionsDir(): string { + const codexHome = + process.env["CODEX_HOME"] ?? path.join(os.homedir(), ".codex"); + return path.join(codexHome, "sessions"); +} + +async function findRollupFiles(sessionsDir: string): Promise { + const files: string[] = []; + let years: string[]; + try { + years = await fs.readdir(sessionsDir); + } catch { + return files; + } + for (const year of years) { + if (!/^\d{4}$/.test(year)) continue; + const yearDir = path.join(sessionsDir, year); + let months: string[]; + try { + months = await fs.readdir(yearDir); + } catch { + continue; + } + for (const month of months) { + if (!/^\d{2}$/.test(month)) continue; + const monthDir = path.join(yearDir, month); + let days: string[]; + try { + days = await fs.readdir(monthDir); + } catch { + continue; + } + for (const day of days) { + if (!/^\d{2}$/.test(day)) continue; + const dayDir = path.join(monthDir, day); + let dayFiles: string[]; + try { + dayFiles = await fs.readdir(dayDir); + } catch { + continue; + } + for (const file of dayFiles) { + if (file.startsWith("rollup-") && file.endsWith(".jsonl")) { + files.push(path.join(dayDir, file)); + } + } + } + } + } + return files; +} + +type RawUsage = { + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; +}; + +async function parseFile(filePath: string): Promise { + let text: string; + try { + text = await fs.readFile(filePath, "utf8"); + } catch { + return []; + } + + const entries: CodexEntry[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed) as CodexEntry); + } catch { + continue; + } + } + + // Pass 1: track the active model at each entry index. + // Model info flows from session_meta and turn_context entries + // (turn_context carries model at payload.model or payload.info.model). + let currentModel: string | undefined; + const modelAtIndex: (string | undefined)[] = new Array(entries.length); + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (e.type === "session_meta" && e.payload?.model) { + currentModel = e.payload.model; + } else if (e.type === "turn_context") { + const m = + e.payload?.model ?? + e.payload?.info?.model ?? + e.payload?.info?.model_name; + if (m) currentModel = m; + } + modelAtIndex[i] = currentModel; + } + + // Pass 2: extract token counts from event_msg/token_count entries and + // correlate them with the model context captured in pass 1. + const usages: RawUsage[] = []; + let prevCumulativeTotal = 0; + let prevInput = 0; + let prevCached = 0; + let prevOutput = 0; + let prevReasoning = 0; + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (e.type !== "event_msg" || e.payload?.type !== "token_count") continue; + + const info = e.payload.info; + if (!info) continue; + + // Skip intermediate duplicates — same cumulative total means no new tokens + const cumulativeTotal = info.total_token_usage?.total_tokens ?? 0; + if (cumulativeTotal > 0 && cumulativeTotal === prevCumulativeTotal) continue; + prevCumulativeTotal = cumulativeTotal; + + const last = info.last_token_usage; + let inputTokens = 0; + let cachedInputTokens = 0; + let outputTokens = 0; + let reasoningTokens = 0; + + if (last) { + // Per-turn delta is directly available + inputTokens = last.input_tokens ?? 0; + cachedInputTokens = last.cached_input_tokens ?? 0; + outputTokens = last.output_tokens ?? 0; + reasoningTokens = last.reasoning_output_tokens ?? 0; + } else if (cumulativeTotal > 0) { + // Derive delta from cumulative totals + const total = info.total_token_usage; + if (!total) continue; + inputTokens = (total.input_tokens ?? 0) - prevInput; + cachedInputTokens = (total.cached_input_tokens ?? 0) - prevCached; + outputTokens = (total.output_tokens ?? 0) - prevOutput; + reasoningTokens = (total.reasoning_output_tokens ?? 0) - prevReasoning; + } + + // Advance cumulative baselines only when using the total_token_usage path + if (!last) { + const total = info.total_token_usage; + if (total) { + prevInput = total.input_tokens ?? 0; + prevCached = total.cached_input_tokens ?? 0; + prevOutput = total.output_tokens ?? 0; + prevReasoning = total.reasoning_output_tokens ?? 0; + } + } + + if (inputTokens + cachedInputTokens + outputTokens + reasoningTokens === 0) + continue; + + // Resolve model: try the token_count entry's own payload fields first, + // then fall back to the context captured in pass 1 + const model = + e.payload.model ?? + info.model ?? + info.model_name ?? + modelAtIndex[i] ?? + "gpt-5"; + + // cached_input_tokens is a subset of input_tokens (same as Gemini) + const freshInput = Math.max(0, inputTokens - cachedInputTokens); + + usages.push({ + model, + inputTokens: freshInput, + // reasoning tokens are billed at the output rate + outputTokens: outputTokens + reasoningTokens, + cacheWriteTokens: 0, + cacheReadTokens: cachedInputTokens, + }); + } + + return usages; +} + +export async function parseCodexUsage(): Promise { + const sessionsDir = getCodexSessionsDir(); + const files = await findRollupFiles(sessionsDir); + if (files.length === 0) return []; + + const allResults = await Promise.all(files.map(parseFile)); + return aggregateToModelUsage(allResults.flat()); +} diff --git a/cli/src/parsers/gemini.ts b/cli/src/parsers/gemini.ts new file mode 100644 index 0000000..e73e73e --- /dev/null +++ b/cli/src/parsers/gemini.ts @@ -0,0 +1,187 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { aggregateToModelUsage } from "../pricing.js"; +import type { ModelUsage } from "../types.js"; + +const GEMINI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp"); + +type GeminiTokens = { + input?: number; + output?: number; + cached?: number; + thoughts?: number; +}; + +type GeminiMessage = { + type: string; + tokens?: GeminiTokens; + model?: string; +}; + +type GeminiSession = { + sessionId: string; + messages: GeminiMessage[]; +}; + +type SessionResult = { + sessionId: string; + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; +}; + +function extractSessionUsage(session: GeminiSession): SessionResult | null { + const geminiMsgs = session.messages.filter( + (m) => m.type === "gemini" && m.tokens != null && m.model, + ); + if (geminiMsgs.length === 0) return null; + + let model = ""; + let totalInput = 0; + let totalOutput = 0; + let totalCached = 0; + + for (const msg of geminiMsgs) { + const t = msg.tokens!; + if (!model && msg.model) model = msg.model; + totalInput += t.input ?? 0; + // thoughts tokens are billed as output + totalOutput += (t.output ?? 0) + (t.thoughts ?? 0); + totalCached += t.cached ?? 0; + } + + if (totalInput === 0 && totalOutput === 0) return null; + + // tokens.input includes cached tokens as a subset — subtract to avoid double-charging + const freshInput = Math.max(0, totalInput - totalCached); + + return { + sessionId: session.sessionId, + model, + inputTokens: freshInput, + outputTokens: totalOutput, + cacheWriteTokens: 0, + cacheReadTokens: totalCached, + }; +} + +function tryParseAsJson(raw: string): GeminiSession | null { + try { + const parsed = JSON.parse(raw) as Record; + if ( + typeof parsed["sessionId"] === "string" && + Array.isArray(parsed["messages"]) + ) { + return parsed as unknown as GeminiSession; + } + } catch { + // not valid JSON or wrong shape + } + return null; +} + +function tryParseAsJsonl(raw: string): GeminiSession | null { + const lines = raw.split("\n").filter((l) => l.trim()); + if (lines.length === 0) return null; + + let sessionId = ""; + const messages: GeminiMessage[] = []; + + for (const line of lines) { + let obj: Record; + try { + obj = JSON.parse(line) as Record; + } catch { + continue; + } + // skip MongoDB-style update operations that Gemini CLI may write + if (obj["$set"] !== undefined) continue; + if (typeof obj["sessionId"] === "string" && !sessionId) { + sessionId = obj["sessionId"] as string; + } else if (typeof obj["type"] === "string") { + messages.push(obj as unknown as GeminiMessage); + } + } + + if (!sessionId) return null; + return { sessionId, messages }; +} + +async function parseFile(filePath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, "utf8"); + } catch { + return null; + } + + // Try single JSON first (Gemini CLI <= 0.38), then JSONL (>= 0.39) + const session = tryParseAsJson(raw) ?? tryParseAsJsonl(raw); + if (!session) return null; + + return extractSessionUsage(session); +} + +async function findSessionFiles(): Promise { + const files: string[] = []; + let projectDirs: string[]; + try { + const entries = await fs.readdir(GEMINI_TMP_DIR, { withFileTypes: true }); + projectDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return files; + } + + for (const project of projectDirs) { + const chatsDir = path.join(GEMINI_TMP_DIR, project, "chats"); + let chatFiles: string[]; + try { + const entries = await fs.readdir(chatsDir); + chatFiles = entries.filter( + (f) => + f.startsWith("session-") && + (f.endsWith(".json") || f.endsWith(".jsonl")), + ); + } catch { + continue; + } + for (const file of chatFiles) { + files.push(path.join(chatsDir, file)); + } + } + return files; +} + +export async function parseGeminiUsage(): Promise { + const files = await findSessionFiles(); + if (files.length === 0) return []; + + const allResults = await Promise.all(files.map(parseFile)); + + // Deduplicate by sessionId in case both .json and .jsonl exist for the same session + const seen = new Set(); + const rawUsages: { + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; + }[] = []; + + for (const result of allResults) { + if (!result || seen.has(result.sessionId)) continue; + seen.add(result.sessionId); + rawUsages.push({ + model: result.model, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + cacheWriteTokens: result.cacheWriteTokens, + cacheReadTokens: result.cacheReadTokens, + }); + } + + return aggregateToModelUsage(rawUsages); +} diff --git a/cli/src/pricing.ts b/cli/src/pricing.ts new file mode 100644 index 0000000..ae6b269 --- /dev/null +++ b/cli/src/pricing.ts @@ -0,0 +1,111 @@ +import type { ModelUsage } from "./types.js"; + +type ModelPrice = { + inputPerMTok: number; + outputPerMTok: number; +}; + +// USD per million tokens — update when Anthropic/Google changes API pricing +const MODEL_PRICES: Record = { + "claude-opus-4": { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + "claude-sonnet-4": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-haiku-4": { inputPerMTok: 0.8, outputPerMTok: 4.0 }, + "claude-3-5-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-3-5-haiku": { inputPerMTok: 0.8, outputPerMTok: 4.0 }, + "claude-3-opus": { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + "claude-3-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-3-haiku": { inputPerMTok: 0.25, outputPerMTok: 1.25 }, + // OpenAI (API pricing, May 2026) + "gpt-5": { inputPerMTok: 1.25, outputPerMTok: 10.0 }, + "gpt-5-mini": { inputPerMTok: 0.25, outputPerMTok: 2.0 }, + "gpt-4.1": { inputPerMTok: 2.0, outputPerMTok: 8.0 }, + "gpt-4.1-mini": { inputPerMTok: 0.40, outputPerMTok: 1.60 }, + "gpt-4.1-nano": { inputPerMTok: 0.10, outputPerMTok: 0.40 }, + "gpt-4o": { inputPerMTok: 2.50, outputPerMTok: 10.0 }, + "gpt-4o-mini": { inputPerMTok: 0.15, outputPerMTok: 0.60 }, + "o3": { inputPerMTok: 2.0, outputPerMTok: 8.0 }, + "o4-mini": { inputPerMTok: 1.10, outputPerMTok: 4.40 }, + // Gemini (Google AI API pricing, May 2026) + "gemini-2.5-pro": { inputPerMTok: 1.25, outputPerMTok: 10.0 }, + "gemini-2.5-flash": { inputPerMTok: 0.30, outputPerMTok: 2.50 }, + "gemini-2.0-flash": { inputPerMTok: 0.10, outputPerMTok: 0.40 }, + "gemini-1.5-pro": { inputPerMTok: 1.25, outputPerMTok: 5.0 }, + "gemini-1.5-flash": { inputPerMTok: 0.075, outputPerMTok: 0.30 }, + "gemini-1.0-pro": { inputPerMTok: 0.50, outputPerMTok: 1.50 }, + // Cursor-specific model names — dot-notation Claude variants and alternate families + // Claude 3.x (Cursor uses dot notation; the existing entries use dash notation) + "claude-3.5-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-3.5-haiku": { inputPerMTok: 0.80, outputPerMTok: 4.0 }, + "claude-3.7-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + // Claude 4 family (Cursor uses "claude-4-*" while Anthropic API uses "claude-*-4") + "claude-4-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-4-opus": { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + "claude-4.5-sonnet": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + "claude-4.5-opus": { inputPerMTok: 15.0, outputPerMTok: 75.0 }, + // Cursor-native models (pricing not publicly disclosed; conservative estimates) + "cursor-small": { inputPerMTok: 0.20, outputPerMTok: 0.80 }, + "cursor-fast": { inputPerMTok: 0.20, outputPerMTok: 0.80 }, + "cursor-auto": { inputPerMTok: 3.0, outputPerMTok: 15.0 }, + // Legacy GPT (may appear in older Cursor history) + "gpt-4-turbo": { inputPerMTok: 10.0, outputPerMTok: 30.0 }, + "gpt-4": { inputPerMTok: 30.0, outputPerMTok: 60.0 }, +}; + +const FALLBACK_PRICE: ModelPrice = { inputPerMTok: 3.0, outputPerMTok: 15.0 }; + +function priceForModel(model: string): ModelPrice { + let best: ModelPrice | null = null; + let bestLen = 0; + for (const [prefix, price] of Object.entries(MODEL_PRICES)) { + if (model.startsWith(prefix) && prefix.length > bestLen) { + best = price; + bestLen = prefix.length; + } + } + return best ?? FALLBACK_PRICE; +} + +export function calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + cacheWriteTokens: number, + cacheReadTokens: number, +): number { + const { inputPerMTok, outputPerMTok } = priceForModel(model); + return ( + (inputTokens / 1_000_000) * inputPerMTok + + (outputTokens / 1_000_000) * outputPerMTok + + (cacheWriteTokens / 1_000_000) * inputPerMTok * 1.25 + + (cacheReadTokens / 1_000_000) * inputPerMTok * 0.1 + ); +} + +type RawUsage = { + model: string; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheReadTokens: number; +}; + +export function aggregateToModelUsage(usages: RawUsage[]): ModelUsage[] { + const map = new Map(); + for (const u of usages) { + const existing = map.get(u.model); + if (existing) { + existing.inputTokens += u.inputTokens; + existing.outputTokens += u.outputTokens; + existing.cacheWriteTokens += u.cacheWriteTokens; + existing.cacheReadTokens += u.cacheReadTokens; + } else { + map.set(u.model, { ...u }); + } + } + return Array.from(map.values()) + .filter((u) => u.inputTokens + u.outputTokens + u.cacheWriteTokens + u.cacheReadTokens > 0) + .map((u) => ({ + ...u, + costUsd: calculateCost(u.model, u.inputTokens, u.outputTokens, u.cacheWriteTokens, u.cacheReadTokens), + })); +} diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 0000000..2f4a99f --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,27 @@ +export type UsageSource = + | "claude-code" + | "cursor" + | "codex" + | "gemini-cli" + | "aider"; + +export type ModelUsage = { + model: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + costUsd: number; +}; + +export type SourceReport = { + source: UsageSource; + models: ModelUsage[]; + lastSyncedAt: string; +}; + +export type UserReport = { + email: string; + sources: SourceReport[]; + updatedAt: string; +}; diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..6e27728 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/tsconfig.json b/tsconfig.json index c133409..f68ef58 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "cli"] }