tokenlean 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.
@@ -20,137 +20,186 @@ if (process.argv.includes('--prompt')) {
20
20
 
21
21
  import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
22
22
  import { join, relative } from 'path';
23
+ import {
24
+ createOutput,
25
+ parseCommonArgs,
26
+ estimateTokens,
27
+ formatTokens,
28
+ formatTable,
29
+ COMMON_OPTIONS_HELP
30
+ } from '../src/output.mjs';
31
+ import {
32
+ findProjectRoot,
33
+ shouldSkip,
34
+ getSkipDirs,
35
+ getImportantDirs
36
+ } from '../src/project.mjs';
37
+
38
+ const HELP = `
39
+ tl-context - Estimate context token usage for files/directories
40
+
41
+ Usage: tl-context [path] [options]
42
+
43
+ Options:
44
+ --top N, -n N Show top N files (default: 20, use --all for all)
45
+ --all Show all files
46
+ ${COMMON_OPTIONS_HELP}
47
+
48
+ Examples:
49
+ tl-context src/ # Estimate tokens for src directory
50
+ tl-context src/ --top 10 # Show top 10 largest files
51
+ tl-context src/ --all # Show all files
52
+ tl-context package.json # Single file estimate
53
+ tl-context -j # JSON output for scripting
54
+ `;
55
+
56
+ function analyzeDir(dirPath, results = [], skipDirs, importantDirs) {
57
+ try {
58
+ const entries = readdirSync(dirPath, { withFileTypes: true });
59
+
60
+ for (const entry of entries) {
61
+ if (entry.name.startsWith('.') && !importantDirs.has(entry.name)) continue;
62
+ if (shouldSkip(entry.name, entry.isDirectory())) continue;
63
+
64
+ const fullPath = join(dirPath, entry.name);
65
+
66
+ if (entry.isDirectory()) {
67
+ analyzeDir(fullPath, results, skipDirs, importantDirs);
68
+ } else {
69
+ try {
70
+ const content = readFileSync(fullPath, 'utf-8');
71
+ const tokens = estimateTokens(content);
72
+ results.push({ path: fullPath, tokens, lines: content.split('\n').length });
73
+ } catch {
74
+ // Skip binary or unreadable files
75
+ }
76
+ }
77
+ }
78
+ } catch {
79
+ // Permission error
80
+ }
23
81
 
24
- const SKIP_DIRS = new Set([
25
- 'node_modules', '.git', 'android', 'ios', 'dist', 'build',
26
- '.expo', '.next', 'coverage', '__pycache__', '.cache'
27
- ]);
28
-
29
- const SKIP_EXTENSIONS = new Set([
30
- '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp',
31
- '.woff', '.woff2', '.ttf', '.eot',
32
- '.mp3', '.mp4', '.wav', '.ogg',
33
- '.zip', '.tar', '.gz',
34
- '.lock', '.log'
35
- ]);
36
-
37
- // Rough token estimate: ~4 chars per token for code
38
- function estimateTokens(content) {
39
- return Math.ceil(content.length / 4);
82
+ return results;
40
83
  }
41
84
 
42
- function formatTokens(tokens) {
43
- if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
44
- if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
45
- return String(tokens);
46
- }
85
+ // Main
86
+ const args = process.argv.slice(2);
87
+ const options = parseCommonArgs(args);
47
88
 
48
- function shouldSkip(name, isDir) {
49
- if (isDir && SKIP_DIRS.has(name)) return true;
50
- if (!isDir) {
51
- const ext = name.substring(name.lastIndexOf('.'));
52
- if (SKIP_EXTENSIONS.has(ext)) return true;
89
+ // Parse tool-specific options
90
+ let topN = 20;
91
+ let targetPath = '.';
92
+
93
+ for (let i = 0; i < options.remaining.length; i++) {
94
+ const arg = options.remaining[i];
95
+ if ((arg === '--top' || arg === '-n') && options.remaining[i + 1]) {
96
+ topN = parseInt(options.remaining[i + 1], 10);
97
+ i++;
98
+ } else if (arg === '--all') {
99
+ topN = null;
100
+ } else if (!arg.startsWith('-')) {
101
+ targetPath = arg;
53
102
  }
54
- return false;
55
103
  }
56
104
 
57
- function analyzeDir(dirPath, results = [], depth = 0) {
58
- const entries = readdirSync(dirPath, { withFileTypes: true });
105
+ if (options.help) {
106
+ console.log(HELP);
107
+ process.exit(0);
108
+ }
59
109
 
60
- for (const entry of entries) {
61
- if (entry.name.startsWith('.') && entry.name !== '.claude') continue;
62
- if (shouldSkip(entry.name, entry.isDirectory())) continue;
110
+ if (!existsSync(targetPath)) {
111
+ console.error(`Path not found: ${targetPath}`);
112
+ process.exit(1);
113
+ }
63
114
 
64
- const fullPath = join(dirPath, entry.name);
115
+ const projectRoot = findProjectRoot();
116
+ const skipDirs = getSkipDirs();
117
+ const importantDirs = getImportantDirs();
118
+ const out = createOutput(options);
65
119
 
66
- if (entry.isDirectory()) {
67
- analyzeDir(fullPath, results, depth + 1);
68
- } else {
69
- try {
70
- const content = readFileSync(fullPath, 'utf-8');
71
- const tokens = estimateTokens(content);
72
- results.push({ path: fullPath, tokens, lines: content.split('\n').length });
73
- } catch (e) {
74
- // Skip binary or unreadable files
75
- }
76
- }
77
- }
120
+ const stat = statSync(targetPath);
121
+ if (stat.isFile()) {
122
+ // Single file
123
+ const content = readFileSync(targetPath, 'utf-8');
124
+ const tokens = estimateTokens(content);
125
+ const lines = content.split('\n').length;
78
126
 
79
- return results;
80
- }
127
+ out.setData('file', targetPath);
128
+ out.setData('tokens', tokens);
129
+ out.setData('lines', lines);
130
+
131
+ out.header(`${targetPath}: ~${formatTokens(tokens)} tokens (${lines} lines)`);
132
+ out.print();
133
+ } else {
134
+ // Directory
135
+ const results = analyzeDir(targetPath, [], skipDirs, importantDirs);
81
136
 
82
- function printResults(results, rootPath, topN) {
83
137
  // Sort by tokens descending
84
138
  results.sort((a, b) => b.tokens - a.tokens);
85
139
 
86
140
  const total = results.reduce((sum, r) => sum + r.tokens, 0);
141
+ const totalLines = results.reduce((sum, r) => sum + r.lines, 0);
87
142
 
88
- console.log(`\nšŸ“Š Context Estimate for: ${rootPath}\n`);
89
- console.log(`Total: ~${formatTokens(total)} tokens across ${results.length} files\n`);
90
-
91
- if (topN) {
92
- console.log(`Top ${topN} largest files:\n`);
93
- results = results.slice(0, topN);
94
- }
143
+ // Set JSON data
144
+ out.setData('path', targetPath);
145
+ out.setData('totalTokens', total);
146
+ out.setData('totalLines', totalLines);
147
+ out.setData('fileCount', results.length);
95
148
 
96
- const maxPathLen = Math.min(60, Math.max(...results.map(r => relative(rootPath, r.path).length)));
149
+ // Header
150
+ out.header(`Context Estimate: ${targetPath}`);
151
+ out.header(`Total: ~${formatTokens(total)} tokens across ${results.length} files`);
152
+ out.blank();
97
153
 
98
- console.log(' Tokens Lines Path');
99
- console.log(' ' + '-'.repeat(maxPathLen + 20));
154
+ // File list
155
+ const displayResults = topN ? results.slice(0, topN) : results;
100
156
 
101
- for (const r of results) {
102
- const relPath = relative(rootPath, r.path);
103
- const truncPath = relPath.length > 60 ? '...' + relPath.slice(-57) : relPath;
104
- console.log(` ${formatTokens(r.tokens).padStart(6)} ${String(r.lines).padStart(5)} ${truncPath}`);
157
+ if (displayResults.length > 0) {
158
+ if (topN) {
159
+ out.header(`Top ${Math.min(topN, results.length)} largest files:`);
160
+ }
161
+ out.blank();
162
+
163
+ // Format as table
164
+ const rows = displayResults.map(r => {
165
+ const relPath = relative(targetPath, r.path);
166
+ const truncPath = relPath.length > 60 ? '...' + relPath.slice(-57) : relPath;
167
+ return [formatTokens(r.tokens), r.lines, truncPath];
168
+ });
169
+
170
+ const tableLines = formatTable(rows, { indent: ' ', separator: ' ' });
171
+ out.add(' Tokens Lines Path');
172
+ out.add(' ' + '-'.repeat(70));
173
+ out.addLines(tableLines);
105
174
  }
106
175
 
107
- console.log();
108
-
109
176
  // Group by directory
110
177
  const byDir = {};
111
178
  for (const r of results) {
112
- const rel = relative(rootPath, r.path);
179
+ const rel = relative(targetPath, r.path);
113
180
  const dir = rel.includes('/') ? rel.split('/')[0] : '.';
114
181
  byDir[dir] = (byDir[dir] || 0) + r.tokens;
115
182
  }
116
183
 
117
184
  const sortedDirs = Object.entries(byDir).sort((a, b) => b[1] - a[1]);
118
185
 
119
- console.log('By top-level directory:\n');
120
- for (const [dir, tokens] of sortedDirs.slice(0, 10)) {
121
- const pct = ((tokens / total) * 100).toFixed(1);
122
- console.log(` ${formatTokens(tokens).padStart(6)} ${pct.padStart(5)}% ${dir}/`);
123
- }
124
- console.log();
125
- }
186
+ out.blank();
187
+ out.header('By top-level directory:');
188
+ out.blank();
126
189
 
127
- // Main
128
- const args = process.argv.slice(2);
129
- let targetPath = '.';
130
- let topN = 20;
190
+ const dirRows = sortedDirs.slice(0, 10).map(([dir, tokens]) => {
191
+ const pct = ((tokens / total) * 100).toFixed(1) + '%';
192
+ return [formatTokens(tokens), pct, dir + '/'];
193
+ });
131
194
 
132
- for (let i = 0; i < args.length; i++) {
133
- if (args[i] === '--top' && args[i + 1]) {
134
- topN = parseInt(args[i + 1], 10);
135
- i++;
136
- } else if (args[i] === '--all') {
137
- topN = null;
138
- } else if (!args[i].startsWith('-')) {
139
- targetPath = args[i];
140
- }
141
- }
195
+ out.addLines(formatTable(dirRows, { indent: ' ', separator: ' ' }));
142
196
 
143
- if (!existsSync(targetPath)) {
144
- console.error(`Path not found: ${targetPath}`);
145
- process.exit(1);
146
- }
197
+ out.setData('byDirectory', Object.fromEntries(sortedDirs));
198
+ out.setData('files', results.slice(0, 100).map(r => ({
199
+ path: relative(targetPath, r.path),
200
+ tokens: r.tokens,
201
+ lines: r.lines
202
+ })));
147
203
 
148
- const stat = statSync(targetPath);
149
- if (stat.isFile()) {
150
- const content = readFileSync(targetPath, 'utf-8');
151
- const tokens = estimateTokens(content);
152
- console.log(`\n${targetPath}: ~${formatTokens(tokens)} tokens (${content.split('\n').length} lines)\n`);
153
- } else {
154
- const results = analyzeDir(targetPath);
155
- printResults(results, targetPath, topN);
204
+ out.print();
156
205
  }
package/bin/tl-diff.mjs CHANGED
@@ -21,6 +21,30 @@ if (process.argv.includes('--prompt')) {
21
21
  }
22
22
 
23
23
  import { execSync } from 'child_process';
24
+ import {
25
+ createOutput,
26
+ parseCommonArgs,
27
+ formatTokens,
28
+ COMMON_OPTIONS_HELP
29
+ } from '../src/output.mjs';
30
+
31
+ const HELP = `
32
+ tl-diff - Token-efficient git diff summary
33
+
34
+ Usage: tl-diff [ref] [options]
35
+
36
+ Options:
37
+ --staged Show staged changes only
38
+ --stat-only Show just the summary (no file list)
39
+ ${COMMON_OPTIONS_HELP}
40
+
41
+ Examples:
42
+ tl-diff # Working directory changes
43
+ tl-diff --staged # Staged changes
44
+ tl-diff HEAD~3 # Last 3 commits
45
+ tl-diff main # Changes vs main branch
46
+ tl-diff -j # JSON output
47
+ `;
24
48
 
25
49
  function run(cmd) {
26
50
  try {
@@ -30,15 +54,6 @@ function run(cmd) {
30
54
  }
31
55
  }
32
56
 
33
- function estimateTokens(content) {
34
- return Math.ceil(content.length / 4);
35
- }
36
-
37
- function formatTokens(tokens) {
38
- if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
39
- return String(tokens);
40
- }
41
-
42
57
  function parseDiffStat(stat) {
43
58
  const lines = stat.trim().split('\n');
44
59
  const files = [];
@@ -96,66 +111,30 @@ function categorizeChanges(files) {
96
111
  return categories;
97
112
  }
98
113
 
99
- function printSummary(files, categories, options) {
100
- const totalChanges = files.reduce((sum, f) => sum + f.changes, 0);
101
- const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0);
102
- const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0);
103
-
104
- console.log(`\nšŸ“Š Diff Summary`);
105
- console.log(` ${files.length} files changed, ~${formatTokens(totalChanges * 4)} tokens of changes`);
106
- console.log(` +${totalAdditions} additions, -${totalDeletions} deletions\n`);
107
-
108
- const order = ['components', 'hooks', 'store', 'types', 'manuscripts', 'tests', 'config', 'other'];
109
- const labels = {
110
- components: '🧩 Components',
111
- hooks: 'šŸŖ Hooks',
112
- store: 'šŸ“¦ Store',
113
- types: 'šŸ“ Types',
114
- manuscripts: 'šŸ“– Manuscripts',
115
- tests: '🧪 Tests',
116
- config: 'āš™ļø Config',
117
- other: 'šŸ“„ Other'
118
- };
119
-
120
- for (const cat of order) {
121
- const catFiles = categories[cat];
122
- if (catFiles.length === 0) continue;
123
-
124
- console.log(`${labels[cat]} (${catFiles.length})`);
125
-
126
- // Sort by changes descending
127
- catFiles.sort((a, b) => b.changes - a.changes);
128
-
129
- for (const f of catFiles.slice(0, 10)) {
130
- const bar = '+'.repeat(Math.min(f.additions, 20)) + '-'.repeat(Math.min(f.deletions, 20));
131
- console.log(` ${f.path}`);
132
- console.log(` ${f.changes} changes ${bar}`);
133
- }
134
-
135
- if (catFiles.length > 10) {
136
- console.log(` ... and ${catFiles.length - 10} more`);
137
- }
138
-
139
- console.log();
140
- }
141
- }
142
-
143
114
  // Main
144
115
  const args = process.argv.slice(2);
116
+ const options = parseCommonArgs(args);
117
+
118
+ // Parse tool-specific options
145
119
  let ref = '';
146
120
  let staged = false;
147
121
  let statOnly = false;
148
122
 
149
- for (let i = 0; i < args.length; i++) {
150
- if (args[i] === '--staged') {
123
+ for (const arg of options.remaining) {
124
+ if (arg === '--staged') {
151
125
  staged = true;
152
- } else if (args[i] === '--stat-only') {
126
+ } else if (arg === '--stat-only') {
153
127
  statOnly = true;
154
- } else if (!args[i].startsWith('-')) {
155
- ref = args[i];
128
+ } else if (!arg.startsWith('-')) {
129
+ ref = arg;
156
130
  }
157
131
  }
158
132
 
133
+ if (options.help) {
134
+ console.log(HELP);
135
+ process.exit(0);
136
+ }
137
+
159
138
  // Build git diff command
160
139
  let diffCmd = 'git diff';
161
140
  if (staged) {
@@ -167,17 +146,71 @@ diffCmd += ' --stat=200';
167
146
 
168
147
  const stat = run(diffCmd);
169
148
 
149
+ const out = createOutput(options);
150
+
170
151
  if (!stat.trim()) {
171
- console.log('\n✨ No changes detected\n');
152
+ out.header('No changes detected');
153
+ out.print();
172
154
  process.exit(0);
173
155
  }
174
156
 
175
157
  const files = parseDiffStat(stat);
176
158
  const categories = categorizeChanges(files);
177
159
 
178
- printSummary(files, categories, { statOnly });
160
+ const totalChanges = files.reduce((sum, f) => sum + f.changes, 0);
161
+ const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0);
162
+ const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0);
163
+
164
+ // Set JSON data
165
+ out.setData('files', files);
166
+ out.setData('categories', categories);
167
+ out.setData('totalFiles', files.length);
168
+ out.setData('totalChanges', totalChanges);
169
+ out.setData('estimatedTokens', totalChanges * 4);
170
+
171
+ // Summary header
172
+ out.header('Diff Summary');
173
+ out.header(`${files.length} files changed, ~${formatTokens(totalChanges * 4)} tokens of changes`);
174
+ out.header(`+${totalAdditions} additions, -${totalDeletions} deletions`);
175
+ out.blank();
179
176
 
180
177
  if (!statOnly) {
181
- console.log('šŸ’” Tip: Use --stat-only for just the summary, or check specific files with:');
182
- console.log(' git diff [ref] -- path/to/file.ts\n');
178
+ const order = ['components', 'hooks', 'store', 'types', 'manuscripts', 'tests', 'config', 'other'];
179
+ const labels = {
180
+ components: 'Components',
181
+ hooks: 'Hooks',
182
+ store: 'Store',
183
+ types: 'Types',
184
+ manuscripts: 'Manuscripts',
185
+ tests: 'Tests',
186
+ config: 'Config',
187
+ other: 'Other'
188
+ };
189
+
190
+ for (const cat of order) {
191
+ const catFiles = categories[cat];
192
+ if (catFiles.length === 0) continue;
193
+
194
+ out.add(`${labels[cat]} (${catFiles.length})`);
195
+
196
+ // Sort by changes descending
197
+ catFiles.sort((a, b) => b.changes - a.changes);
198
+
199
+ for (const f of catFiles.slice(0, 10)) {
200
+ const bar = '+'.repeat(Math.min(f.additions, 20)) + '-'.repeat(Math.min(f.deletions, 20));
201
+ out.add(` ${f.path}`);
202
+ out.add(` ${f.changes} changes ${bar}`);
203
+ }
204
+
205
+ if (catFiles.length > 10) {
206
+ out.add(` ... and ${catFiles.length - 10} more`);
207
+ }
208
+
209
+ out.blank();
210
+ }
211
+
212
+ out.header('Tip: Use --stat-only for just the summary, or check specific files with:');
213
+ out.header(' git diff [ref] -- path/to/file.ts');
183
214
  }
215
+
216
+ out.print();
package/bin/tl-entry.mjs CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  COMMON_OPTIONS_HELP
31
31
  } from '../src/output.mjs';
32
32
  import { findProjectRoot } from '../src/project.mjs';
33
+ import { withCache } from '../src/cache.mjs';
33
34
 
34
35
  const HELP = `
35
36
  tl-entry - Find entry points in a codebase
@@ -130,7 +131,12 @@ function findEntryPoints(searchPath, projectRoot, filterType) {
130
131
  for (const { pattern, desc } of config.patterns) {
131
132
  try {
132
133
  const cmd = `rg -n -g "*.{ts,tsx,js,jsx,mjs}" --no-heading -e "${shellEscape(pattern)}" "${shellEscape(searchPath)}" 2>/dev/null || true`;
133
- const output = execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
134
+ const cacheKey = { op: 'rg-entry-pattern', pattern, path: searchPath };
135
+ const output = withCache(
136
+ cacheKey,
137
+ () => execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }),
138
+ { projectRoot }
139
+ );
134
140
 
135
141
  for (const line of output.trim().split('\n')) {
136
142
  if (!line) continue;
package/bin/tl-impact.mjs CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  COMMON_OPTIONS_HELP
34
34
  } from '../src/output.mjs';
35
35
  import { findProjectRoot, categorizeFile } from '../src/project.mjs';
36
+ import { withCache } from '../src/cache.mjs';
36
37
 
37
38
  const HELP = `
38
39
  tl-impact - Analyze the blast radius of changing a file
@@ -79,8 +80,15 @@ function findDirectImporters(filePath, projectRoot) {
79
80
  const patterns = searchTerms.map(t => `-e "${rgEscape(t)}"`).join(' ');
80
81
 
81
82
  try {
82
- const rgCommand = `rg -l --type-add 'code:*.{js,jsx,ts,tsx,mjs,mts,cjs}' -t code ${patterns} "${projectRoot}" 2>/dev/null || true`;
83
- const result = execSync(rgCommand, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
83
+ const cacheKey = { op: 'rg-find-candidates', terms: searchTerms.sort() };
84
+ const result = withCache(
85
+ cacheKey,
86
+ () => {
87
+ const rgCommand = `rg -l --type-add 'code:*.{js,jsx,ts,tsx,mjs,mts,cjs}' -t code ${patterns} "${projectRoot}" 2>/dev/null || true`;
88
+ return execSync(rgCommand, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
89
+ },
90
+ { projectRoot }
91
+ );
84
92
  const candidates = result.trim().split('\n').filter(Boolean);
85
93
 
86
94
  for (const candidate of candidates) {