turntax 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,138 +1,242 @@
1
- # turntax
2
-
3
- Coding agents re-send their instruction files with every turn. `turntax` measures that fixed per-turn cost for a repository, for every agent that reads it, and shows where to cut it.
4
-
5
- ```bash
6
- npx turntax
7
- ```
8
-
9
- Try it in the browser, with nothing uploaded: https://torutdev.github.io/turntax/
10
-
11
- ## Supported agents
12
-
13
- Each agent's documented loading rules decide what is sent on every turn, what loads only when the agent works in a folder or touches a matching file, and what is ignored.
14
-
15
- | Agent | Files it reads from the repository |
16
- | --- | --- |
17
- | Aider | files listed under `read:` in `.aider.conf.yml` (`CONVENTIONS.md` only when listed or passed with `--read`) |
18
- | Amp | `AGENTS.md` (or `AGENT.md` / `CLAUDE.md`), nested `AGENTS.md`, @-mentioned files |
19
- | Claude Code | `CLAUDE.md`, `CLAUDE.local.md`, `.claude/rules`, skills, commands, subagents, `AGENTS.md` when there is no `CLAUDE.md` |
20
- | Cline | `.clinerules/`, `.cline/rules/` |
21
- | Continue | `.continue/rules/` |
22
- | Cursor | `.cursor/rules/*.mdc`, `.cursorrules`, `AGENTS.md`, `CLAUDE.md` |
23
- | Gemini CLI | `GEMINI.md` |
24
- | GitHub Copilot | `.github/copilot-instructions.md`, `.github/instructions/`, `AGENTS.md` |
25
- | Goose | `.goosehints`, `AGENTS.md` |
26
- | JetBrains Junie | `.junie/AGENTS.md`, or `AGENTS.md` with `.junie/rules/`, or `.junie/guidelines` |
27
- | Kiro | `.kiro/steering/`, `AGENTS.md` |
28
- | OpenAI Codex | `AGENTS.md`, `AGENTS.override.md`, `.agents/skills` |
29
- | OpenCode | `AGENTS.md`, or `CLAUDE.md` when there is none |
30
- | Roo Code | `.roo/rules/`, `.roo/rules-{mode}/`, `.roorules`, `AGENTS.md` |
31
- | Windsurf (Devin Desktop) | `.devin/rules/`, `.windsurf/rules/`, `.windsurfrules`, `AGENTS.md` |
32
- | Zed | the first of `.rules`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.github/copilot-instructions.md`, `AGENT.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` |
33
-
34
- Agents that load exactly the same files share one row in the report:
35
-
36
- ```text
37
- always o200k/turn on-demand model tokens/turn counted by 100 turns cached agents
38
- 4 272 53 (no selected model) Claude Code
39
- 1 63 0 GPT-6.1 Sol 63 estimate (o200k_base proxy) $0.01 $0.0006 OpenAI Codex, Roo Code, Kiro, JetBrains Junie, Goose, OpenCode
40
- 1 17 0 (no selected model) Gemini CLI
41
- 4 297 37 GPT-6.1 Sol 297 estimate (o200k_base proxy) $0.06 $0.0030 Cursor
42
- 2 74 8 GPT-6.1 Sol 74 estimate (o200k_base proxy) $0.01 $0.0007 GitHub Copilot
43
- 2 75 0 GPT-6.1 Sol 75 estimate (o200k_base proxy) $0.01 $0.0008 Windsurf (Devin Desktop)
44
- 1 11 0 GPT-6.1 Sol 11 estimate (o200k_base proxy) $0.0022 $0.0001 Zed
45
- 1 63 19 GPT-6.1 Sol 63 estimate (o200k_base proxy) $0.01 $0.0006 Amp
46
-
47
- Advice (savings are estimates in o200k tokens per turn)
48
- HIGH duplicate CLAUDE.md, AGENTS.md [saves ~48 tokens/turn]
49
- 3 identical lines are loaded twice every turn by cursor. Keep them in one file.
50
- HIGH contradiction AGENTS.md, CLAUDE.md [saves ~11 tokens/turn]
51
- Conflicting package manager: "Always use npm ..." (AGENTS.md:3) vs "Always use pnpm ..." (CLAUDE.md:5).
52
- MEDIUM scopable-section CLAUDE.md:11 [saves ~128 tokens/turn]
53
- Section "API package" is mostly about packages/api/. Move it to packages/api/AGENTS.md ...
54
- ```
55
-
56
- (Excerpt of the output for [`test/fixtures/mixed`](test/fixtures/mixed) with `--models gpt-6.1-sol`.)
57
-
58
- ## What it counts, and how
59
-
60
- | Model family | How tokens are counted | Label |
61
- | --- | --- | --- |
62
- | OpenAI GPT-5.x, gpt-oss | Offline with `o200k_base` (the encoding OpenAI's tiktoken maps them to) | exact |
63
- | OpenAI GPT-6.x | Offline with `o200k_base`; OpenAI has not published a newer encoding | estimate |
64
- | Claude | Only Anthropic's `count_tokens` API, with your key and `--claude-api` | API count |
65
- | Gemini | Only Google's `countTokens` API, with your key and `--gemini-api` | API count |
66
-
67
- Claude's tokenizer is not public, and Anthropic's docs note that newer Claude models produce noticeably more tokens than older ones for the same text. `turntax` never shows an OpenAI count as a Claude or Gemini number: without a key those rows say "needs API key". The `o200k` column is a labelled reference count for every file.
68
-
69
- Cost per N turns = tokens per turn × N × input price. The "cached" column assumes every turn reads the context from the prompt cache. Prices come from a dated table with a source link per model (`turntax --list-models`); pass `--prices my-prices.json` to use your own.
70
-
71
- See [docs/how-it-counts.md](docs/how-it-counts.md) for the loading rules per agent and their sources.
72
-
73
- ## Advice
74
-
75
- All checks are deterministic. Each one shows an estimated saving in o200k tokens per turn.
76
-
77
- - **duplicate**: identical lines in two files. A per-turn saving only when one tool loads both; otherwise flagged as drift risk.
78
- - **contradiction**: conflicting rules reaching the same agent (package manager, indentation, quotes, semicolons).
79
- - **scopable-section**: a section mostly about one directory that could load only there (nested file, path-scoped rule, `applyTo`).
80
- - **oversized-section**: a large always-loaded section that could become an on-demand skill.
81
- - **size-limit**: documented limits (CLAUDE.md under 200 lines, Codex's 32 KiB cut-off, Windsurf's 12,000 characters, Cursor's 500 lines).
82
- - **misconfigured**: files a tool ignores, such as `.md` files in `.cursor/rules` or Windsurf rules without a `trigger`.
83
- - **stale-reference**: referenced files or `npm run` scripts that no longer exist.
84
- - **non-english**: non-English sections. The token multiplier is measured only against an English version you supply (`--english CLAUDE.md=CLAUDE.en.md`) or, if you opt in, a Claude translation (`--translate`).
85
-
86
- ## Options
87
-
88
- ```text
89
- turntax [path]
90
- --tools <ids> agents to report (default: every agent that reads a file); see --help
91
- --models <ids> see --list-models (default: claude-sonnet-5.5, gpt-6.1-sol, gemini-3.8-flash)
92
- --turns <n> cost projection (default 100)
93
- --json | --md | --badge output format (default: terminal table)
94
- --out <file> write the output to a file
95
- --max-tokens <n> exit 1 when any tool loads more than n o200k tokens per turn (CI gate)
96
- --claude-api exact Claude counts (ANTHROPIC_API_KEY)
97
- --gemini-api Gemini counts (GEMINI_API_KEY)
98
- --english <file=en> measure a language multiplier against your English version
99
- --translate opt-in Claude translation for that measurement (needs --claude-api)
100
- --prices <file.json> your own price table
101
- --section-tokens <n> threshold for oversized sections (default 600)
102
- ```
103
-
104
- ## Privacy
105
-
106
- Offline by default: no network calls, no telemetry. File contents are sent only when you pass `--claude-api`, `--gemini-api` or `--translate`, and only to that vendor's API. Keys are read from environment variables and sent only in request headers.
107
-
108
- ## Web page
109
-
110
- [`web/`](web) is a static page that runs the same engine in the browser with the offline tokenizer only. Drop a repository folder or paste files; nothing is uploaded.
111
-
112
- ```bash
113
- npm run build:web
114
- ```
115
-
116
- ## Badge
117
-
118
- ```bash
119
- npx turntax --badge
120
- ```
121
-
122
- [![context tax (o200k)](https://img.shields.io/badge/context%20tax%20(o200k)-297%20tokens%2Fturn-brightgreen)](https://github.com/TorutDev/turntax)
123
-
124
- The badge shows the largest always-loaded total among the tools found, counted with o200k_base.
125
-
126
- ## Limits
127
-
128
- - Assumes the agent starts at the repository root. Files outside the repository (user-level `~/.claude/CLAUDE.md`, global rules, team rules in a dashboard) are not counted.
129
- - Counts instruction files only, not the agent's own system prompt, tool definitions or MCP servers.
130
- - Loading rules change. Each rule cites the vendor page and the date it was checked; please open an issue when one is out of date.
131
-
132
- ## Contributing
133
-
134
- Loading rules and prices go out of date. Corrections with a link to the vendor's page are the most useful contribution; see [CONTRIBUTING.md](CONTRIBUTING.md).
135
-
136
- ## License
137
-
138
- MIT
1
+ # turntax
2
+
3
+ Coding agents re-send their instruction files with every turn. `turntax` measures that fixed per-turn cost for a repository, for every agent that reads it, and shows where to cut it.
4
+
5
+ ```bash
6
+ npx turntax
7
+ ```
8
+
9
+ Try it in the browser, with nothing uploaded: https://torutdev.github.io/turntax/ — drop a folder or enter a public GitHub repository. Links like `https://torutdev.github.io/turntax/?repo=owner/name` open straight to that repository's report.
10
+
11
+ ## Supported agents
12
+
13
+ Each agent's documented loading rules decide what is sent on every turn, what loads only when the agent works in a folder or touches a matching file, and what is ignored.
14
+
15
+ | Agent | Files it reads from the repository |
16
+ | --- | --- |
17
+ | Aider | files listed under `read:` in `.aider.conf.yml` (`CONVENTIONS.md` only when listed or passed with `--read`) |
18
+ | Amp | `AGENTS.md` (or `AGENT.md` / `CLAUDE.md`), nested `AGENTS.md`, @-mentioned files |
19
+ | Claude Code | `CLAUDE.md`, `CLAUDE.local.md`, `.claude/rules`, skills, commands, subagents, `AGENTS.md` when there is no `CLAUDE.md` |
20
+ | Cline | `.clinerules/`, `.cline/rules/` |
21
+ | Continue | `.continue/rules/` |
22
+ | Cursor | `.cursor/rules/*.mdc`, `.cursorrules`, `AGENTS.md`, `CLAUDE.md` |
23
+ | Gemini CLI | `GEMINI.md` |
24
+ | GitHub Copilot | `.github/copilot-instructions.md`, `.github/instructions/`, `AGENTS.md` |
25
+ | Goose | `.goosehints`, `AGENTS.md` |
26
+ | JetBrains Junie | `.junie/AGENTS.md`, or `AGENTS.md` with `.junie/rules/`, or `.junie/guidelines` |
27
+ | Kiro | `.kiro/steering/`, `AGENTS.md` |
28
+ | OpenAI Codex | `AGENTS.md`, `AGENTS.override.md`, `.agents/skills` |
29
+ | OpenCode | `AGENTS.md`, or `CLAUDE.md` when there is none |
30
+ | Roo Code | `.roo/rules/`, `.roo/rules-{mode}/`, `.roorules`, `AGENTS.md` |
31
+ | Windsurf (Devin Desktop) | `.devin/rules/`, `.windsurf/rules/`, `.windsurfrules`, `AGENTS.md` |
32
+ | Zed | the first of `.rules`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.github/copilot-instructions.md`, `AGENT.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` |
33
+
34
+ Agents that load exactly the same files share one row in the report:
35
+
36
+ ```text
37
+ always o200k/turn on-demand model tokens/turn counted by 100 turns cached agents
38
+ 4 272 53 (no selected model) Claude Code
39
+ 1 63 0 GPT-6.1 Sol 63 estimate (o200k_base proxy) $0.01 $0.0006 OpenAI Codex, Roo Code, Kiro, JetBrains Junie, Goose, OpenCode
40
+ 1 17 0 (no selected model) Gemini CLI
41
+ 4 297 37 GPT-6.1 Sol 297 estimate (o200k_base proxy) $0.06 $0.0030 Cursor
42
+ 2 74 8 GPT-6.1 Sol 74 estimate (o200k_base proxy) $0.01 $0.0007 GitHub Copilot
43
+ 2 75 0 GPT-6.1 Sol 75 estimate (o200k_base proxy) $0.01 $0.0008 Windsurf (Devin Desktop)
44
+ 1 11 0 GPT-6.1 Sol 11 estimate (o200k_base proxy) $0.0022 $0.0001 Zed
45
+ 1 63 19 GPT-6.1 Sol 63 estimate (o200k_base proxy) $0.01 $0.0006 Amp
46
+
47
+ Advice (savings are estimates in o200k tokens per turn)
48
+ HIGH duplicate CLAUDE.md, AGENTS.md [saves ~48 tokens/turn]
49
+ 3 identical lines are loaded twice every turn by cursor. Keep them in one file.
50
+ HIGH contradiction AGENTS.md, CLAUDE.md [saves ~11 tokens/turn]
51
+ Conflicting package manager: "Always use npm ..." (AGENTS.md:3) vs "Always use pnpm ..." (CLAUDE.md:5).
52
+ MEDIUM scopable-section CLAUDE.md:11 [saves ~128 tokens/turn]
53
+ Section "API package" is mostly about packages/api/. Move it to packages/api/AGENTS.md ...
54
+ ```
55
+
56
+ (Excerpt of the output for [`test/fixtures/mixed`](test/fixtures/mixed) with `--models gpt-6.1-sol`.)
57
+
58
+ ## What it counts, and how
59
+
60
+ | Model family | How tokens are counted | Label |
61
+ | --- | --- | --- |
62
+ | OpenAI GPT-5.x, gpt-oss | Offline with `o200k_base` (the encoding OpenAI's tiktoken maps them to) | exact |
63
+ | OpenAI GPT-6.x | Offline with `o200k_base`; OpenAI has not published a newer encoding | estimate |
64
+ | Claude | Only Anthropic's `count_tokens` API, with your key and `--claude-api` | API count |
65
+ | Gemini | Only Google's `countTokens` API, with your key and `--gemini-api` | API count |
66
+
67
+ Claude's tokenizer is not public, and Anthropic's docs note that newer Claude models produce noticeably more tokens than older ones for the same text. `turntax` never shows an OpenAI count as a Claude or Gemini number: without a key those rows say "needs API key". The `o200k` column is a labelled reference count for every file.
68
+
69
+ Cost per N turns = tokens per turn × N × input price. The "cached" column assumes every turn reads the context from the prompt cache. Prices come from a dated table with a source link per model (`turntax --list-models`); pass `--prices my-prices.json` to use your own.
70
+
71
+ See [docs/how-it-counts.md](docs/how-it-counts.md) for the loading rules per agent and their sources.
72
+
73
+ ## Advice
74
+
75
+ All checks are deterministic. Each one shows an estimated saving in o200k tokens per turn.
76
+
77
+ - **duplicate**: identical lines in two files. A per-turn saving only when one tool loads both; otherwise flagged as drift risk.
78
+ - **contradiction**: conflicting rules reaching the same agent (package manager, indentation, quotes, semicolons).
79
+ - **scopable-section**: a section mostly about one directory that could load only there (nested file, path-scoped rule, `applyTo`).
80
+ - **oversized-section**: a large always-loaded section that could become an on-demand skill.
81
+ - **size-limit**: documented limits (CLAUDE.md under 200 lines, Codex's 32 KiB cut-off, Windsurf's 12,000 characters, Cursor's 500 lines).
82
+ - **misconfigured**: files a tool ignores, such as `.md` files in `.cursor/rules` or Windsurf rules without a `trigger`.
83
+ - **stale-reference**: referenced files or `npm run` scripts that no longer exist.
84
+ - **non-english**: non-English sections. The token multiplier is measured only against an English version you supply (`--english CLAUDE.md=CLAUDE.en.md`) or, if you opt in, a Claude translation (`--translate`).
85
+
86
+ ## Fixing
87
+
88
+ ```bash
89
+ npx turntax --fix # print a diff of the safe fixes; nothing is changed
90
+ npx turntax --fix --write # apply them
91
+ ```
92
+
93
+ `--fix` handles three kinds of advice:
94
+
95
+ - **duplicate**: removes lines from a file when every agent that reads the file also reads the same lines every turn from another file. Skill, command and subagent files are left whole.
96
+ - **scopable-section**: moves a section into the folder it is about: the same file name there (`packages/api/CLAUDE.md`), a nested `AGENTS.md`, or a Claude Code rule with `paths:`. The folder the section is about must already exist, and sections with `@imports` or relative links stay put. A section moves together with its subsections.
97
+ - **misconfigured**: renames `.md` files in `.cursor/rules` to `.mdc`.
98
+
99
+ Before a fix is kept, the files are classified again with the change applied: every agent must still read every line it read before, including files reached through `@imports`, and as often as before. The only exception is a moved section, which may load only when the agent works in that folder. Otherwise the fix is skipped and the reason is printed. A moved section can reach agents that did not read it before (a nested `AGENTS.md` is read by more agents than a `CLAUDE.md`); the summary names them. By default that check covers every supported agent, so a section of `AGENTS.md` usually stays where it is, because some agents read only the root file. `--tools codex,claude-code` limits the check to the agents you use.
100
+
101
+ In preview the diff is on stdout and works with `git apply`, and the summary is on stderr; with `--write` the summary is on stdout. `--write` checks that no file changed since it was read and writes nothing if one did. Symlinked files, their targets and files in symlinked folders are never changed. Contradictions and stale references need a person to decide, so `--fix` leaves them alone.
102
+
103
+ ## MCP servers
104
+
105
+ Every MCP server an agent starts adds its tool definitions (names, descriptions, input schemas) to the context. `turntax` reads the agents' MCP configs and lists the servers each agent starts:
106
+
107
+ | Agent | Project config | User config |
108
+ | --- | --- | --- |
109
+ | Claude Code | `.mcp.json` | `~/.claude.json` (user and per-project servers) |
110
+ | Cursor | `.cursor/mcp.json` | `~/.cursor/mcp.json` |
111
+ | GitHub Copilot (VS Code) | `.vscode/mcp.json` | |
112
+ | Gemini CLI | `.gemini/settings.json` | `~/.gemini/settings.json` |
113
+ | Kiro | `.kiro/settings/mcp.json` | `~/.kiro/settings/mcp.json` |
114
+ | Roo Code | `.roo/mcp.json` | |
115
+ | OpenAI Codex | `.codex/config.toml` (trusted projects) | `~/.codex/config.toml` |
116
+ | OpenCode | `opencode.json` | `~/.config/opencode/opencode.json` |
117
+ | Zed | `.zed/settings.json` | |
118
+ | Windsurf (Devin Desktop) | | `~/.config/devin/mcp_config.json` |
119
+
120
+ Counting the tool definitions needs the servers running, so it is opt-in:
121
+
122
+ ```bash
123
+ npx turntax --mcp
124
+ ```
125
+
126
+ `--mcp` starts each stdio server with the command from its config (in the repository folder) and connects to streamable HTTP servers, asks for `tools/list`, and counts the definitions as JSON with o200k. A server several agents share is started once. Environment variables in the config (`${VAR}`, `${VAR:-default}`, `${env:VAR}`) are expanded; servers that need a VS Code `${input:...}` prompt, legacy SSE servers and servers that fail or time out (`--mcp-timeout`, default 30 s) are listed with the reason and not counted.
127
+
128
+ Claude Code defers MCP tool definitions by default (tool search): only the tool names are in context until a tool is used, so its row counts the names and shows the full size separately. A server with `alwaysLoad: true` is counted in full.
129
+
130
+ MCP tokens are an o200k estimate and are not included in the model cost columns.
131
+
132
+ ## User-level files
133
+
134
+ Agents also read instruction files from your home directory in every project. `--include-user` adds them in a separate `user/turn` column and a `total`:
135
+
136
+ ```bash
137
+ npx turntax --include-user
138
+ ```
139
+
140
+ It reads only the documented locations: `~/.claude/` (CLAUDE.md, rules, skills, commands, agents), `~/.codex/AGENTS.md`, `~/.agents/skills`, `~/.gemini/GEMINI.md`, Windsurf's `global_rules.md`, Cline's global rules folders, `~/.roo/rules*`, `~/.kiro/steering`, `~/.config/goose/.goosehints`, Amp's `~/.config/AGENTS.md` and `~/.config/amp/AGENTS.md`, and `~/.config/opencode/AGENTS.md` (OpenCode falls back to `~/.claude/CLAUDE.md`), plus the user MCP configs above. Contradictions and duplicates between user-level and repository files are reported. User-level files never count toward the badge or `--max-tokens`, so the result does not depend on who runs it.
141
+
142
+ ## Comparing with a baseline
143
+
144
+ ```bash
145
+ npx turntax --compare origin/main # instruction files at a git ref, read with git show
146
+ npx turntax --json --out before.json # or save a report ...
147
+ npx turntax --baseline before.json # ... and compare with it later
148
+ npx turntax --compare origin/main --max-increase 200 # exit 1 when any agent grows by more than 200 tokens
149
+ ```
150
+
151
+ The comparison covers repository instruction files (o200k), per agent and per file, and lists MCP servers that were added or removed. It never changes the working tree.
152
+
153
+ ## GitHub Action
154
+
155
+ The repository is also an Action. On pull requests it compares with the base branch, posts one comment (updated on every push) and writes the report to the job summary:
156
+
157
+ ```yaml
158
+ name: turntax
159
+ on: pull_request
160
+ permissions:
161
+ contents: read
162
+ pull-requests: write
163
+ jobs:
164
+ turntax:
165
+ runs-on: ubuntu-latest
166
+ steps:
167
+ - uses: actions/checkout@v4
168
+ - uses: TorutDev/turntax@v0.4.0
169
+ with:
170
+ max-increase: 500 # optional: fail when an agent grows by more than 500 tokens per turn
171
+ # max-tokens: 8000 # optional: absolute budget
172
+ # args: --tools claude-code,cursor
173
+ ```
174
+
175
+ The comment opens with a table of the change per agent and per file, and folds the full report below it. Pull requests from forks get a read-only token, so the comment step is skipped there; the job summary still has the report.
176
+
177
+ ## Options
178
+
179
+ ```text
180
+ turntax [path]
181
+ --tools <ids> agents to report (default: every agent that reads a file); see --help
182
+ --models <ids> see --list-models (default: claude-sonnet-5.5, gpt-6.1-sol, gemini-3.8-flash)
183
+ --turns <n> cost projection (default 100)
184
+ --json | --md | --badge output format (default: terminal table)
185
+ --pr-comment Markdown for a pull request comment (change first, full report folded)
186
+ --out <file> write the output to a file
187
+ --max-tokens <n> exit 1 when any tool loads more than n o200k tokens per turn (CI gate)
188
+ --include-user add user-level files and MCP servers from your home directory
189
+ --mcp start the configured MCP servers and count their tool definitions
190
+ --mcp-timeout <s> seconds to wait for each MCP server (default 30)
191
+ --baseline <file.json> compare with an earlier --json report
192
+ --compare <git-ref> compare with the instruction files at a git ref
193
+ --max-increase <n> exit 1 when any tool grows by more than n tokens per turn vs the baseline
194
+ --claude-api exact Claude counts (ANTHROPIC_API_KEY)
195
+ --gemini-api Gemini counts (GEMINI_API_KEY)
196
+ --english <file=en> measure a language multiplier against your English version
197
+ --translate opt-in Claude translation for that measurement (needs --claude-api)
198
+ --prices <file.json> your own price table
199
+ --section-tokens <n> threshold for oversized sections (default 600)
200
+ --fix print a diff of the safe fixes (--tools limits the agents whose context is kept)
201
+ --write with --fix, apply the changes
202
+ ```
203
+
204
+ ## Privacy
205
+
206
+ Offline by default: no network calls, no telemetry. File contents are sent only when you pass `--claude-api`, `--gemini-api` or `--translate`, and only to that vendor's API. Keys are read from environment variables and sent only in request headers.
207
+
208
+ `--mcp` runs the commands in the MCP configs it finds and connects to the configured HTTP servers, as the agents themselves would. Run it only on repositories whose configs you trust. `turntax` prints each command before starting it. `--include-user` reads files in your home directory and never sends them anywhere. `--fix --write` is the only option that changes files, and only repository files.
209
+
210
+ ## Web page
211
+
212
+ [`web/`](web) is a static page that runs the same engine in the browser with the offline tokenizer only. Drop a repository folder or paste files; nothing is uploaded.
213
+
214
+ It can also load a public GitHub repository: it lists the files with the GitHub API (`api.github.com`, unauthenticated, 60 requests per hour) and downloads only the instruction files, and files they `@import`, from `raw.githubusercontent.com`. Those are the only network requests the page makes, and only when you load a repository or open a `?repo=` link.
215
+
216
+ ```bash
217
+ npm run build:web
218
+ ```
219
+
220
+ ## Badge
221
+
222
+ ```bash
223
+ npx turntax --badge
224
+ ```
225
+
226
+ [![context tax (o200k)](https://img.shields.io/badge/context%20tax%20(o200k)-297%20tokens%2Fturn-brightgreen)](https://github.com/TorutDev/turntax)
227
+
228
+ The badge shows the largest always-loaded total among the tools found, counted with o200k_base: repository files plus measured project MCP tools, never user-level files.
229
+
230
+ ## Limits
231
+
232
+ - Assumes the agent starts at the repository root. User-level files are counted only with `--include-user`; rules kept in an app's settings or a team dashboard (Cursor user rules, Copilot organization instructions) are not counted.
233
+ - Counts instruction files and, with `--mcp`, MCP tool definitions; not the agent's own system prompt or built-in tools.
234
+ - Loading rules change. Each rule cites the vendor page and the date it was checked; please open an issue when one is out of date.
235
+
236
+ ## Contributing
237
+
238
+ Loading rules and prices go out of date. Corrections with a link to the vendor's page are the most useful contribution; see [CONTRIBUTING.md](CONTRIBUTING.md).
239
+
240
+ ## License
241
+
242
+ MIT
package/dist/cli.js CHANGED
@@ -1,13 +1,21 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
- import { relative, resolve } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { basename, relative, resolve } from 'node:path';
3
4
  import { parseArgs } from 'node:util';
4
5
  import { hasNonEnglish } from './core/advice.js';
5
6
  import { audit } from './core/audit.js';
6
7
  import { classify } from './core/classify.js';
8
+ import { asDiffInput, diffReports, maxIncrease } from './core/diff.js';
9
+ import { planFixes, unifiedDiff } from './core/fix.js';
10
+ import { parseMcpConfigs, serverKey, toolDefinitionsText, toolNamesText } from './core/mcp.js';
7
11
  import { DEFAULT_MODEL_IDS, MODELS, validateModels } from './core/prices.js';
8
- import { badgeMarkdown, headlineTokens, renderMarkdown, renderText } from './core/render.js';
12
+ import { badgeMarkdown, headlineTokens, renderMarkdown, renderPrComment, renderText } from './core/render.js';
13
+ import { countO200k } from './core/tokenize.js';
9
14
  import { TOOL_IDS } from './core/tools.js';
10
- import { discover, existsIn, packageScripts } from './node/discover.js';
15
+ import { applyChanges } from './node/apply.js';
16
+ import { discover, discoverMcpConfigs, discoverUser, existsIn, linkedPaths, packageScripts } from './node/discover.js';
17
+ import { filesAtRef } from './node/git.js';
18
+ import { measureServer } from './node/mcp.js';
11
19
  import { anthropicCounter, geminiCounter, translateToEnglish } from './node/remote.js';
12
20
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
13
21
  const HELP = `turntax ${VERSION}
