cc-file-churn 1.0.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 +60 -0
- package/cli.mjs +182 -0
- package/index.html +237 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# cc-file-churn
|
|
2
|
+
|
|
3
|
+
Which files does Claude Code touch the most? Find the hotspots.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx cc-file-churn
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
Scans all your `~/.claude/projects/` session transcripts and counts every `Edit`, `Write`, `Read`, `Grep`, and `Glob` tool call per file. Shows a ranked list with color-coded progress bars.
|
|
12
|
+
|
|
13
|
+
## Example output
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
cc-file-churn — Most-touched files in your Claude Code sessions
|
|
17
|
+
|
|
18
|
+
Scanned 755 session files · 2,219 unique files · 12,476 tool calls
|
|
19
|
+
|
|
20
|
+
① ████████████████████ 933 total
|
|
21
|
+
~/.claude/plans/staged-hatching-backus.md
|
|
22
|
+
669 writes 239 reads 25 searches
|
|
23
|
+
|
|
24
|
+
② ██████████████░░░░░░ 671 total
|
|
25
|
+
~/projects/dungeon-diablo/dungeon_game.py
|
|
26
|
+
70 writes 313 reads 288 searches
|
|
27
|
+
|
|
28
|
+
③ ███████████░░░░░░░░░ 493 total
|
|
29
|
+
~/schedule-app/index.html
|
|
30
|
+
215 writes 218 reads 60 searches
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Why this is interesting
|
|
34
|
+
|
|
35
|
+
The #1 most-touched file in my 60 days: a planning file with 669 writes. #2: the game I spent 3 months building. #3: a schedule app I built in one session.
|
|
36
|
+
|
|
37
|
+
Your highest-churn files reveal:
|
|
38
|
+
- **Where the AI spends most effort** (vs where you think it does)
|
|
39
|
+
- **Which files are becoming complexity magnets**
|
|
40
|
+
- **Which projects actually got attention** (vs the ones that felt important)
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx cc-file-churn # Top 20 files by total activity
|
|
46
|
+
npx cc-file-churn 10 # Top 10
|
|
47
|
+
npx cc-file-churn --writes # Rank by write frequency (most-modified)
|
|
48
|
+
npx cc-file-churn --reads # Rank by read frequency (most-referenced)
|
|
49
|
+
npx cc-file-churn --days=7 # Only last 7 days
|
|
50
|
+
npx cc-file-churn --project=my-app # Filter to a specific project
|
|
51
|
+
npx cc-file-churn --json # JSON output for scripting
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Part of cc-toolkit
|
|
55
|
+
|
|
56
|
+
One of 50 free tools for Claude Code users → [cc-toolkit](https://yurukusa.github.io/cc-toolkit/)
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// cc-file-churn — Which files does Claude Code touch the most?
|
|
4
|
+
// Scans ~/.claude/projects/ session transcripts, tallies Edit/Write/Read/Grep tool calls per file.
|
|
5
|
+
// Zero dependencies. Works across all Claude Code projects.
|
|
6
|
+
|
|
7
|
+
import { readdir, stat, open } from 'node:fs/promises';
|
|
8
|
+
import { join, basename } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
// ── Config ──────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const READ_TOOLS = new Set(['Read']);
|
|
14
|
+
const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
|
|
15
|
+
const SEARCH_TOOLS = new Set(['Grep', 'Glob']);
|
|
16
|
+
const ALL_TRACKED = new Set([...READ_TOOLS, ...WRITE_TOOLS, ...SEARCH_TOOLS]);
|
|
17
|
+
|
|
18
|
+
const TAIL_BYTES = 4_194_304; // 4MB per file (faster than full scan)
|
|
19
|
+
|
|
20
|
+
// ── Colors ──────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const C = {
|
|
23
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
24
|
+
red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
|
|
25
|
+
blue: '\x1b[34m', cyan: '\x1b[36m', white: '\x1b[37m',
|
|
26
|
+
orange: '\x1b[38;5;208m',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function bar(n, max, width = 20) {
|
|
30
|
+
const filled = max > 0 ? Math.round((n / max) * width) : 0;
|
|
31
|
+
return C.cyan + '█'.repeat(filled) + C.dim + '░'.repeat(width - filled) + C.reset;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── File reading ─────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
async function readChunk(filePath, maxBytes) {
|
|
37
|
+
const fh = await open(filePath, 'r');
|
|
38
|
+
try {
|
|
39
|
+
const { size } = await fh.stat();
|
|
40
|
+
const start = Math.max(0, size - maxBytes);
|
|
41
|
+
const buf = Buffer.alloc(size - start);
|
|
42
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, start);
|
|
43
|
+
return buf.toString('utf8', 0, bytesRead);
|
|
44
|
+
} finally {
|
|
45
|
+
await fh.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Parse tool calls from jsonl chunk ───────────────────────────
|
|
50
|
+
|
|
51
|
+
function parseChunk(chunk, fileMap, toolMap) {
|
|
52
|
+
const lines = chunk.split('\n');
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
if (!line.trim()) continue;
|
|
55
|
+
let d;
|
|
56
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
57
|
+
if (d.type !== 'assistant') continue;
|
|
58
|
+
for (const content of d?.message?.content ?? []) {
|
|
59
|
+
if (content?.type !== 'tool_use') continue;
|
|
60
|
+
const toolName = content.name;
|
|
61
|
+
if (!ALL_TRACKED.has(toolName)) continue;
|
|
62
|
+
const inp = content.input ?? {};
|
|
63
|
+
const path = inp.file_path ?? inp.path ?? '';
|
|
64
|
+
if (!path || typeof path !== 'string') continue;
|
|
65
|
+
// Normalize: remove absolute prefix up to home
|
|
66
|
+
const norm = path.replace(/^\/home\/[^/]+/, '~');
|
|
67
|
+
if (!fileMap.has(norm)) fileMap.set(norm, { r: 0, w: 0, s: 0 });
|
|
68
|
+
const e = fileMap.get(norm);
|
|
69
|
+
if (READ_TOOLS.has(toolName)) e.r++;
|
|
70
|
+
else if (WRITE_TOOLS.has(toolName)) e.w++;
|
|
71
|
+
else if (SEARCH_TOOLS.has(toolName)) e.s++;
|
|
72
|
+
toolMap.set(toolName, (toolMap.get(toolName) ?? 0) + 1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Find all jsonl files ─────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async function findJsonlFiles(projectFilter) {
|
|
80
|
+
const base = join(homedir(), '.claude', 'projects');
|
|
81
|
+
let dirs;
|
|
82
|
+
try { dirs = await readdir(base); } catch { return []; }
|
|
83
|
+
const files = [];
|
|
84
|
+
for (const dir of dirs) {
|
|
85
|
+
if (projectFilter && !dir.includes(projectFilter)) continue;
|
|
86
|
+
const dirPath = join(base, dir);
|
|
87
|
+
let entries;
|
|
88
|
+
try { entries = await readdir(dirPath); } catch { continue; }
|
|
89
|
+
for (const e of entries) {
|
|
90
|
+
if (!e.endsWith('.jsonl')) continue;
|
|
91
|
+
const fp = join(dirPath, e);
|
|
92
|
+
try {
|
|
93
|
+
const s = await stat(fp);
|
|
94
|
+
files.push({ fp, size: s.size, mtime: s.mtimeMs, project: dir });
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return files;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Main ─────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
async function main() {
|
|
104
|
+
const args = process.argv.slice(2);
|
|
105
|
+
const topN = parseInt(args.find(a => a.match(/^\d+$/)) ?? '20');
|
|
106
|
+
const showWrites = args.includes('--writes') || args.includes('-w');
|
|
107
|
+
const showReads = args.includes('--reads') || args.includes('-r');
|
|
108
|
+
const showAll = args.includes('--all') || args.includes('-a');
|
|
109
|
+
const jsonOut = args.includes('--json');
|
|
110
|
+
const projectFilter = args.find(a => a.startsWith('--project='))?.split('=')[1];
|
|
111
|
+
const daysArg = args.find(a => a.startsWith('--days='))?.split('=')[1];
|
|
112
|
+
const sinceMs = daysArg ? Date.now() - parseInt(daysArg) * 86400_000 : 0;
|
|
113
|
+
|
|
114
|
+
const files = await findJsonlFiles(projectFilter);
|
|
115
|
+
if (!files.length) {
|
|
116
|
+
console.error('No Claude Code session files found.');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const filtered = sinceMs ? files.filter(f => f.mtime >= sinceMs) : files;
|
|
121
|
+
|
|
122
|
+
const fileMap = new Map();
|
|
123
|
+
const toolMap = new Map();
|
|
124
|
+
let processed = 0;
|
|
125
|
+
|
|
126
|
+
for (const f of filtered) {
|
|
127
|
+
try {
|
|
128
|
+
const chunk = await readChunk(f.fp, TAIL_BYTES);
|
|
129
|
+
parseChunk(chunk, fileMap, toolMap);
|
|
130
|
+
processed++;
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Build sorted list
|
|
135
|
+
const entries = [...fileMap.entries()].map(([path, c]) => ({
|
|
136
|
+
path, reads: c.r, writes: c.w, searches: c.s, total: c.r + c.w + c.s,
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
// Filter mode
|
|
140
|
+
let display = showWrites ? entries.filter(e => e.writes > 0) :
|
|
141
|
+
showReads ? entries.filter(e => e.reads > 0) :
|
|
142
|
+
entries;
|
|
143
|
+
display.sort((a, b) => (showWrites ? b.writes - a.writes :
|
|
144
|
+
showReads ? b.reads - a.reads :
|
|
145
|
+
b.total - a.total));
|
|
146
|
+
display = display.slice(0, showAll ? 50 : topN);
|
|
147
|
+
|
|
148
|
+
if (jsonOut) {
|
|
149
|
+
console.log(JSON.stringify({ files_analyzed: processed, top: display }, null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const maxVal = display[0]?.total ?? 1;
|
|
154
|
+
const totalOps = [...fileMap.values()].reduce((s, c) => s + c.r + c.w + c.s, 0);
|
|
155
|
+
|
|
156
|
+
console.log(`\n${C.bold}cc-file-churn${C.reset} — Most-touched files in your Claude Code sessions\n`);
|
|
157
|
+
console.log(`${C.dim}Scanned ${processed} session files · ${fileMap.size} unique files · ${totalOps} tool calls${daysArg ? ` (last ${daysArg}d)` : ''}${C.reset}\n`);
|
|
158
|
+
|
|
159
|
+
const rank = [C.yellow + '①', C.white + '②', C.dim + '③'];
|
|
160
|
+
display.forEach((e, i) => {
|
|
161
|
+
const medal = rank[i] ?? C.dim + `${String(i + 1).padStart(2)}`;
|
|
162
|
+
const shortPath = e.path.length > 60 ? '…' + e.path.slice(-59) : e.path;
|
|
163
|
+
const barVal = showWrites ? e.writes : showReads ? e.reads : e.total;
|
|
164
|
+
console.log(`${medal}${C.reset} ${bar(barVal, maxVal)} ${C.bold}${barVal}${C.reset} ${C.dim}total${C.reset}`);
|
|
165
|
+
console.log(` ${C.cyan}${shortPath}${C.reset}`);
|
|
166
|
+
if (e.writes > 0 || e.reads > 0 || e.searches > 0) {
|
|
167
|
+
const parts = [];
|
|
168
|
+
if (e.writes > 0) parts.push(`${C.orange}${e.writes} writes${C.reset}`);
|
|
169
|
+
if (e.reads > 0) parts.push(`${C.green}${e.reads} reads${C.reset}`);
|
|
170
|
+
if (e.searches > 0) parts.push(`${C.dim}${e.searches} searches${C.reset}`);
|
|
171
|
+
console.log(` ${parts.join(' ')}`);
|
|
172
|
+
}
|
|
173
|
+
console.log();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Tool breakdown
|
|
177
|
+
const topTools = [...toolMap.entries()].sort((a, b) => b[1] - a[1]);
|
|
178
|
+
console.log(`${C.dim}Tool breakdown: ${topTools.map(([t, n]) => `${t}:${n}`).join(' · ')}${C.reset}\n`);
|
|
179
|
+
console.log(`${C.dim}Options: --writes (-w) write-heavy files · --reads (-r) read-heavy files · --days=7 last N days · --project=name filter project · --json JSON output${C.reset}\n`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
main().catch(e => { console.error(e.message); process.exit(1); });
|
package/index.html
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>cc-file-churn — Which files does Claude Code touch the most?</title>
|
|
7
|
+
<meta name="description" content="Find the hotspot files in your Claude Code sessions. Drag in your ~/.claude folder to rank files by Edit/Write/Read frequency.">
|
|
8
|
+
<meta property="og:title" content="cc-file-churn — File activity ranking for Claude Code">
|
|
9
|
+
<meta property="og:description" content="Which files does your AI keep coming back to? Ranks by writes, reads, and searches across all sessions.">
|
|
10
|
+
<meta property="og:url" content="https://yurukusa.github.io/cc-file-churn/">
|
|
11
|
+
<meta name="twitter:card" content="summary">
|
|
12
|
+
<meta name="twitter:site" content="@yurukusa_dev">
|
|
13
|
+
<style>
|
|
14
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
15
|
+
:root {
|
|
16
|
+
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
|
17
|
+
--text: #e6edf3; --muted: #8b949e;
|
|
18
|
+
--green: #56d364; --yellow: #f7c948; --orange: #ffa657;
|
|
19
|
+
--cyan: #58d8f0; --blue: #58a6ff; --red: #ff7b72;
|
|
20
|
+
}
|
|
21
|
+
body {
|
|
22
|
+
background: var(--bg); color: var(--text);
|
|
23
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Consolas, monospace;
|
|
24
|
+
min-height: 100vh; display: flex; flex-direction: column;
|
|
25
|
+
align-items: center; padding: 2rem 1rem;
|
|
26
|
+
}
|
|
27
|
+
.header { text-align: center; margin-bottom: 2rem; }
|
|
28
|
+
.header h1 { font-size: 1.8rem; font-weight: 700; color: var(--orange); margin-bottom: 0.5rem; }
|
|
29
|
+
.header p { color: var(--muted); font-size: 0.9rem; }
|
|
30
|
+
.drop-zone {
|
|
31
|
+
width: 100%; max-width: 600px; border: 2px dashed var(--border);
|
|
32
|
+
border-radius: 12px; padding: 3rem 2rem; text-align: center;
|
|
33
|
+
cursor: pointer; transition: border-color 0.2s, background 0.2s;
|
|
34
|
+
margin-bottom: 2rem; background: var(--surface);
|
|
35
|
+
}
|
|
36
|
+
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--orange); background: #1a1a0f; }
|
|
37
|
+
.drop-zone h2 { font-size: 1.1rem; margin-bottom: 0.75rem; }
|
|
38
|
+
.drop-zone p { color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
|
|
39
|
+
.drop-zone .icon { font-size: 2.5rem; margin-bottom: 1rem; }
|
|
40
|
+
.drop-zone input[type="file"] { display: none; }
|
|
41
|
+
.controls {
|
|
42
|
+
width: 100%; max-width: 700px; display: flex; gap: 0.5rem;
|
|
43
|
+
flex-wrap: wrap; margin-bottom: 1.5rem;
|
|
44
|
+
}
|
|
45
|
+
.filter-btn {
|
|
46
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
47
|
+
border-radius: 6px; padding: 0.4rem 0.85rem; font-size: 0.8rem;
|
|
48
|
+
color: var(--muted); cursor: pointer; font-family: inherit;
|
|
49
|
+
transition: border-color 0.15s, color 0.15s;
|
|
50
|
+
}
|
|
51
|
+
.filter-btn.active { border-color: var(--orange); color: var(--orange); }
|
|
52
|
+
.filter-btn:hover { border-color: var(--text); color: var(--text); }
|
|
53
|
+
#results { width: 100%; max-width: 700px; display: none; }
|
|
54
|
+
.meta { font-size: 0.8rem; color: var(--muted); margin-bottom: 1.5rem; text-align: center; }
|
|
55
|
+
.file-row {
|
|
56
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
57
|
+
border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 0.75rem;
|
|
58
|
+
}
|
|
59
|
+
.file-rank { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
|
60
|
+
.rank-num { font-size: 1.1rem; min-width: 1.5rem; }
|
|
61
|
+
.file-path { font-size: 0.82rem; color: var(--cyan); font-family: Consolas, monospace; word-break: break-all; }
|
|
62
|
+
.bar-container { margin-bottom: 0.5rem; }
|
|
63
|
+
.bar-bg { height: 6px; border-radius: 3px; background: #21262d; overflow: hidden; }
|
|
64
|
+
.bar-fill { height: 100%; border-radius: 3px; background: var(--orange); }
|
|
65
|
+
.bar-label { display: flex; justify-content: space-between; font-size: 0.75rem; margin-top: 0.25rem; }
|
|
66
|
+
.counts { display: flex; gap: 1rem; font-size: 0.78rem; flex-wrap: wrap; margin-top: 0.4rem; }
|
|
67
|
+
.count-w { color: var(--orange); } .count-r { color: var(--green); } .count-s { color: var(--muted); }
|
|
68
|
+
.summary-card {
|
|
69
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
70
|
+
border-radius: 8px; padding: 1.25rem; text-align: center; margin-top: 1.5rem;
|
|
71
|
+
}
|
|
72
|
+
.s-title { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 1rem; }
|
|
73
|
+
.s-stats { display: flex; justify-content: center; gap: 2.5rem; flex-wrap: wrap; }
|
|
74
|
+
.s-stat .num { font-size: 1.5rem; font-weight: 700; color: var(--orange); display: block; }
|
|
75
|
+
.s-stat .lbl { font-size: 0.75rem; color: var(--muted); }
|
|
76
|
+
.footer { margin-top: 2rem; text-align: center; font-size: 0.8rem; color: var(--muted); }
|
|
77
|
+
.footer a { color: var(--blue); text-decoration: none; margin: 0 0.5rem; }
|
|
78
|
+
.btn {
|
|
79
|
+
background: var(--orange); color: #0d1117; border: none;
|
|
80
|
+
border-radius: 6px; padding: 0.6rem 1.5rem; font-size: 0.9rem;
|
|
81
|
+
font-weight: 600; cursor: pointer; margin-top: 1rem; font-family: inherit;
|
|
82
|
+
}
|
|
83
|
+
.btn:hover { opacity: 0.85; }
|
|
84
|
+
</style>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<div class="header">
|
|
88
|
+
<h1>cc-file-churn</h1>
|
|
89
|
+
<p>Which files does Claude Code touch the most?</p>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fi').click()">
|
|
92
|
+
<div class="icon">📁</div>
|
|
93
|
+
<h2>Drop your .claude folder here</h2>
|
|
94
|
+
<p>Or click to select it.<br>Your data stays local — nothing is uploaded.</p>
|
|
95
|
+
<input type="file" id="fi" webkitdirectory multiple>
|
|
96
|
+
<button class="btn" onclick="event.stopPropagation(); document.getElementById('fi').click()">
|
|
97
|
+
Select ~/.claude folder
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="controls" id="controls" style="display:none;">
|
|
101
|
+
<button class="filter-btn active" data-mode="total">All activity</button>
|
|
102
|
+
<button class="filter-btn" data-mode="writes">Most-modified</button>
|
|
103
|
+
<button class="filter-btn" data-mode="reads">Most-referenced</button>
|
|
104
|
+
</div>
|
|
105
|
+
<div id="results">
|
|
106
|
+
<div class="meta" id="metaLine"></div>
|
|
107
|
+
<div id="fileList"></div>
|
|
108
|
+
<div class="summary-card" id="summaryCard"></div>
|
|
109
|
+
<div class="footer">
|
|
110
|
+
<a href="https://github.com/yurukusa/cc-file-churn" target="_blank">GitHub</a> ·
|
|
111
|
+
<a href="https://yurukusa.github.io/cc-toolkit/" target="_blank">cc-toolkit (106 free tools)</a> ·
|
|
112
|
+
<span>Also: <code>npx cc-file-churn</code></span>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
<div class="footer" id="footerMain">
|
|
116
|
+
<a href="https://github.com/yurukusa/cc-file-churn" target="_blank">GitHub</a> ·
|
|
117
|
+
<a href="https://yurukusa.github.io/cc-toolkit/" target="_blank">cc-toolkit</a> ·
|
|
118
|
+
<span>Part of 106 free tools for Claude Code</span>
|
|
119
|
+
</div>
|
|
120
|
+
<script>
|
|
121
|
+
const READ_TOOLS = new Set(['Read']);
|
|
122
|
+
const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
|
|
123
|
+
const SEARCH_TOOLS = new Set(['Grep', 'Glob']);
|
|
124
|
+
const ALL_TRACKED = new Set([...READ_TOOLS, ...WRITE_TOOLS, ...SEARCH_TOOLS]);
|
|
125
|
+
|
|
126
|
+
let allFiles = [];
|
|
127
|
+
let currentMode = 'total';
|
|
128
|
+
|
|
129
|
+
const dropZone = document.getElementById('dropZone');
|
|
130
|
+
const fi = document.getElementById('fi');
|
|
131
|
+
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
|
132
|
+
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
|
133
|
+
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); processFiles(Array.from(e.dataTransfer.files)); });
|
|
134
|
+
fi.addEventListener('change', e => processFiles(Array.from(e.target.files)));
|
|
135
|
+
|
|
136
|
+
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
137
|
+
btn.addEventListener('click', () => {
|
|
138
|
+
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
139
|
+
btn.classList.add('active');
|
|
140
|
+
currentMode = btn.dataset.mode;
|
|
141
|
+
renderFiles(allFiles);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
async function processFiles(files) {
|
|
146
|
+
const jsonlFiles = files.filter(f => f.name.endsWith('.jsonl') && f.webkitRelativePath.includes('projects'));
|
|
147
|
+
if (!jsonlFiles.length) { alert('No session files found.\nSelect your .claude folder (not a subfolder).'); return; }
|
|
148
|
+
const fileMap = new Map();
|
|
149
|
+
for (const f of jsonlFiles) {
|
|
150
|
+
try {
|
|
151
|
+
const text = await readChunk(f, 4 * 1024 * 1024);
|
|
152
|
+
parseChunk(text, fileMap);
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
allFiles = [...fileMap.entries()].map(([path, c]) => ({
|
|
156
|
+
path, r: c.r, w: c.w, s: c.s, total: c.r + c.w + c.s
|
|
157
|
+
}));
|
|
158
|
+
const totalOps = allFiles.reduce((sum, e) => sum + e.total, 0);
|
|
159
|
+
document.getElementById('metaLine').textContent =
|
|
160
|
+
`${jsonlFiles.length} session files · ${allFiles.length} unique files · ${totalOps} tool calls`;
|
|
161
|
+
dropZone.style.display = 'none';
|
|
162
|
+
document.getElementById('footerMain').style.display = 'none';
|
|
163
|
+
document.getElementById('controls').style.display = 'flex';
|
|
164
|
+
document.getElementById('results').style.display = 'block';
|
|
165
|
+
renderFiles(allFiles);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readChunk(file, maxBytes) {
|
|
169
|
+
const start = Math.max(0, file.size - maxBytes);
|
|
170
|
+
return await file.slice(start).text();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseChunk(text, fileMap) {
|
|
174
|
+
for (const line of text.split('\n')) {
|
|
175
|
+
if (!line.trim()) continue;
|
|
176
|
+
let d; try { d = JSON.parse(line); } catch { continue; }
|
|
177
|
+
if (d.type !== 'assistant') continue;
|
|
178
|
+
for (const c of d?.message?.content ?? []) {
|
|
179
|
+
if (c?.type !== 'tool_use' || !ALL_TRACKED.has(c.name)) continue;
|
|
180
|
+
const path = c.input?.file_path ?? c.input?.path ?? '';
|
|
181
|
+
if (!path || typeof path !== 'string') continue;
|
|
182
|
+
const norm = path.replace(/^\/home\/[^/]+/, '~');
|
|
183
|
+
if (!fileMap.has(norm)) fileMap.set(norm, { r: 0, w: 0, s: 0 });
|
|
184
|
+
const e = fileMap.get(norm);
|
|
185
|
+
if (READ_TOOLS.has(c.name)) e.r++;
|
|
186
|
+
else if (WRITE_TOOLS.has(c.name)) e.w++;
|
|
187
|
+
else if (SEARCH_TOOLS.has(c.name)) e.s++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderFiles(files) {
|
|
193
|
+
const sorted = [...files].sort((a, b) =>
|
|
194
|
+
currentMode === 'writes' ? b.w - a.w :
|
|
195
|
+
currentMode === 'reads' ? b.r - a.r : b.total - a.total
|
|
196
|
+
).slice(0, 25);
|
|
197
|
+
|
|
198
|
+
const maxVal = sorted[0]?.[currentMode === 'writes' ? 'w' : currentMode === 'reads' ? 'r' : 'total'] ?? 1;
|
|
199
|
+
const medals = ['🥇', '🥈', '🥉'];
|
|
200
|
+
const list = document.getElementById('fileList');
|
|
201
|
+
list.innerHTML = sorted.map((e, i) => {
|
|
202
|
+
const val = currentMode === 'writes' ? e.w : currentMode === 'reads' ? e.r : e.total;
|
|
203
|
+
const pct = (val / maxVal * 100).toFixed(0);
|
|
204
|
+
const short = e.path.length > 65 ? '…' + e.path.slice(-64) : e.path;
|
|
205
|
+
const countParts = [];
|
|
206
|
+
if (e.w > 0) countParts.push(`<span class="count-w">${e.w} writes</span>`);
|
|
207
|
+
if (e.r > 0) countParts.push(`<span class="count-r">${e.r} reads</span>`);
|
|
208
|
+
if (e.s > 0) countParts.push(`<span class="count-s">${e.s} searches</span>`);
|
|
209
|
+
return `<div class="file-row">
|
|
210
|
+
<div class="file-rank">
|
|
211
|
+
<span class="rank-num">${medals[i] ?? '#' + (i+1)}</span>
|
|
212
|
+
<span style="font-size:1.1rem;font-weight:700;color:var(--orange)">${val}</span>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="file-path">${short}</div>
|
|
215
|
+
<div class="bar-container">
|
|
216
|
+
<div class="bar-bg"><div class="bar-fill" style="width:${pct}%"></div></div>
|
|
217
|
+
<div class="bar-label"><span>${currentMode}</span><span style="color:var(--muted)">${pct}%</span></div>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="counts">${countParts.join('')}</div>
|
|
220
|
+
</div>`;
|
|
221
|
+
}).join('');
|
|
222
|
+
|
|
223
|
+
const totalWrites = files.reduce((s, e) => s + e.w, 0);
|
|
224
|
+
const totalReads = files.reduce((s, e) => s + e.r, 0);
|
|
225
|
+
const topFile = sorted[0];
|
|
226
|
+
document.getElementById('summaryCard').innerHTML = `
|
|
227
|
+
<div class="s-title">Summary</div>
|
|
228
|
+
<div class="s-stats">
|
|
229
|
+
<div class="s-stat"><span class="num">${files.length}</span><span class="lbl">unique files</span></div>
|
|
230
|
+
<div class="s-stat"><span class="num" style="color:var(--orange)">${totalWrites}</span><span class="lbl">total writes</span></div>
|
|
231
|
+
<div class="s-stat"><span class="num" style="color:var(--green)">${totalReads}</span><span class="lbl">total reads</span></div>
|
|
232
|
+
<div class="s-stat"><span class="num">${topFile ? topFile.total : 0}</span><span class="lbl">max activity (#1)</span></div>
|
|
233
|
+
</div>`;
|
|
234
|
+
}
|
|
235
|
+
</script>
|
|
236
|
+
</body>
|
|
237
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-file-churn",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Which files does Claude Code touch the most? Ranks files by Edit/Write/Read frequency across all sessions.",
|
|
5
|
+
"main": "cli.mjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-file-churn": "./cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node cli.mjs"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"claude-code",
|
|
14
|
+
"claude",
|
|
15
|
+
"ai",
|
|
16
|
+
"file-churn",
|
|
17
|
+
"developer-tools",
|
|
18
|
+
"cli",
|
|
19
|
+
"productivity"
|
|
20
|
+
],
|
|
21
|
+
"author": "yurukusa",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
}
|
|
27
|
+
}
|