turntax 0.1.0 → 0.3.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.
@@ -1,6 +1,8 @@
1
1
  import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { MAX_DEPTH, addImports, isInstructionFile, isSkippedDir } from '../core/files.js';
4
+ import { MCP_CONFIGS, USER_MCP_PATHS } from '../core/mcp.js';
5
+ import { USER_ROOTS, isUserInstructionFile } from '../core/user.js';
4
6
  const MAX_BYTES = 2 * 1024 * 1024;
5
7
  function readText(abs) {
6
8
  try {
@@ -61,3 +63,46 @@ export function packageScripts(root) {
61
63
  return undefined;
62
64
  }
63
65
  }
66
+ /** Project MCP config files (for example .mcp.json, .cursor/mcp.json). Read-only. */
67
+ export function discoverMcpConfigs(root) {
68
+ const paths = [...new Set(MCP_CONFIGS.filter((c) => c.scope === 'project').map((c) => c.path))];
69
+ return paths.flatMap((path) => {
70
+ const text = readText(join(root, path));
71
+ return text === null ? [] : [{ path, text }];
72
+ });
73
+ }
74
+ /**
75
+ * User-level instruction files and MCP configs in the home directory, with `~/` paths.
76
+ * Only the folders listed in USER_ROOTS are searched. Read-only.
77
+ */
78
+ export function discoverUser(home) {
79
+ const files = new Map();
80
+ const walk = (rel, depth) => {
81
+ let entries;
82
+ try {
83
+ entries = readdirSync(join(home, rel), { withFileTypes: true });
84
+ }
85
+ catch {
86
+ return;
87
+ }
88
+ for (const e of entries) {
89
+ const path = `${rel}/${e.name}`;
90
+ if (e.isDirectory()) {
91
+ if (depth > 0 && !isSkippedDir(path))
92
+ walk(path, depth - 1);
93
+ }
94
+ else if ((e.isFile() || e.isSymbolicLink()) && isUserInstructionFile(path)) {
95
+ const text = readText(join(home, path));
96
+ if (text !== null)
97
+ files.set(path, { path: `~/${path}`, text });
98
+ }
99
+ }
100
+ };
101
+ for (const r of USER_ROOTS)
102
+ walk(r.dir, r.depth);
103
+ const mcpConfigs = USER_MCP_PATHS.flatMap((path) => {
104
+ const text = readText(join(home, path));
105
+ return text === null ? [] : [{ path: `~/${path}`, text }];
106
+ });
107
+ return { files: [...files.values()].sort((a, b) => a.path.localeCompare(b.path)), mcpConfigs };
108
+ }
@@ -0,0 +1,46 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { addImports, isInstructionFile, isSkippedDir } from '../core/files.js';
3
+ import { isMcpConfigFile } from '../core/mcp.js';
4
+ /**
5
+ * Reads the instruction files and project MCP configs of `root` as they were at a git ref,
6
+ * without touching the working tree. Paths are relative to `root`, like discover().
7
+ */
8
+ export async function filesAtRef(root, ref) {
9
+ const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
10
+ try {
11
+ git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`);
12
+ }
13
+ catch {
14
+ throw new Error(`git ref "${ref}" was not found in ${root}`);
15
+ }
16
+ // ls-tree prints paths relative to the current directory, so a subfolder of a repository works too.
17
+ const paths = git('ls-tree', '-r', '--name-only', '-z', ref, '--', '.').split('\0').filter(Boolean);
18
+ const kept = paths.filter((p) => {
19
+ const parts = p.split('/');
20
+ for (let i = 1; i < parts.length; i++)
21
+ if (isSkippedDir(parts.slice(0, i).join('/')))
22
+ return false;
23
+ return true;
24
+ });
25
+ const read = (p) => {
26
+ try {
27
+ return git('show', `${ref}:./${p}`);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ };
33
+ const found = new Map();
34
+ for (const p of kept.filter(isInstructionFile)) {
35
+ const text = read(p);
36
+ if (text !== null)
37
+ found.set(p, { path: p, text });
38
+ }
39
+ const all = new Set(kept);
40
+ await addImports(found, (p) => (all.has(p) ? read(p) : null));
41
+ const mcpConfigs = kept.filter(isMcpConfigFile).flatMap((p) => {
42
+ const text = read(p);
43
+ return text === null ? [] : [{ path: p, text }];
44
+ });
45
+ return { files: [...found.values()].sort((a, b) => a.path.localeCompare(b.path)), mcpConfigs, paths: all };
46
+ }
@@ -0,0 +1,212 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ /**
4
+ * Starts MCP servers and asks them for their tool definitions (initialize + tools/list).
5
+ * Runs only with --mcp: stdio servers execute the command from the config file.
6
+ */
7
+ const PROTOCOL_VERSION = '2025-06-18';
8
+ const CLIENT_INFO = { name: 'turntax', version: '0' };
9
+ /** Expands `${VAR}`, `${VAR:-default}` and `${env:VAR}`; returns null when a VS Code `${input:...}` is needed. */
10
+ export function expandVars(value, env) {
11
+ if (/\$\{input:[^}]+\}/.test(value))
12
+ return null;
13
+ return value.replace(/\$\{(?:env:)?([A-Za-z_][\w]*)(?::-([^}]*))?\}/g, (_, name, fallback) => env[name] ?? fallback ?? '');
14
+ }
15
+ function expandAll(server, env) {
16
+ const one = (v) => (v === undefined ? undefined : expandVars(v, env));
17
+ const many = (vs) => vs?.map((v) => expandVars(v, env));
18
+ const map = (m) => m ? Object.fromEntries(Object.entries(m).map(([k, v]) => [k, expandVars(v, env)])) : undefined;
19
+ const out = { ...server, command: one(server.command), args: many(server.args), env: map(server.env), url: one(server.url), headers: map(server.headers) };
20
+ const values = [out.command, ...(out.args ?? []), ...Object.values(out.env ?? {}), out.url, ...Object.values(out.headers ?? {})];
21
+ if (values.some((v) => v === null))
22
+ return 'needs a value VS Code prompts for (${input:...}); not measured';
23
+ return out;
24
+ }
25
+ export async function measureServer(server, opts) {
26
+ const expanded = expandAll(server, opts.env ?? process.env);
27
+ if (typeof expanded === 'string')
28
+ return { error: expanded };
29
+ try {
30
+ if (expanded.transport === 'stdio')
31
+ return { tools: await viaStdio(expanded, opts.root, opts.timeoutMs, opts.env ?? process.env) };
32
+ if (expanded.transport === 'http')
33
+ return { tools: await viaHttp(expanded, opts.timeoutMs, opts.fetchFn ?? fetch) };
34
+ return { error: 'legacy SSE transport is not supported; not measured' };
35
+ }
36
+ catch (e) {
37
+ return { error: e.message };
38
+ }
39
+ }
40
+ async function listTools(rpc, notify) {
41
+ await rpc('initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO });
42
+ await notify('notifications/initialized');
43
+ const tools = [];
44
+ let cursor;
45
+ for (let page = 0; page < 50; page++) {
46
+ const r = await rpc('tools/list', cursor ? { cursor } : {});
47
+ for (const t of r?.tools ?? [])
48
+ tools.push({ name: String(t.name), description: t.description, inputSchema: t.inputSchema });
49
+ cursor = r?.nextCursor;
50
+ if (!cursor)
51
+ break;
52
+ }
53
+ return tools;
54
+ }
55
+ function viaStdio(server, root, timeoutMs, env) {
56
+ if (!server.command)
57
+ return Promise.reject(new Error('no command in the config'));
58
+ const cwd = server.cwd ? (isAbsolute(server.cwd) ? server.cwd : resolve(root, server.cwd)) : root;
59
+ // npx and similar launchers are .cmd shims on Windows, which only start through the shell.
60
+ const windows = process.platform === 'win32';
61
+ const child = windows
62
+ ? spawn([server.command, ...(server.args ?? [])].map(cmdQuote).join(' '), { cwd, env: { ...env, ...server.env }, shell: true, windowsHide: true })
63
+ : spawn(server.command, server.args ?? [], { cwd, env: { ...env, ...server.env }, stdio: ['pipe', 'pipe', 'pipe'] });
64
+ const pending = new Map();
65
+ let nextId = 1;
66
+ let buffer = '';
67
+ let stderr = '';
68
+ let failed = null;
69
+ const failAll = (e) => {
70
+ failed ??= e;
71
+ for (const p of pending.values())
72
+ p.reject(e);
73
+ pending.clear();
74
+ };
75
+ child.stdout.setEncoding('utf8');
76
+ child.stdout.on('data', (chunk) => {
77
+ buffer += chunk;
78
+ let nl;
79
+ while ((nl = buffer.indexOf('\n')) >= 0) {
80
+ const line = buffer.slice(0, nl).trim();
81
+ buffer = buffer.slice(nl + 1);
82
+ if (!line)
83
+ continue;
84
+ let msg;
85
+ try {
86
+ msg = JSON.parse(line);
87
+ }
88
+ catch {
89
+ continue; // Servers sometimes log to stdout; skip non-JSON lines.
90
+ }
91
+ const p = typeof msg?.id === 'number' ? pending.get(msg.id) : undefined;
92
+ if (!p)
93
+ continue;
94
+ pending.delete(msg.id);
95
+ if (msg.error)
96
+ p.reject(new Error(`server error: ${msg.error.message ?? 'unknown'}`));
97
+ else
98
+ p.resolve(msg.result);
99
+ }
100
+ });
101
+ child.stderr.setEncoding('utf8');
102
+ child.stderr.on('data', (s) => (stderr = (stderr + s).slice(-500)));
103
+ child.on('error', (e) => failAll(new Error(`could not start "${server.command}": ${e.message}`)));
104
+ child.on('exit', (code) => failAll(new Error(`exited with code ${code} before answering${stderr.trim() ? `: ${lastLine(stderr)}` : ''}`)));
105
+ const send = (msg) => child.stdin.write(JSON.stringify(msg) + '\n');
106
+ const rpc = (method, params) => failed
107
+ ? Promise.reject(failed)
108
+ : new Promise((res, rej) => {
109
+ const id = nextId++;
110
+ pending.set(id, { resolve: res, reject: rej });
111
+ send({ jsonrpc: '2.0', id, method, params });
112
+ });
113
+ const notify = async (method) => void send({ jsonrpc: '2.0', method });
114
+ let timer;
115
+ const timeout = new Promise((_, rej) => {
116
+ timer = setTimeout(() => rej(new Error(`no answer within ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
117
+ });
118
+ return Promise.race([listTools(rpc, notify), timeout]).finally(() => {
119
+ clearTimeout(timer);
120
+ child.removeAllListeners('exit');
121
+ child.removeAllListeners('error');
122
+ child.on('error', () => { });
123
+ // On Windows the server runs under cmd.exe; end the whole process tree, not just the shell.
124
+ if (windows && child.pid)
125
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => { }).unref();
126
+ else
127
+ child.kill();
128
+ child.stdin.destroy();
129
+ child.stdout.destroy();
130
+ child.stderr.destroy();
131
+ });
132
+ }
133
+ /** Quotes one argument for cmd.exe so spaces, quotes and characters such as > & ( ) reach the program unchanged. */
134
+ export function cmdQuote(arg) {
135
+ if (arg !== '' && !/[\s"&|<>^()%!,;=]/.test(arg))
136
+ return arg;
137
+ return '"' + arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1') + '"';
138
+ }
139
+ /** The most useful stderr line: the first that names an error, else the last. */
140
+ function lastLine(s) {
141
+ const lines = s.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
142
+ return (lines.find((l) => /error|not found|cannot|denied/i.test(l)) ?? lines.at(-1) ?? '').slice(0, 200);
143
+ }
144
+ async function viaHttp(server, timeoutMs, fetchFn) {
145
+ const url = server.url;
146
+ let session;
147
+ let nextId = 1;
148
+ const controller = new AbortController();
149
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
150
+ const post = async (body) => {
151
+ const res = await fetchFn(url, {
152
+ method: 'POST',
153
+ headers: {
154
+ 'content-type': 'application/json',
155
+ accept: 'application/json, text/event-stream',
156
+ 'mcp-protocol-version': PROTOCOL_VERSION,
157
+ ...(session ? { 'mcp-session-id': session } : {}),
158
+ ...server.headers,
159
+ },
160
+ body: JSON.stringify(body),
161
+ signal: controller.signal,
162
+ });
163
+ if (!res.ok)
164
+ throw new Error(`HTTP ${res.status}${res.status === 401 || res.status === 403 ? ' (needs authorization)' : ''}`);
165
+ session = res.headers.get('mcp-session-id') ?? session;
166
+ return res;
167
+ };
168
+ const rpc = async (method, params) => {
169
+ const id = nextId++;
170
+ const res = await post({ jsonrpc: '2.0', id, method, params });
171
+ const type = res.headers.get('content-type') ?? '';
172
+ const messages = type.includes('text/event-stream') ? sseMessages(await res.text()) : [await res.json()];
173
+ const msg = messages.find((m) => m?.id === id);
174
+ if (!msg)
175
+ throw new Error(`no response to ${method}`);
176
+ if (msg.error)
177
+ throw new Error(`server error: ${msg.error.message ?? 'unknown'}`);
178
+ return msg.result;
179
+ };
180
+ const notify = async (method) => void (await post({ jsonrpc: '2.0', method }));
181
+ try {
182
+ return await listTools(rpc, notify);
183
+ }
184
+ catch (e) {
185
+ if (controller.signal.aborted)
186
+ throw new Error(`no answer within ${Math.round(timeoutMs / 1000)}s`);
187
+ throw e;
188
+ }
189
+ finally {
190
+ clearTimeout(timer);
191
+ }
192
+ }
193
+ /** JSON-RPC messages from the `data:` lines of a server-sent event stream. */
194
+ export function sseMessages(text) {
195
+ const out = [];
196
+ for (const event of text.split(/\r?\n\r?\n/)) {
197
+ const data = event
198
+ .split(/\r?\n/)
199
+ .filter((l) => l.startsWith('data:'))
200
+ .map((l) => l.slice(5).trimStart())
201
+ .join('\n');
202
+ if (!data)
203
+ continue;
204
+ try {
205
+ out.push(JSON.parse(data));
206
+ }
207
+ catch {
208
+ // Ignore keep-alive and non-JSON events.
209
+ }
210
+ }
211
+ return out;
212
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "turntax",
3
- "version": "0.1.0",
4
- "description": "Measure the per-turn token tax your AI coding-agent instruction files (CLAUDE.md, AGENTS.md, Cursor/Copilot/Windsurf rules) add to every agent turn.",
3
+ "version": "0.3.0",
4
+ "description": "Measure the per-turn token cost of AI coding-agent instruction files (AGENTS.md, CLAUDE.md, Cursor, Copilot, Cline, Roo, Kiro, Windsurf and more) for every agent that reads them.",
5
5
  "license": "MIT",
6
6
  "author": "TORUT LTD",
7
7
  "homepage": "https://github.com/TorutDev/turntax#readme",
@@ -30,13 +30,24 @@
30
30
  "prepack": "npm run build"
31
31
  },
32
32
  "keywords": [
33
- "claude-code",
33
+ "mcp",
34
34
  "agents-md",
35
+ "claude-code",
35
36
  "codex",
36
37
  "cursor",
37
38
  "copilot",
38
39
  "gemini-cli",
39
40
  "windsurf",
41
+ "cline",
42
+ "roo-code",
43
+ "kiro",
44
+ "junie",
45
+ "zed",
46
+ "continue",
47
+ "aider",
48
+ "goose",
49
+ "amp",
50
+ "opencode",
40
51
  "tokens",
41
52
  "context",
42
53
  "cli"