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.
@@ -0,0 +1,63 @@
1
+ export function diffReports(before, after, base) {
2
+ const ids = [...new Set([...after.tools.map((t) => t.tool), ...before.tools.map((t) => t.tool)])];
3
+ const tools = ids.map((id) => {
4
+ const a = after.tools.find((t) => t.tool === id);
5
+ const b = before.tools.find((t) => t.tool === id);
6
+ const av = a?.perTurnO200k ?? null;
7
+ const bv = b?.perTurnO200k ?? null;
8
+ return { tool: id, name: (a ?? b).name, before: bv, after: av, delta: (av ?? 0) - (bv ?? 0) };
9
+ });
10
+ const alwaysFiles = (r) => {
11
+ const m = new Map();
12
+ for (const f of r.files) {
13
+ if (f.mode !== 'always' || f.scope === 'user')
14
+ continue;
15
+ const key = `${f.path}\0${f.part}`;
16
+ const prev = m.get(key);
17
+ if (prev)
18
+ prev.tools.add(f.tool);
19
+ else
20
+ m.set(key, { path: f.path, part: f.part, o200k: f.o200k, tools: new Set([f.tool]) });
21
+ }
22
+ return m;
23
+ };
24
+ const bf = alwaysFiles(before);
25
+ const af = alwaysFiles(after);
26
+ const files = [];
27
+ for (const key of new Set([...bf.keys(), ...af.keys()])) {
28
+ const b = bf.get(key);
29
+ const a = af.get(key);
30
+ const delta = (a?.o200k ?? 0) - (b?.o200k ?? 0);
31
+ if (a && b && delta === 0)
32
+ continue;
33
+ files.push({
34
+ path: (a ?? b).path,
35
+ part: (a ?? b).part,
36
+ before: b?.o200k ?? null,
37
+ after: a?.o200k ?? null,
38
+ delta,
39
+ tools: [...new Set([...(b?.tools ?? []), ...(a?.tools ?? [])])],
40
+ });
41
+ }
42
+ files.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta) || x.path.localeCompare(y.path));
43
+ const servers = (r) => new Set((r.mcp ?? []).filter((s) => s.scope === 'project' && s.status !== 'disabled').map((s) => `${s.name} (${s.tool})`));
44
+ const bs = servers(before);
45
+ const as = servers(after);
46
+ return {
47
+ base,
48
+ tools,
49
+ files,
50
+ mcp: { added: [...as].filter((s) => !bs.has(s)), removed: [...bs].filter((s) => !as.has(s)) },
51
+ };
52
+ }
53
+ /** Largest per-turn increase across agents (0 when nothing grew). */
54
+ export function maxIncrease(diff) {
55
+ return Math.max(0, ...diff.tools.map((t) => t.delta));
56
+ }
57
+ /** Validates a parsed JSON report enough to diff against it. */
58
+ export function asDiffInput(data) {
59
+ const r = data;
60
+ if (!r || !Array.isArray(r.tools) || !Array.isArray(r.files))
61
+ throw new Error('not a turntax --json report');
62
+ return r;
63
+ }
@@ -1,5 +1,5 @@
1
1
  /** Which repository paths can hold agent instructions. Shared by the CLI and the web page. */