@@ -22,9 +30,20 @@ Options:
22
30
  --turns <n> Turns for the cost projection (default: 100)
23
31
  --json Print the full report as JSON
24
32
  --md Print a Markdown report
33
+ --pr-comment Print a pull request comment: the change first, the full report folded
25
34
  --out <file> Write the report to a file instead of stdout
26
35
  --badge Print a shields.io badge snippet
27
36
  --max-tokens <n> Exit with code 1 if any tool loads more than n o200k tokens per turn
37
+ (repository files plus measured project MCP tools)
38
+ --include-user Also count user-level files and MCP servers in your home directory
39
+ (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md, ...)
40
+ --mcp Start the configured MCP servers and count their tool definitions
41
+ (runs the commands in .mcp.json and the other agents' MCP configs)
42
+ --mcp-timeout <s> Seconds to wait for each MCP server (default: 30)
43
+ --baseline <file.json> Compare with an earlier --json report
44
+ --compare <git-ref> Compare with the instruction files at a git ref (for example origin/main)
45
+ --max-increase <n> Exit with code 1 if any tool's per-turn tokens grew by more than n
46
+ compared with --baseline or --compare
28
47
  --claude-api Count Claude tokens with Anthropic's count_tokens (needs ANTHROPIC_API_KEY;
29
48
  sends file contents to api.anthropic.com)