2
- import { findImports, resolvePath } from './markdown.js';
2
+ import { configList, findImports, resolvePath } from './markdown.js';
3
3
  export const SKIP_DIRS = new Set([
4
4
  'node_modules',
5
5
  '.git',
@@ -30,6 +30,13 @@ const NAMES = new Set([
30
30
  'GEMINI.md',
31
31
  '.cursorrules',
32
32
  '.windsurfrules',
33
+ '.clinerules',
34
+ '.roorules',
35
+ '.rules',
36
+ 'AGENT.md',
37
+ 'CONVENTIONS.md',
38
+ '.goosehints',
39
+ '.aider.conf.yml',
33
40
  ]);
34
41
  const PATTERNS = [
35
42
  /^\.github\/copilot-instructions\.md$/,
@@ -40,6 +47,13 @@ const PATTERNS = [
40
47
  /(^|\/)\.agents\/skills\/[^/]+\/SKILL\.md$/,
41
48
  /(^|\/)\.cursor\/rules\/.+\.mdc?$/,
42
49
  /(^|\/)\.(devin|windsurf)\/rules\/.+\.md$/,
50
+ /^\.(clinerules|cline\/rules)\/.+\.md$/,
51
+ /^\.roo\/rules(-[\w-]+)?\/.+\.(md|txt)$/,
52
+ /^\.roorules-[\w-]+$/,
53
+ /^\.kiro\/steering\/[^/]+\.md$/,
54
+ /^\.junie\/(AGENTS|playbook|guidelines)\.md$/,
55
+ /^\.junie\/(rules|guidelines)\/[^/]+\.md$/,
56
+ /^\.continue\/rules\/[^/]+\.md$/,
43
57
  ];
44
58
  export const MAX_DEPTH = 12;
45
59
  export function isInstructionFile(path) {
@@ -51,15 +65,22 @@ export function isSkippedDir(path) {
51
65
  const name = path.slice(path.lastIndexOf('/') + 1);
52
66
  return SKIP_DIRS.has(name) || path === '.claude/worktrees' || path.endsWith('/.claude/worktrees');
53
67
  }
54
- /** Adds in-repo files reached through @imports (up to five levels) to `found`. */
68
+ /** Files a file pulls in: its @imports, or the read: list of .aider.conf.yml. */
69
+ function references(f) {
70
+ if (f.path === '.aider.conf.yml')
71
+ return configList(f, 'read');
72
+ return findImports(f.text)
73
+ .map((t) => resolvePath(f.path, t))
74
+ .filter((p) => p !== null);
75
+ }
76
+ /** Adds in-repo files reached through @imports or config read lists (up to five levels) to `found`. */
55
77
  export async function addImports(found, read) {
56
78
  let queue = [...found.values()];
57
79
  for (let depth = 0; depth < 5 && queue.length; depth++) {
58
80
  const next = [];
59
81
  for (const f of queue) {
60
- for (const target of findImports(f.text)) {
61
- const rel = resolvePath(f.path, target);
62
- if (rel === null || found.has(rel))
82
+ for (const rel of references(f)) {
83
+ if (found.has(rel))
63
84
  continue;
64
85
  const text = await read(rel);
65
86
  if (text === null)
@@ -168,3 +168,13 @@ export function resolvePath(fromFile, target) {
168
168
  }
169
169
  return parts.join('/');
170
170
  }
171
+ /** Paths listed under `key` in a small YAML config (string, inline list or block list). */
172
+ export function configList(config, key) {
173
+ const value = parseFrontmatter(`---\n${config.text}\n---\n`).frontmatter[key];
174
+ const list = value === undefined || value === '' ? [] : Array.isArray(value) ? value : [String(value)];
175
+ return list
176
+ .map((p) => p.replace(/\s+#.*$/, '').trim())
177
+ .filter(Boolean)
178
+ .map((p) => resolvePath(config.path, p))
179
+ .filter((p) => p !== null);
180
+ }
@@ -0,0 +1,227 @@
1
+ const CHECKED_AT = '2026-10-11';
2
+ /** Where each agent reads MCP servers from. Paths and keys come from the cited pages. */
3
+ export const MCP_CONFIGS = [
4
+ { tool: 'claude-code', path: '.mcp.json', scope: 'project', key: 'mcpServers', shape: 'standard', source: 'https://code.claude.com/docs/en/mcp' },
5
+ { tool: 'cursor', path: '.cursor/mcp.json', scope: 'project', key: 'mcpServers', shape: 'standard', source: 'https://cursor.com/docs/context/mcp' },
6
+ { tool: 'copilot', path: '.vscode/mcp.json', scope: 'project', key: 'servers', shape: 'vscode', source: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers' },
7
+ { tool: 'gemini-cli', path: '.gemini/settings.json', scope: 'project', key: 'mcpServers', shape: 'gemini', source: 'https://geminicli.com/docs/tools/mcp-server/' },
8
+ { tool: 'kiro', path: '.kiro/settings/mcp.json', scope: 'project', key: 'mcpServers', shape: 'standard', source: 'https://kiro.dev/docs/mcp/configuration/' },
9
+ { tool: 'roo-code', path: '.roo/mcp.json', scope: 'project', key: 'mcpServers', shape: 'standard', source: 'https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo' },
10
+ { tool: 'opencode', path: 'opencode.json', scope: 'project', key: 'mcp', shape: 'opencode', source: 'https://opencode.ai/docs/mcp-servers/' },
11
+ { tool: 'opencode', path: 'opencode.jsonc', scope: 'project', key: 'mcp', shape: 'opencode', source: 'https://opencode.ai/docs/mcp-servers/' },
12
+ { tool: 'zed', path: '.zed/settings.json', scope: 'project', key: 'context_servers', shape: 'zed', source: 'https://zed.dev/docs/ai/mcp' },
13
+ { tool: 'codex', path: '.codex/config.toml', scope: 'project', key: 'mcp_servers', shape: 'codex-toml', source: 'https://learn.chatgpt.com/docs/extend/mcp' },
14
+ { tool: 'claude-code', path: '.claude.json', scope: 'user', key: 'mcpServers', shape: 'claude-json', source: 'https://code.claude.com/docs/en/mcp' },
15
+ { tool: 'cursor', path: '.cursor/mcp.json', scope: 'user', key: 'mcpServers', shape: 'standard', source: 'https://cursor.com/docs/context/mcp' },
16
+ { tool: 'gemini-cli', path: '.gemini/settings.json', scope: 'user', key: 'mcpServers', shape: 'gemini', source: 'https://geminicli.com/docs/tools/mcp-server/' },
17
+ { tool: 'kiro', path: '.kiro/settings/mcp.json', scope: 'user', key: 'mcpServers', shape: 'standard', source: 'https://kiro.dev/docs/mcp/configuration/' },
18
+ { tool: 'windsurf', path: '.config/devin/mcp_config.json', scope: 'user', key: 'mcpServers', shape: 'standard', source: 'https://docs.devin.ai/desktop/cascade/mcp' },
19
+ { tool: 'windsurf', path: 'AppData/Roaming/devin/mcp_config.json', scope: 'user', key: 'mcpServers', shape: 'standard', source: 'https://docs.devin.ai/desktop/cascade/mcp' },
20
+ { tool: 'codex', path: '.codex/config.toml', scope: 'user', key: 'mcp_servers', shape: 'codex-toml', source: 'https://learn.chatgpt.com/docs/extend/mcp' },
21
+ { tool: 'opencode', path: '.config/opencode/opencode.json', scope: 'user', key: 'mcp', shape: 'opencode', source: 'https://opencode.ai/docs/mcp-servers/' },
22
+ ];
23
+ export const MCP_CHECKED_AT = CHECKED_AT;
24
+ /** True for a repo-relative path that holds project MCP servers for some agent. */
25
+ export function isMcpConfigFile(path) {
26
+ return MCP_CONFIGS.some((c) => c.scope === 'project' && c.path === path);
27
+ }
28
+ /** Home-relative paths of user-level MCP configs. */
29
+ export const USER_MCP_PATHS = [...new Set(MCP_CONFIGS.filter((c) => c.scope === 'user').map((c) => c.path))];
30
+ /** JSON with comments and trailing commas (VS Code, Zed and OpenCode accept both). */
31
+ export function parseJsonc(text) {
32
+ let out = '';
33
+ let inString = false;
34
+ for (let i = 0; i < text.length; i++) {
35
+ const c = text[i];
36
+ if (inString) {
37
+ out += c;
38
+ if (c === '\\')
39
+ out += text[++i] ?? '';
40
+ else if (c === '"')
41
+ inString = false;
42
+ }
43
+ else if (c === '"') {
44
+ inString = true;
45
+ out += c;
46
+ }
47
+ else if (c === '/' && text[i + 1] === '/') {
48
+ while (i < text.length && text[i] !== '\n')
49
+ i++;
50
+ out += '\n';
51
+ }
52
+ else if (c === '/' && text[i + 1] === '*') {
53
+ i = text.indexOf('*/', i + 2);
54
+ if (i < 0)
55
+ break;
56
+ i++;
57
+ }
58
+ else
59
+ out += c;
60
+ }
61
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1').replace(/^/, ''));
62
+ }
63
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
64
+ const strings = (v) => (Array.isArray(v) ? v.map(String) : undefined);
65
+ const record = (v) => isObj(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)])) : undefined;
66
+ function fromStandard(name, v, gemini = false) {
67
+ const type = String(v.type ?? v.transport ?? '').toLowerCase();
68
+ // Gemini CLI has no type field: `httpUrl` is streamable HTTP and `url` is SSE.
69
+ // Elsewhere `url` (Windsurf: `serverUrl`) is streamable HTTP unless the type says SSE.
70
+ const httpUrl = typeof v.httpUrl === 'string' ? v.httpUrl : undefined;
71
+ const url = httpUrl ?? (typeof v.url === 'string' ? v.url : typeof v.serverUrl === 'string' ? v.serverUrl : undefined);
72
+ const transport = !url ? 'stdio' : httpUrl ? 'http' : gemini || type === 'sse' ? 'sse' : 'http';
73
+ return {
74
+ name,
75
+ transport,
76
+ command: typeof v.command === 'string' ? v.command : undefined,
77
+ args: strings(v.args),
78
+ env: record(v.env),
79
+ cwd: typeof v.cwd === 'string' ? v.cwd : undefined,
80
+ url,
81
+ headers: record(v.headers),
82
+ disabled: v.disabled === true || v.enabled === false,
83
+ ...(v.alwaysLoad === true ? { alwaysLoad: true } : {}),
84
+ };
85
+ }
86
+ function fromOpencode(name, v) {
87
+ const command = strings(v.command) ?? [];
88
+ const remote = v.type === 'remote' || typeof v.url === 'string';
89
+ return {
90
+ name,
91
+ transport: remote ? 'http' : 'stdio',
92
+ command: remote ? undefined : command[0],
93
+ args: remote ? undefined : command.slice(1),
94
+ env: record(v.environment),
95
+ url: typeof v.url === 'string' ? v.url : undefined,
96
+ headers: record(v.headers),
97
+ disabled: v.enabled === false,
98
+ };
99
+ }
100
+ function fromZed(name, v) {
101
+ // Older Zed settings nest the command: { "command": { "path", "args", "env" } }.
102
+ if (isObj(v.command)) {
103
+ const c = v.command;
104
+ return fromStandard(name, { ...v, command: c.path ?? c.command, args: c.args, env: c.env });
105
+ }
106
+ return fromStandard(name, v);
107
+ }
108
+ function serversIn(container, shape) {
109
+ if (!isObj(container))
110
+ return [];
111
+ return Object.entries(container)
112
+ .filter(([, v]) => isObj(v))
113
+ .map(([name, v]) => shape === 'opencode'
114
+ ? fromOpencode(name, v)
115
+ : shape === 'zed'
116
+ ? fromZed(name, v)
117
+ : fromStandard(name, v, shape === 'gemini'));
118
+ }
119
+ /** Minimal TOML reader for Codex `[mcp_servers.<name>]` tables: strings, string arrays and inline tables. */
120
+ export function parseCodexToml(text) {
121
+ const servers = {};
122
+ let current = null;
123
+ let sub = null;
124
+ const value = (raw) => {
125
+ const t = raw.trim();
126
+ if (t.startsWith('['))
127
+ return [...t.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2] ?? '');
128
+ if (t.startsWith('{'))
129
+ return Object.fromEntries([...t.matchAll(/([\w-]+|"[^"]*")\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/g)].map((m) => [m[1].replace(/"/g, ''), m[2] ?? m[3] ?? '']));
130
+ if (t === 'true' || t === 'false')
131
+ return t === 'true';
132
+ const s = /^"((?:[^"\\]|\\.)*)"|^'([^']*)'/.exec(t);
133
+ return s ? (s[1] ?? s[2]) : t;
134
+ };
135
+ for (const line of text.split(/\r?\n/)) {
136
+ const header = /^\s*\[\s*mcp_servers\.(?:"([^"]+)"|([\w-]+))(?:\.(\w+))?\s*\]/.exec(line);
137
+ if (header) {
138
+ const name = (header[1] ?? header[2]);
139
+ servers[name] ??= {};
140
+ current = servers[name];
141
+ sub = header[3] ?? null;
142
+ continue;
143
+ }
144
+ if (/^\s*\[/.test(line)) {
145
+ current = null;
146
+ continue;
147
+ }
148
+ const kv = /^\s*([\w-]+)\s*=\s*(.+?)\s*(?:#.*)?$/.exec(line);
149
+ if (!kv || !current)
150
+ continue;
151
+ if (sub) {
152
+ const table = (current[sub] ??= {});
153
+ table[kv[1]] = value(kv[2]);
154
+ }
155
+ else
156
+ current[kv[1]] = value(kv[2]);
157
+ }
158
+ // Codex calls HTTP headers `http_headers`.
159
+ for (const v of Object.values(servers))
160
+ if (isObj(v) && v.http_headers && !v.headers)
161
+ v.headers = v.http_headers;
162
+ return servers;
163
+ }
164
+ /**
165
+ * Parses the MCP configs among `files`. Project files use repo-relative paths; user files are passed
166
+ * with a `~/` prefix. `projectRoot` (absolute) selects the per-project entry in ~/.claude.json.
167
+ */
168
+ export function parseMcpConfigs(files, projectRoot) {
169
+ const servers = [];
170
+ const problems = [];
171
+ for (const f of files) {
172
+ const user = f.path.startsWith('~/');
173
+ const rel = user ? f.path.slice(2) : f.path;
174
+ const specs = MCP_CONFIGS.filter((c) => c.path === rel && c.scope === (user ? 'user' : 'project'));
175
+ if (!specs.length)
176
+ continue;
177
+ let data;
178
+ try {
179
+ data = specs[0].shape === 'codex-toml' ? parseCodexToml(f.text) : parseJsonc(f.text);
180
+ }
181
+ catch (e) {
182
+ problems.push({ path: f.path, message: `could not be parsed: ${e.message}` });
183
+ continue;
184
+ }
185
+ if (!isObj(data))
186
+ continue;
187
+ for (const spec of specs) {
188
+ const containers = spec.shape === 'codex-toml'
189
+ ? [data]
190
+ : spec.shape === 'claude-json'
191
+ ? [data.mcpServers, projectEntry(data, projectRoot)?.mcpServers]
192
+ : [data[spec.key]];
193
+ for (const c of containers)
194
+ for (const s of serversIn(c, spec.shape))
195
+ servers.push({ ...s, tool: spec.tool, configPath: f.path, scope: spec.scope });
196
+ }
197
+ }
198
+ return { servers, problems };
199
+ }
200
+ /** The `projects["<abs path>"]` entry of ~/.claude.json for this repository, matched case- and slash-insensitively. */
201
+ function projectEntry(data, root) {
202
+ if (!root || !isObj(data.projects))
203
+ return undefined;
204
+ const norm = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
205
+ const hit = Object.entries(data.projects).find(([p]) => norm(p) === norm(root));
206
+ return hit && isObj(hit[1]) ? hit[1] : undefined;
207
+ }
208
+ /** Same server started by several agents or configs: one launch, one measurement. */
209
+ export function serverKey(s) {
210
+ return JSON.stringify([s.transport, s.command ?? '', s.args ?? [], s.url ?? '', s.cwd ?? '']);
211
+ }
212
+ /**
213
+ * Claude Code defers MCP tool definitions by default (tool search): only tool names are in context
214
+ * until a tool is used. A server with `alwaysLoad: true` is exempt.
215
+ */
216
+ export function defersTools(s) {
217
+ return s.tool === 'claude-code' && !s.alwaysLoad;
218
+ }
219
+ export const DEFERRED_NOTE = 'Claude Code defers MCP tool definitions by default (tool search): only tool names load every turn; full definitions load when a tool is used. Set ENABLE_TOOL_SEARCH=false or alwaysLoad: true to load them upfront.';
220
+ /** Tool names only, as listed when definitions are deferred. */
221
+ export function toolNamesText(tools) {
222
+ return tools.map((t) => t.name).join('\n');
223
+ }
224
+ /** The text an agent receives for a server's tools, as one JSON document per tool. */
225
+ export function toolDefinitionsText(tools) {
226
+ return tools.map((t) => JSON.stringify({ name: t.name, description: t.description ?? '', input_schema: t.inputSchema ?? {} })).join('\n');
227
+ }