30
49
  --gemini-api Count Gemini tokens with Google's countTokens (needs GEMINI_API_KEY;
@@ -34,10 +53,14 @@ Options:
34
53
  (needs --claude-api; sends those files to api.anthropic.com)
35
54
  --prices <file.json> Use your own price table (same shape as --list-models --json)
36
55
  --section-tokens <n> Flag always-loaded sections at or above n o200k tokens (default: 600)
56
+ --fix Print a diff of the safe fixes: duplicate lines, sections that can load only in
57
+ their folder, misnamed Cursor rules. Every agent keeps reading the moved text;
58
+ --tools limits the agents whose context is kept
59
+ --write With --fix, apply the changes to the files
37
60
  -h, --help Show help
38
61
  -v, --version Show version
39
62
 
40
- Without --claude-api or --gemini-api nothing leaves your machine.
63
+ Without --claude-api, --gemini-api or --mcp nothing leaves your machine.
41
64
  `;
42
65
  class UsageError extends Error {
43
66
  }
@@ -68,6 +91,7 @@ export async function main(argv, env = process.env) {
68
91
  turns: { type: 'string' },
69
92
  json: { type: 'boolean' },
70
93
  md: { type: 'boolean' },
94
+ 'pr-comment': { type: 'boolean' },
71
95
  out: { type: 'string' },
72
96
  badge: { type: 'boolean' },
73
97
  'max-tokens': { type: 'string' },
@@ -77,6 +101,14 @@ export async function main(argv, env = process.env) {
77
101
  translate: { type: 'boolean' },
78
102
  prices: { type: 'string' },
79
103
  'section-tokens': { type: 'string' },
104
+ 'include-user': { type: 'boolean' },
105
+ mcp: { type: 'boolean' },
106
+ 'mcp-timeout': { type: 'string' },
107
+ baseline: { type: 'string' },
108
+ compare: { type: 'string' },
109
+ 'max-increase': { type: 'string' },
110
+ fix: { type: 'boolean' },
111
+ write: { type: 'boolean' },
80
112
  help: { type: 'boolean', short: 'h' },
81
113
  version: { type: 'boolean', short: 'v' },
82
114
  },
@@ -120,7 +152,38 @@ export async function main(argv, env = process.env) {
120
152
  if (!TOOL_IDS.includes(t))
121
153
  throw new UsageError(`Unknown tool "${t}". Use: ${TOOL_IDS.join(', ')}`);
122
154
  const root = resolve(positionals[0] ?? '.');
155
+ if (o.write && !o.fix)
156
+ throw new UsageError('--write needs --fix.');
157
+ if (o.fix) {
158
+ const other = ['json', 'md', 'pr-comment', 'badge', 'baseline', 'compare', 'max-tokens', 'max-increase', 'include-user', 'mcp'].filter((k) => o[k] !== undefined);
159
+ if (o.write && o.out)
160
+ other.push('out');
161
+ if (other.length)
162
+ throw new UsageError(`--fix${o.write ? ' --write' : ''} cannot be combined with --${other.join(', --')}.`);
163
+ return await fix(root, tools, Boolean(o.write), o.out);
164
+ }
123
165
  const files = await discover(root);
166
+ if (o.baseline && o.compare)
167
+ throw new UsageError('Use --baseline or --compare, not both.');
168
+ if (o['max-increase'] !== undefined && !o.baseline && !o.compare)
169
+ throw new UsageError('--max-increase needs --baseline or --compare.');
170
+ const maxIncreaseLimit = o['max-increase'] === undefined ? undefined : nonNegativeInt('max-increase', o['max-increase']);
171
+ let userFiles = [];
172
+ let mcpFiles = discoverMcpConfigs(root);
173
+ if (o['include-user']) {
174
+ const user = discoverUser(env.HOME || env.USERPROFILE || homedir());
175
+ userFiles = user.files;
176
+ mcpFiles = [...mcpFiles, ...user.mcpConfigs];
177
+ }
178
+ const parsedMcp = parseMcpConfigs(mcpFiles, root);
179
+ for (const p of parsedMcp.problems)
180
+ notice(`${p.path} ${p.message}`);
181
+ const servers = parsedMcp.servers;
182
+ let measured;
183
+ if (o.mcp) {
184
+ const timeoutMs = positiveInt('mcp-timeout', o['mcp-timeout'], 30) * 1000;
185
+ measured = await measureAll(servers.filter((s) => !s.disabled && (!tools || tools.includes(s.tool))), root, timeoutMs, env);
186
+ }
124
187
  const counters = {};
125
188
  if (o['claude-api']) {
126
189
  const key = env.ANTHROPIC_API_KEY;
@@ -167,6 +230,8 @@ export async function main(argv, env = process.env) {
167
230
  models,
168
231
  counters,
169
232
  turns: positiveInt('turns', o.turns, 100),
233
+ userFiles,
234
+ mcp: { servers, measured },
170
235
  advice: {
171
236
  exists: existsIn(root),
172
237
  packageScripts: packageScripts(root),
@@ -174,23 +239,51 @@ export async function main(argv, env = process.env) {
174
239
  sectionTokens: positiveInt('section-tokens', o['section-tokens'], 600),
175
240
  },
176
241
  });
242
+ if (o.baseline) {
243
+ let data;
244
+ try {
245
+ data = JSON.parse(readFileSync(o.baseline, 'utf8'));
246
+ }
247
+ catch (e) {
248
+ throw new UsageError(`Could not read --baseline ${o.baseline}: ${e.message}`);
249
+ }
250
+ report.diff = diffReports(asDiffInput(data), report, basename(o.baseline));
251
+ }
252
+ else if (o.compare) {
253
+ const base = await filesAtRef(root, o.compare).catch((e) => {
254
+ throw new UsageError(e.message);
255
+ });
256
+ const before = await audit(base.files, {
257
+ tools,
258
+ models: [],
259
+ mcp: { servers: parseMcpConfigs(base.mcpConfigs, root).servers },
260
+ });
261
+ report.diff = diffReports(before, report, o.compare);
262
+ }
177
263
  const output = o.json
178
264
  ? JSON.stringify({ root, ...report }, null, 2) + '\n'
179
- : o.md
180
- ? renderMarkdown(report)
181
- : o.badge
182
- ? badgeMarkdown(report) + '\n'
183
- : renderText(report, root) + '\n';
265
+ : o['pr-comment']
266
+ ? renderPrComment(report)
267
+ : o.md
268
+ ? renderMarkdown(report)
269
+ : o.badge
270
+ ? badgeMarkdown(report) + '\n'
271
+ : renderText(report, root) + '\n';
184
272
  if (o.out)
185
273
  writeFileSync(o.out, output);
186
274
  else
187
275
  process.stdout.write(output);
276
+ let code = 0;
188
277
  const max = o['max-tokens'] === undefined ? undefined : positiveInt('max-tokens', o['max-tokens'], 0);
189
278
  if (max !== undefined && headlineTokens(report) > max) {
190
279
  process.stderr.write(`turntax: ${headlineTokens(report)} o200k tokens per turn exceeds --max-tokens ${max}\n`);
191
- return 1;
280
+ code = 1;
192
281
  }
193
- return 0;
282
+ if (maxIncreaseLimit !== undefined && report.diff && maxIncrease(report.diff) > maxIncreaseLimit) {
283
+ process.stderr.write(`turntax: per-turn tokens grew by ${maxIncrease(report.diff)} vs ${report.diff.base}, more than --max-increase ${maxIncreaseLimit}\n`);
284
+ code = 1;
285
+ }
286
+ return code;
194
287
  }
195
288
  catch (e) {
196
289
  if (e instanceof UsageError) {
@@ -201,6 +294,68 @@ export async function main(argv, env = process.env) {
201
294
  return 1;
202
295
  }
203
296
  }
297
+ async function fix(root, tools, write, out) {
298
+ const files = await discover(root);
299
+ const plan = planFixes(files, { tools, exists: existsIn(root), readOnly: linkedPaths(root, files) });
300
+ const log = (line) => (write ? process.stdout : process.stderr).write(line + '\n');
301
+ for (const f of plan.fixes)
302
+ log(`fix: ${f.message}${f.savingTokens ? ` [saves ~${f.savingTokens} tokens/turn]` : ''}`);
303
+ for (const s of plan.skipped)
304
+ log(`skipped ${s.kind}: ${s.reason}`);
305
+ if (!plan.changes.length) {
306
+ // An empty patch, so an earlier patch in the same file is not applied again.
307
+ if (out && !write)
308
+ writeFileSync(out, '');
309
+ log('Nothing to fix automatically.');
310
+ return 0;
311
+ }
312
+ if (write) {
313
+ applyChanges(root, plan.changes);
314
+ log(`Changed ${plan.changes.length} file(s): ${plan.changes.map((c) => c.path).join(', ')}`);
315
+ return 0;
316
+ }
317
+ const diff = unifiedDiff(plan.changes);
318
+ if (out)
319
+ writeFileSync(out, diff);
320
+ else
321
+ process.stdout.write(diff);
322
+ log('Preview only. Run again with --write to apply, or save the diff and use git apply.');
323
+ return 0;
324
+ }
325
+ function nonNegativeInt(name, value) {
326
+ const n = Number(value);
327
+ if (!Number.isInteger(n) || n < 0)
328
+ throw new UsageError(`--${name} must be a non-negative integer`);
329
+ return n;
330
+ }
331
+ /** Starts each distinct server once (several agents often share one) and counts its tool definitions. */
332
+ async function measureAll(servers, root, timeoutMs, env) {
333
+ const unique = new Map();
334
+ for (const s of servers)
335
+ if (!unique.has(serverKey(s)))
336
+ unique.set(serverKey(s), s);
337
+ const out = new Map();
338
+ const queue = [...unique.entries()];
339
+ for (const [, s] of queue)
340
+ notice(s.transport === 'stdio'
341
+ ? `Starting MCP server "${s.name}" from ${s.configPath}: ${[s.command, ...(s.args ?? [])].join(' ')}`
342
+ : `Connecting to MCP server "${s.name}" from ${s.configPath}: ${s.url}`);
343
+ const worker = async () => {
344
+ for (let next = queue.shift(); next; next = queue.shift()) {
345
+ const [key, s] = next;
346
+ const r = await measureServer(s, { root, timeoutMs, env });
347
+ if (r.tools) {
348
+ out.set(key, { tools: r.tools.length, o200k: countO200k(toolDefinitionsText(r.tools)), namesO200k: countO200k(toolNamesText(r.tools)) });
349
+ }
350
+ else {
351
+ out.set(key, { error: r.error ?? 'failed' });
352
+ notice(`MCP server "${s.name}" was not measured: ${r.error}`);
353
+ }
354
+ }
355
+ };
356
+ await Promise.all(Array.from({ length: Math.min(4, queue.length) }, worker));
357
+ return out;
358
+ }
204
359
  function notice(message) {
205
360
  process.stderr.write(`turntax: ${message}\n`);
206
361
  }