ai-credit 1.0.2 โ 1.0.4
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 +62 -34
- package/dist/analyzer.js +50 -24
- package/dist/cli.js +5 -5
- package/dist/reporter.js +3 -1
- package/dist/scanners/base.d.ts +4 -0
- package/dist/scanners/base.js +9 -3
- package/dist/scanners/claude.d.ts +5 -1
- package/dist/scanners/claude.js +17 -4
- package/dist/scanners/codex.d.ts +12 -0
- package/dist/scanners/codex.js +80 -25
- package/dist/scanners/gemini.d.ts +5 -0
- package/dist/scanners/gemini.js +62 -10
- package/dist/scanners/opencode.js +5 -3
- package/package.json +4 -3
- package/dist/scanners/aider.d.ts +0 -35
- package/dist/scanners/aider.js +0 -205
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ai-credit)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
A command-line tool to track and analyze AI coding assistants' contributions in your codebase. Supports **Claude Code**, **Codex CLI**, **Gemini CLI**, and **Opencode**.
|
|
6
|
+
A command-line tool to track and analyze AI coding assistants' contributions in your codebase (macOS/Linux). Supports **Claude Code**, **Codex CLI**, **Gemini CLI**, and **Opencode**.
|
|
7
7
|
|
|
8
8
|
## Quick Start
|
|
9
9
|
|
|
@@ -18,7 +18,7 @@ ai-credit
|
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
20
|
|
|
21
|
-
- ๐ **Auto-detection**: Automatically finds AI tool session data on your system
|
|
21
|
+
- ๐ **Auto-detection**: Automatically finds AI tool session data on your system (macOS/Linux)
|
|
22
22
|
- ๐ **Detailed Statistics**: Lines of code, files modified, contribution ratios
|
|
23
23
|
- ๐ค **Multi-tool Support**: Claude Code, Codex CLI, Gemini CLI, Opencode
|
|
24
24
|
- ๐ **Visual Reports**: Console, JSON, and Markdown output formats
|
|
@@ -72,6 +72,7 @@ Shows which AI tools have data available on your system:
|
|
|
72
72
|
Claude Code ~/.claude/projects/ โ Available
|
|
73
73
|
Codex CLI ~/.codex/sessions/ โ Available
|
|
74
74
|
Gemini CLI ~/.gemini/tmp/ โ Not found
|
|
75
|
+
Opencode ~/.local/share/opencode/ โ Available
|
|
75
76
|
```
|
|
76
77
|
|
|
77
78
|
### File-level Analysis
|
|
@@ -101,35 +102,57 @@ Lists all AI sessions for the repository.
|
|
|
101
102
|
## Output Example
|
|
102
103
|
|
|
103
104
|
```
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
โ
|
|
115
|
-
|
|
116
|
-
โ
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
โ
|
|
126
|
-
|
|
105
|
+
ai-credit (main) npx ai-credit
|
|
106
|
+
Leave a ๐ star if you like it: https://github.com/debugtheworldbot/ai-credit
|
|
107
|
+
|
|
108
|
+
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
|
|
109
|
+
โ AI Contribution Analysis โ
|
|
110
|
+
โ Repository: /Users/eric/Developer/ai-credit โ
|
|
111
|
+
โ Scan time: 2/2/2026, 4:22:53 PM โ
|
|
112
|
+
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
|
|
113
|
+
๐ Overview
|
|
114
|
+
โโโโโโโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโโโโโโโโโ
|
|
115
|
+
โ Metric โ Value โ AI Contribution โ
|
|
116
|
+
โโโโโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโโโโค
|
|
117
|
+
โ Total Files โ 18 โ 15 (83.3%) โ
|
|
118
|
+
โโโโโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโโโโค
|
|
119
|
+
โ Total Lines โ 3496 โ 1660 (47.5%) โ
|
|
120
|
+
โโโโโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโโโโค
|
|
121
|
+
โ AI Sessions โ 6 โ - โ
|
|
122
|
+
โโโโโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโโโโโ
|
|
123
|
+
|
|
124
|
+
๐ค Contribution by AI Tool
|
|
125
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
|
|
126
|
+
โ Tool / Model โ Sessions โ Files โ Lines Added โ Lines Removed โ Share โ
|
|
127
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
128
|
+
โ Opencode โ 2 โ 12 โ +558 โ -128 โ 32.2% โ
|
|
129
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
130
|
+
โ โโ kimi-k2.5-free โ 2 โ 12 โ +558 โ -128 โ 100.0% (of tool) โ
|
|
131
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
132
|
+
โ Codex CLI โ 1 โ 11 โ +482 โ -303 โ 27.8% โ
|
|
133
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
134
|
+
โ โโ gpt-5.2-codex โ 1 โ 11 โ +482 โ -303 โ 100.0% (of tool) โ
|
|
135
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
136
|
+
โ Gemini CLI โ 2 โ 11 โ +357 โ -262 โ 20.6% โ
|
|
137
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
138
|
+
โ โโ gemini-2.5-pro โ 1 โ 8 โ +330 โ -237 โ 92.4% (of tool) โ
|
|
139
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
140
|
+
โ โโ gemini-3-pro-preview โ 1 โ 5 โ +27 โ -25 โ 7.6% (of tool) โ
|
|
141
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
142
|
+
โ Claude Code โ 1 โ 5 โ +338 โ -452 โ 19.5% โ
|
|
143
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
144
|
+
โ โโ claude-opus-4-5-20251101 โ 1 โ 5 โ +338 โ -452 โ 100.0% (of tool) โ
|
|
145
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโ
|
|
127
146
|
|
|
128
147
|
๐ Contribution Distribution
|
|
129
148
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
Opencode
|
|
149
|
+
๐ง๐ง๐ง๐ง๐ง๐ง๐ฆ๐ฆ๐ฆ๐ฆ๐ฆ๐ช๐ช๐ช๐ช๐ช๐ช๐ฉ๐ฉ๐ฉโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌ
|
|
150
|
+
|
|
151
|
+
๐ Opencode 15.3% (534 lines)
|
|
152
|
+
๐ต Codex CLI 13.2% (461 lines)
|
|
153
|
+
๐ฃ Gemini CLI 9.8% (342 lines)
|
|
154
|
+
๐ข Claude Code 9.2% (323 lines)
|
|
155
|
+
โช Unknown/Human 52.5% (1836 lines)
|
|
133
156
|
```
|
|
134
157
|
|
|
135
158
|
## Supported AI Tools
|
|
@@ -217,13 +240,14 @@ Here's a detailed breakdown of the parsing method for each supported tool:
|
|
|
217
240
|
### 3. Gemini CLI
|
|
218
241
|
|
|
219
242
|
- **File Format**: JSON (`.json`), where one file represents a complete session.
|
|
220
|
-
- **Scan
|
|
243
|
+
- **Scan Paths**: `~/.gemini/tmp/<hash>/chats/*.json`, `~/.gemini/history/*.json`, `~/.gemini/sessions/*.json`
|
|
221
244
|
- **Parsing Logic**:
|
|
222
|
-
1. The
|
|
245
|
+
1. The scanner searches Geminiโs session JSON files under common locations (`tmp`, `history`, `sessions`).
|
|
223
246
|
2. It parses the entire JSON file, which typically contains a `"messages"` or `"turns"` array logging the conversation history.
|
|
224
247
|
3. It iterates through the `messages` array, looking for `"parts"` arrays within messages from the `"assistant"` role.
|
|
225
|
-
4. Within the `parts` array, it searches for an object containing a `"functionCall"
|
|
248
|
+
4. Within the `parts` array, it searches for an object containing a `"functionCall"` (or `toolCalls`). This object's structure is similar to Codex CLI.
|
|
226
249
|
5. It extracts the `"name"` (function name) and `"args"` (arguments dictionary) from the `functionCall` object.
|
|
250
|
+
6. **Project matching**: if the session JSON has an explicit project path (`projectPath/cwd/...`), it must match the target repo. If not, the scanner only keeps tool calls whose `file_path` is inside the target repo.
|
|
227
251
|
|
|
228
252
|
**Example (Simplified Gemini CLI JSON Fragment):**
|
|
229
253
|
|
|
@@ -266,20 +290,23 @@ The tool applies a strict verification rule when calculating AI contribution sta
|
|
|
266
290
|
|
|
267
291
|
1. **Parse AI Session Logs**: The scanner reads session files from each AI tool and extracts file change events (writes, edits, patches).
|
|
268
292
|
|
|
269
|
-
2. **
|
|
293
|
+
2. **Build Repository File Set**: The tool gathers repository files using text-file extensions, excluding common build/vendor folders and honoring the root `.gitignore`.
|
|
294
|
+
|
|
295
|
+
3. **Extract Changed Content**: For each file change, the tool captures:
|
|
270
296
|
- The file path
|
|
271
297
|
- Lines added (new content)
|
|
272
298
|
- Lines removed (old content)
|
|
273
299
|
|
|
274
|
-
|
|
300
|
+
4. **Verify Against Current Codebase**: Before counting any line as an AI contribution, the tool:
|
|
275
301
|
- Reads the current content of the target file from the repository
|
|
276
302
|
- For each line that AI claims to have added, checks if an **identical line** exists in the current file
|
|
277
303
|
- Only lines that match exactly (character-for-character) are counted
|
|
278
304
|
|
|
279
|
-
|
|
305
|
+
5. **Calculate Statistics**: The verified lines are then aggregated into:
|
|
280
306
|
- Per-file contribution counts
|
|
281
307
|
- Per-tool contribution totals
|
|
282
308
|
- Overall repository contribution ratios
|
|
309
|
+
- **Sessions, files, and models are counted only when at least one verified line exists**
|
|
283
310
|
|
|
284
311
|
### Example
|
|
285
312
|
|
|
@@ -312,6 +339,7 @@ This methodology ensures that:
|
|
|
312
339
|
- Cannot detect AI-generated code that was copy-pasted manually
|
|
313
340
|
- Accuracy depends on the completeness of AI tool session logs
|
|
314
341
|
- Some AI tools may not record all file operations
|
|
342
|
+
- Files ignored by the root `.gitignore` are excluded from Total Files/Lines
|
|
315
343
|
|
|
316
344
|
## Contributing
|
|
317
345
|
|
package/dist/analyzer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { glob } from 'glob';
|
|
4
|
+
import ignore from 'ignore';
|
|
4
5
|
import { ClaudeScanner, CodexScanner, GeminiScanner, OpencodeScanner, } from './scanners/index.js';
|
|
5
6
|
/**
|
|
6
7
|
* Main analyzer that coordinates all scanners and computes statistics
|
|
@@ -59,9 +60,19 @@ export class ContributionAnalyzer {
|
|
|
59
60
|
const repoFiles = this.getRepoFiles();
|
|
60
61
|
const repoFileIndex = this.buildRepoFileIndex(repoFiles);
|
|
61
62
|
const totalLines = this.sumRepoLines(repoFileIndex);
|
|
63
|
+
// Filter sessions to those with verified contributions
|
|
64
|
+
const verifiedSessions = sessions.filter(session => {
|
|
65
|
+
for (const change of session.changes) {
|
|
66
|
+
const fileInfo = repoFileIndex.get(change.filePath);
|
|
67
|
+
if (this.countVerifiedAddedLines(change, fileInfo) > 0) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
});
|
|
62
73
|
// Compute statistics
|
|
63
|
-
const byTool = this.computeToolStats(
|
|
64
|
-
const byFile = this.computeFileStats(
|
|
74
|
+
const byTool = this.computeToolStats(verifiedSessions, repoFileIndex);
|
|
75
|
+
const byFile = this.computeFileStats(verifiedSessions, repoFileIndex);
|
|
65
76
|
// Count AI-touched files and lines (only count files that exist in repo)
|
|
66
77
|
let aiTouchedFiles = 0;
|
|
67
78
|
let aiContributedLines = 0;
|
|
@@ -80,7 +91,7 @@ export class ContributionAnalyzer {
|
|
|
80
91
|
totalLines,
|
|
81
92
|
aiTouchedFiles,
|
|
82
93
|
aiContributedLines,
|
|
83
|
-
sessions,
|
|
94
|
+
sessions: verifiedSessions,
|
|
84
95
|
byTool,
|
|
85
96
|
byFile,
|
|
86
97
|
};
|
|
@@ -106,13 +117,13 @@ export class ContributionAnalyzer {
|
|
|
106
117
|
'**/yarn.lock',
|
|
107
118
|
];
|
|
108
119
|
try {
|
|
109
|
-
|
|
120
|
+
let files = glob.sync('**/*', {
|
|
110
121
|
cwd: this.projectPath,
|
|
111
122
|
nodir: true,
|
|
112
123
|
ignore: ignorePatterns,
|
|
113
124
|
});
|
|
114
125
|
// Filter to only include text files
|
|
115
|
-
|
|
126
|
+
files = files.filter(file => {
|
|
116
127
|
const ext = path.extname(file).toLowerCase();
|
|
117
128
|
const textExtensions = [
|
|
118
129
|
'.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs',
|
|
@@ -132,6 +143,23 @@ export class ContributionAnalyzer {
|
|
|
132
143
|
];
|
|
133
144
|
return textExtensions.includes(ext) || !ext;
|
|
134
145
|
});
|
|
146
|
+
// Normalize to forward slashes so keys match scanner output on all platforms
|
|
147
|
+
files = files.map(file => file.replace(/\\/g, '/'));
|
|
148
|
+
const gitignorePath = path.join(this.projectPath, '.gitignore');
|
|
149
|
+
if (fs.existsSync(gitignorePath)) {
|
|
150
|
+
try {
|
|
151
|
+
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
|
|
152
|
+
const ignoreFactory = ignore.default
|
|
153
|
+
?? ignore;
|
|
154
|
+
const ig = ignoreFactory();
|
|
155
|
+
ig.add(gitignoreContent.split(/\r?\n/));
|
|
156
|
+
files = files.filter(file => !ig.ignores(file));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Ignore gitignore parsing errors
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return files;
|
|
135
163
|
}
|
|
136
164
|
catch {
|
|
137
165
|
return [];
|
|
@@ -219,6 +247,18 @@ export class ContributionAnalyzer {
|
|
|
219
247
|
// Track unique files per model across all sessions
|
|
220
248
|
const filesByModel = new Map();
|
|
221
249
|
for (const session of sessions) {
|
|
250
|
+
const sessionContribs = [];
|
|
251
|
+
for (const change of session.changes) {
|
|
252
|
+
const fileInfo = repoFileIndex.get(change.filePath);
|
|
253
|
+
const verifiedAdded = this.countVerifiedAddedLines(change, fileInfo);
|
|
254
|
+
if (verifiedAdded <= 0)
|
|
255
|
+
continue;
|
|
256
|
+
const modelName = change.model || session.model || 'unknown';
|
|
257
|
+
sessionContribs.push({ change, verifiedAdded, modelName });
|
|
258
|
+
}
|
|
259
|
+
if (sessionContribs.length === 0) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
222
262
|
let toolStats = stats.get(session.tool);
|
|
223
263
|
if (!toolStats) {
|
|
224
264
|
toolStats = {
|
|
@@ -237,10 +277,9 @@ export class ContributionAnalyzer {
|
|
|
237
277
|
}
|
|
238
278
|
toolStats.sessionsCount++;
|
|
239
279
|
const toolFiles = filesByTool.get(session.tool);
|
|
240
|
-
|
|
280
|
+
const modelsInSession = new Set();
|
|
281
|
+
for (const { change, verifiedAdded, modelName } of sessionContribs) {
|
|
241
282
|
toolFiles.add(change.filePath);
|
|
242
|
-
const fileInfo = repoFileIndex.get(change.filePath);
|
|
243
|
-
const verifiedAdded = this.countVerifiedAddedLines(change, fileInfo);
|
|
244
283
|
toolStats.linesAdded += verifiedAdded;
|
|
245
284
|
toolStats.linesRemoved += change.linesRemoved;
|
|
246
285
|
if (change.changeType === 'create') {
|
|
@@ -249,13 +288,13 @@ export class ContributionAnalyzer {
|
|
|
249
288
|
else {
|
|
250
289
|
toolStats.filesModified++;
|
|
251
290
|
}
|
|
291
|
+
modelsInSession.add(modelName);
|
|
252
292
|
// Aggregate by model
|
|
253
|
-
const modelName = change.model || session.model || 'unknown';
|
|
254
293
|
let modelStats = toolStats.byModel.get(modelName);
|
|
255
294
|
if (!modelStats) {
|
|
256
295
|
modelStats = {
|
|
257
296
|
model: modelName,
|
|
258
|
-
sessionsCount: 0, // Will be counted below
|
|
297
|
+
sessionsCount: 0, // Will be counted below
|
|
259
298
|
filesCreated: 0,
|
|
260
299
|
filesModified: 0,
|
|
261
300
|
totalFiles: 0,
|
|
@@ -277,21 +316,8 @@ export class ContributionAnalyzer {
|
|
|
277
316
|
modelStats.filesModified++;
|
|
278
317
|
}
|
|
279
318
|
}
|
|
280
|
-
// Count sessions per model (if any change in session is attributed to model)
|
|
281
|
-
// This is a bit tricky if a session has mixed models, but usually it's one per session
|
|
282
|
-
const sessionModel = session.model || 'unknown';
|
|
283
|
-
// Use a set to track which models appeared in this session to avoid double counting if mixed
|
|
284
|
-
const modelsInSession = new Set();
|
|
285
|
-
if (session.model)
|
|
286
|
-
modelsInSession.add(session.model);
|
|
287
|
-
for (const change of session.changes) {
|
|
288
|
-
if (change.model)
|
|
289
|
-
modelsInSession.add(change.model);
|
|
290
|
-
}
|
|
291
|
-
if (modelsInSession.size === 0)
|
|
292
|
-
modelsInSession.add('unknown');
|
|
293
319
|
for (const modelName of modelsInSession) {
|
|
294
|
-
|
|
320
|
+
const modelStats = toolStats.byModel.get(modelName);
|
|
295
321
|
if (modelStats) {
|
|
296
322
|
modelStats.sessionsCount++;
|
|
297
323
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Command } from 'commander';
|
|
3
|
-
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
2
|
import chalk from 'chalk';
|
|
6
|
-
import
|
|
3
|
+
import { Command } from 'commander';
|
|
7
4
|
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
|
|
8
8
|
import { ContributionAnalyzer } from './analyzer.js';
|
|
9
9
|
import { ConsoleReporter, JsonReporter, MarkdownReporter } from './reporter.js';
|
|
10
10
|
import { AITool } from './types.js';
|
|
@@ -161,7 +161,7 @@ function parseTools(toolStr) {
|
|
|
161
161
|
}
|
|
162
162
|
const program = new Command();
|
|
163
163
|
program
|
|
164
|
-
.name('ai-
|
|
164
|
+
.name('ai-credit')
|
|
165
165
|
.description('CLI tool to track and analyze AI coding assistants\' contributions in your codebase')
|
|
166
166
|
.version('1.0.0');
|
|
167
167
|
// Main scan command
|
package/dist/reporter.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
1
|
import chalk from 'chalk';
|
|
3
2
|
import Table from 'cli-table3';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
4
|
import { AITool } from './types.js';
|
|
5
5
|
/**
|
|
6
6
|
* Tool display names
|
|
@@ -41,6 +41,7 @@ export class ConsoleReporter {
|
|
|
41
41
|
const title = 'AI Contribution Analysis';
|
|
42
42
|
const repoLine = `Repository: ${stats.repoPath}`;
|
|
43
43
|
const timeLine = `Scan time: ${stats.scanTime.toLocaleString()}`;
|
|
44
|
+
console.log();
|
|
44
45
|
console.log(chalk.dim('Leave a ๐ star if you like it: https://github.com/debugtheworldbot/ai-credit'));
|
|
45
46
|
console.log();
|
|
46
47
|
console.log(chalk.cyan('โญ' + 'โ'.repeat(boxWidth) + 'โฎ'));
|
|
@@ -75,6 +76,7 @@ export class ConsoleReporter {
|
|
|
75
76
|
printToolBreakdown(stats) {
|
|
76
77
|
if (stats.byTool.size === 0) {
|
|
77
78
|
console.log(chalk.yellow('No AI contributions found.'));
|
|
79
|
+
console.log();
|
|
78
80
|
return;
|
|
79
81
|
}
|
|
80
82
|
console.log(chalk.bold('๐ค Contribution by AI Tool'));
|
package/dist/scanners/base.d.ts
CHANGED
|
@@ -49,6 +49,10 @@ export declare abstract class BaseScanner {
|
|
|
49
49
|
* Extract added lines from a unified diff
|
|
50
50
|
*/
|
|
51
51
|
protected extractAddedLinesFromDiff(diff: string | undefined): string[];
|
|
52
|
+
/**
|
|
53
|
+
* Normalize separators to forward slashes (consistent with glob output)
|
|
54
|
+
*/
|
|
55
|
+
protected toForwardSlash(p: string): string;
|
|
52
56
|
/**
|
|
53
57
|
* Normalize file path relative to project
|
|
54
58
|
*/
|
package/dist/scanners/base.js
CHANGED
|
@@ -20,7 +20,7 @@ export class BaseScanner {
|
|
|
20
20
|
* Resolve the full storage path
|
|
21
21
|
*/
|
|
22
22
|
resolveStoragePath() {
|
|
23
|
-
return this.storagePath.replace('~', this.homeDir);
|
|
23
|
+
return path.join(this.storagePath.replace('~', this.homeDir));
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Count lines in a string
|
|
@@ -107,14 +107,20 @@ export class BaseScanner {
|
|
|
107
107
|
}
|
|
108
108
|
return added;
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Normalize separators to forward slashes (consistent with glob output)
|
|
112
|
+
*/
|
|
113
|
+
toForwardSlash(p) {
|
|
114
|
+
return p.replace(/\\/g, '/');
|
|
115
|
+
}
|
|
110
116
|
/**
|
|
111
117
|
* Normalize file path relative to project
|
|
112
118
|
*/
|
|
113
119
|
normalizePath(filePath, projectPath) {
|
|
114
120
|
if (path.isAbsolute(filePath)) {
|
|
115
|
-
return path.relative(projectPath, filePath);
|
|
121
|
+
return this.toForwardSlash(path.relative(projectPath, filePath));
|
|
116
122
|
}
|
|
117
|
-
return filePath;
|
|
123
|
+
return this.toForwardSlash(filePath);
|
|
118
124
|
}
|
|
119
125
|
/**
|
|
120
126
|
* Check if a file path belongs to the project
|
|
@@ -14,13 +14,17 @@ export declare class ClaudeScanner extends BaseScanner {
|
|
|
14
14
|
get storagePath(): string;
|
|
15
15
|
/**
|
|
16
16
|
* Encode project path to match Claude's directory naming convention
|
|
17
|
-
* Claude encodes paths by replacing / with -
|
|
17
|
+
* Claude encodes paths by replacing / (and \ on Windows) with -
|
|
18
18
|
*/
|
|
19
19
|
private encodeProjectPath;
|
|
20
20
|
/**
|
|
21
21
|
* Decode Claude's directory name back to a path
|
|
22
22
|
*/
|
|
23
23
|
private decodeProjectPath;
|
|
24
|
+
/**
|
|
25
|
+
* Normalize a path for comparison (lowercase drive letter, forward slashes)
|
|
26
|
+
*/
|
|
27
|
+
private normForCompare;
|
|
24
28
|
scan(projectPath: string): AISession[];
|
|
25
29
|
parseSessionFile(filePath: string, projectPath: string): AISession | null;
|
|
26
30
|
/**
|
package/dist/scanners/claude.js
CHANGED
|
@@ -21,10 +21,10 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Encode project path to match Claude's directory naming convention
|
|
24
|
-
* Claude encodes paths by replacing / with -
|
|
24
|
+
* Claude encodes paths by replacing / (and \ on Windows) with -
|
|
25
25
|
*/
|
|
26
26
|
encodeProjectPath(projectPath) {
|
|
27
|
-
return projectPath.replace(
|
|
27
|
+
return projectPath.replace(/[\\/]/g, '-').replace(/^-/, '');
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Decode Claude's directory name back to a path
|
|
@@ -32,6 +32,17 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
32
32
|
decodeProjectPath(encodedPath) {
|
|
33
33
|
return '/' + encodedPath.replace(/-/g, '/');
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a path for comparison (lowercase drive letter, forward slashes)
|
|
37
|
+
*/
|
|
38
|
+
normForCompare(p) {
|
|
39
|
+
let s = this.toForwardSlash(p);
|
|
40
|
+
// Normalize Windows drive letter to lowercase: C:/... -> c:/...
|
|
41
|
+
if (/^[A-Z]:\//.test(s)) {
|
|
42
|
+
s = s[0].toLowerCase() + s.slice(1);
|
|
43
|
+
}
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
35
46
|
scan(projectPath) {
|
|
36
47
|
const sessions = [];
|
|
37
48
|
const basePath = this.resolveStoragePath();
|
|
@@ -57,6 +68,8 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
57
68
|
continue;
|
|
58
69
|
// Check various matching criteria
|
|
59
70
|
const decodedPath = this.decodeProjectPath(dir);
|
|
71
|
+
const normProject = this.normForCompare(projectPath);
|
|
72
|
+
const normDecoded = this.normForCompare(decodedPath);
|
|
60
73
|
// Match by:
|
|
61
74
|
// 1. Directory name contains project basename
|
|
62
75
|
// 2. Decoded path ends with project path
|
|
@@ -64,8 +77,8 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
64
77
|
// 4. Same basename
|
|
65
78
|
if (dir.includes(projectBasename) ||
|
|
66
79
|
dir.toLowerCase().includes(projectBasename.toLowerCase()) ||
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
normDecoded.endsWith(normProject) ||
|
|
81
|
+
normProject.endsWith(normDecoded.slice(1)) ||
|
|
69
82
|
path.basename(decodedPath) === projectBasename) {
|
|
70
83
|
possibleDirs.add(fullDir);
|
|
71
84
|
}
|
package/dist/scanners/codex.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export declare class CodexScanner extends BaseScanner {
|
|
|
24
24
|
* The session cwd must be exactly the project path or a subdirectory of it.
|
|
25
25
|
*/
|
|
26
26
|
private pathsMatch;
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a path for comparison (trim trailing slash, normalize Windows case).
|
|
29
|
+
*/
|
|
30
|
+
private normForCompare;
|
|
27
31
|
/**
|
|
28
32
|
* Parse Codex apply_patch custom_tool_call entries.
|
|
29
33
|
*
|
|
@@ -39,6 +43,14 @@ export declare class CodexScanner extends BaseScanner {
|
|
|
39
43
|
* *** End Patch
|
|
40
44
|
*/
|
|
41
45
|
private parseApplyPatch;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the patch text from a Codex custom_tool_call payload.
|
|
48
|
+
*/
|
|
49
|
+
private resolvePatchInput;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a file path against session cwd, then normalize to project-relative.
|
|
52
|
+
*/
|
|
53
|
+
private resolveFilePath;
|
|
42
54
|
/**
|
|
43
55
|
* Parse a function_call payload (e.g. shell_command with file write operations)
|
|
44
56
|
*/
|
package/dist/scanners/codex.js
CHANGED
|
@@ -84,7 +84,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
84
84
|
}
|
|
85
85
|
// Handle custom_tool_call (apply_patch) โ the primary way Codex writes files
|
|
86
86
|
if (entry.type === 'response_item' && payload.type === 'custom_tool_call') {
|
|
87
|
-
const patchChanges = this.parseApplyPatch(payload, projectPath, entry.timestamp);
|
|
87
|
+
const patchChanges = this.parseApplyPatch(payload, projectPath, sessionProjectPath, entry.timestamp);
|
|
88
88
|
changes.push(...patchChanges);
|
|
89
89
|
continue;
|
|
90
90
|
}
|
|
@@ -100,7 +100,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
100
100
|
catch {
|
|
101
101
|
continue;
|
|
102
102
|
}
|
|
103
|
-
const change = this.parseFunctionCall(funcName, args, projectPath, entry.timestamp);
|
|
103
|
+
const change = this.parseFunctionCall(funcName, args, projectPath, sessionProjectPath, entry.timestamp);
|
|
104
104
|
if (change) {
|
|
105
105
|
changes.push(change);
|
|
106
106
|
}
|
|
@@ -110,7 +110,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
110
110
|
const toolCalls = entry.tool_calls || entry.function_calls || [];
|
|
111
111
|
if (Array.isArray(toolCalls)) {
|
|
112
112
|
for (const toolCall of toolCalls) {
|
|
113
|
-
const change = this.parseToolCall(toolCall, projectPath, entry.timestamp);
|
|
113
|
+
const change = this.parseToolCall(toolCall, projectPath, sessionProjectPath, entry.timestamp);
|
|
114
114
|
if (change) {
|
|
115
115
|
changes.push(change);
|
|
116
116
|
}
|
|
@@ -118,7 +118,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
118
118
|
}
|
|
119
119
|
// Legacy: direct function format
|
|
120
120
|
if (entry.function && entry.function.name) {
|
|
121
|
-
const change = this.parseToolCall({ function: entry.function }, projectPath, entry.timestamp);
|
|
121
|
+
const change = this.parseToolCall({ function: entry.function }, projectPath, sessionProjectPath, entry.timestamp);
|
|
122
122
|
if (change) {
|
|
123
123
|
changes.push(change);
|
|
124
124
|
}
|
|
@@ -129,7 +129,9 @@ export class CodexScanner extends BaseScanner {
|
|
|
129
129
|
const normalizedSessionPath = path.resolve(sessionProjectPath);
|
|
130
130
|
const normalizedProjectPath = path.resolve(projectPath);
|
|
131
131
|
if (!this.pathsMatch(normalizedSessionPath, normalizedProjectPath)) {
|
|
132
|
-
|
|
132
|
+
if (changes.length === 0) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
137
|
if (changes.length === 0)
|
|
@@ -151,13 +153,26 @@ export class CodexScanner extends BaseScanner {
|
|
|
151
153
|
* The session cwd must be exactly the project path or a subdirectory of it.
|
|
152
154
|
*/
|
|
153
155
|
pathsMatch(sessionPath, projectPath) {
|
|
154
|
-
|
|
156
|
+
const s = this.normForCompare(sessionPath);
|
|
157
|
+
const p = this.normForCompare(projectPath);
|
|
158
|
+
if (s === p)
|
|
155
159
|
return true;
|
|
156
160
|
// Session opened inside a subdirectory of the project
|
|
157
|
-
if (
|
|
161
|
+
if (s.startsWith(p + '/'))
|
|
158
162
|
return true;
|
|
159
163
|
return false;
|
|
160
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Normalize a path for comparison (trim trailing slash, normalize Windows case).
|
|
167
|
+
*/
|
|
168
|
+
normForCompare(p) {
|
|
169
|
+
let s = this.toForwardSlash(p).replace(/\/+$/, '');
|
|
170
|
+
const isWindowsPath = /^[A-Za-z]:\//.test(s) || s.startsWith('//');
|
|
171
|
+
if (isWindowsPath) {
|
|
172
|
+
s = s.toLowerCase();
|
|
173
|
+
}
|
|
174
|
+
return s;
|
|
175
|
+
}
|
|
161
176
|
/**
|
|
162
177
|
* Parse Codex apply_patch custom_tool_call entries.
|
|
163
178
|
*
|
|
@@ -172,11 +187,11 @@ export class CodexScanner extends BaseScanner {
|
|
|
172
187
|
* +new file content
|
|
173
188
|
* *** End Patch
|
|
174
189
|
*/
|
|
175
|
-
parseApplyPatch(payload, projectPath, timestamp) {
|
|
190
|
+
parseApplyPatch(payload, projectPath, sessionCwd, timestamp) {
|
|
176
191
|
const name = (payload.name || '').toLowerCase();
|
|
177
192
|
if (name !== 'apply_patch')
|
|
178
193
|
return [];
|
|
179
|
-
const input = payload
|
|
194
|
+
const input = this.resolvePatchInput(payload);
|
|
180
195
|
if (!input)
|
|
181
196
|
return [];
|
|
182
197
|
const changes = [];
|
|
@@ -188,17 +203,19 @@ export class CodexScanner extends BaseScanner {
|
|
|
188
203
|
let addedLines = [];
|
|
189
204
|
const flushFile = () => {
|
|
190
205
|
if (currentFile && (linesAdded > 0 || linesRemoved > 0)) {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
206
|
+
const resolvedPath = this.resolveFilePath(currentFile, projectPath, sessionCwd);
|
|
207
|
+
if (resolvedPath) {
|
|
208
|
+
changes.push({
|
|
209
|
+
filePath: resolvedPath,
|
|
210
|
+
linesAdded,
|
|
211
|
+
linesRemoved,
|
|
212
|
+
changeType,
|
|
213
|
+
timestamp: timestamp ? new Date(timestamp) : new Date(),
|
|
214
|
+
tool: this.tool,
|
|
215
|
+
content: addedLines.join('\n'),
|
|
216
|
+
addedLines,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
202
219
|
}
|
|
203
220
|
currentFile = null;
|
|
204
221
|
linesAdded = 0;
|
|
@@ -244,10 +261,46 @@ export class CodexScanner extends BaseScanner {
|
|
|
244
261
|
flushFile();
|
|
245
262
|
return changes;
|
|
246
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Resolve the patch text from a Codex custom_tool_call payload.
|
|
266
|
+
*/
|
|
267
|
+
resolvePatchInput(payload) {
|
|
268
|
+
const rawInput = payload?.input;
|
|
269
|
+
if (!rawInput)
|
|
270
|
+
return null;
|
|
271
|
+
if (typeof rawInput === 'string')
|
|
272
|
+
return rawInput;
|
|
273
|
+
if (Array.isArray(rawInput)) {
|
|
274
|
+
const parts = rawInput.filter(part => typeof part === 'string');
|
|
275
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
276
|
+
}
|
|
277
|
+
if (typeof rawInput === 'object') {
|
|
278
|
+
const patch = rawInput.patch ||
|
|
279
|
+
rawInput.diff ||
|
|
280
|
+
rawInput.text ||
|
|
281
|
+
rawInput.content ||
|
|
282
|
+
rawInput.input;
|
|
283
|
+
return typeof patch === 'string' && patch ? patch : null;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Resolve a file path against session cwd, then normalize to project-relative.
|
|
289
|
+
*/
|
|
290
|
+
resolveFilePath(filePath, projectPath, sessionCwd) {
|
|
291
|
+
let resolvedPath = filePath;
|
|
292
|
+
if (!path.isAbsolute(resolvedPath) && sessionCwd) {
|
|
293
|
+
resolvedPath = path.resolve(sessionCwd, resolvedPath);
|
|
294
|
+
}
|
|
295
|
+
if (!this.isProjectFile(resolvedPath, projectPath)) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return this.normalizePath(resolvedPath, projectPath);
|
|
299
|
+
}
|
|
247
300
|
/**
|
|
248
301
|
* Parse a function_call payload (e.g. shell_command with file write operations)
|
|
249
302
|
*/
|
|
250
|
-
parseFunctionCall(funcName, args, projectPath, timestamp) {
|
|
303
|
+
parseFunctionCall(funcName, args, projectPath, sessionCwd, timestamp) {
|
|
251
304
|
const writeOps = ['write_file', 'create_file', 'write', 'save_file', 'create', 'writefile'];
|
|
252
305
|
const editOps = ['edit_file', 'apply_diff', 'patch', 'replace_in_file', 'edit', 'update_file', 'modify_file'];
|
|
253
306
|
let filePath = args.path || args.file_path || args.filename || args.file || args.target || '';
|
|
@@ -255,7 +308,9 @@ export class CodexScanner extends BaseScanner {
|
|
|
255
308
|
let oldContent = args.old_content || args.original || args.old_text || '';
|
|
256
309
|
if (!filePath)
|
|
257
310
|
return null;
|
|
258
|
-
|
|
311
|
+
const resolvedPath = this.resolveFilePath(filePath, projectPath, sessionCwd);
|
|
312
|
+
if (!resolvedPath)
|
|
313
|
+
return null;
|
|
259
314
|
let changeType = 'modify';
|
|
260
315
|
let linesAdded = 0;
|
|
261
316
|
let linesRemoved = 0;
|
|
@@ -288,7 +343,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
288
343
|
if (linesAdded === 0 && linesRemoved === 0)
|
|
289
344
|
return null;
|
|
290
345
|
return {
|
|
291
|
-
filePath,
|
|
346
|
+
filePath: resolvedPath,
|
|
292
347
|
linesAdded,
|
|
293
348
|
linesRemoved,
|
|
294
349
|
changeType,
|
|
@@ -301,7 +356,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
301
356
|
/**
|
|
302
357
|
* Parse a legacy tool_call object to extract file changes
|
|
303
358
|
*/
|
|
304
|
-
parseToolCall(toolCall, projectPath, timestamp) {
|
|
359
|
+
parseToolCall(toolCall, projectPath, sessionCwd, timestamp) {
|
|
305
360
|
const func = toolCall.function;
|
|
306
361
|
if (!func)
|
|
307
362
|
return null;
|
|
@@ -318,7 +373,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
318
373
|
catch {
|
|
319
374
|
return null;
|
|
320
375
|
}
|
|
321
|
-
return this.parseFunctionCall(funcName, args, projectPath, timestamp);
|
|
376
|
+
return this.parseFunctionCall(funcName, args, projectPath, sessionCwd, timestamp);
|
|
322
377
|
}
|
|
323
378
|
/**
|
|
324
379
|
* Parse a unified diff to count added/removed lines
|
|
@@ -15,6 +15,7 @@ export declare class GeminiScanner extends BaseScanner {
|
|
|
15
15
|
get storagePath(): string;
|
|
16
16
|
/**
|
|
17
17
|
* Hash project path to match Gemini's directory naming
|
|
18
|
+
* Normalize to forward slashes so the hash is consistent across platforms
|
|
18
19
|
*/
|
|
19
20
|
private hashProjectPath;
|
|
20
21
|
scan(projectPath: string): AISession[];
|
|
@@ -23,6 +24,10 @@ export declare class GeminiScanner extends BaseScanner {
|
|
|
23
24
|
* Check if two paths match or are related
|
|
24
25
|
*/
|
|
25
26
|
private pathsMatch;
|
|
27
|
+
/**
|
|
28
|
+
* Find explicit project path from known fields in the session data
|
|
29
|
+
*/
|
|
30
|
+
private findProjectPath;
|
|
26
31
|
/**
|
|
27
32
|
* Parse a functionCall object to extract file changes
|
|
28
33
|
*/
|
package/dist/scanners/gemini.js
CHANGED
|
@@ -23,9 +23,11 @@ export class GeminiScanner extends BaseScanner {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Hash project path to match Gemini's directory naming
|
|
26
|
+
* Normalize to forward slashes so the hash is consistent across platforms
|
|
26
27
|
*/
|
|
27
28
|
hashProjectPath(projectPath) {
|
|
28
|
-
|
|
29
|
+
const normalized = this.toForwardSlash(projectPath);
|
|
30
|
+
return crypto.createHash('md5').update(normalized).digest('hex').substring(0, 16);
|
|
29
31
|
}
|
|
30
32
|
scan(projectPath) {
|
|
31
33
|
const sessions = [];
|
|
@@ -100,8 +102,8 @@ export class GeminiScanner extends BaseScanner {
|
|
|
100
102
|
sessionTimestamp = new Date(data.startTime);
|
|
101
103
|
}
|
|
102
104
|
// Try to find project path from various fields
|
|
103
|
-
sessionProjectPath =
|
|
104
|
-
//
|
|
105
|
+
sessionProjectPath = this.findProjectPath(data);
|
|
106
|
+
// Require explicit project path match when available
|
|
105
107
|
if (sessionProjectPath) {
|
|
106
108
|
const normalizedSessionPath = path.resolve(sessionProjectPath);
|
|
107
109
|
const normalizedProjectPath = path.resolve(projectPath);
|
|
@@ -178,6 +180,15 @@ export class GeminiScanner extends BaseScanner {
|
|
|
178
180
|
}
|
|
179
181
|
if (changes.length === 0)
|
|
180
182
|
return null;
|
|
183
|
+
// If no explicit project path, only keep changes that belong to the target project
|
|
184
|
+
if (!sessionProjectPath) {
|
|
185
|
+
const filteredChanges = changes.filter(change => this.isProjectFile(change.filePath, projectPath));
|
|
186
|
+
if (filteredChanges.length === 0) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
changes.length = 0;
|
|
190
|
+
changes.push(...filteredChanges);
|
|
191
|
+
}
|
|
181
192
|
return {
|
|
182
193
|
id: this.generateSessionId(filePath),
|
|
183
194
|
tool: this.tool,
|
|
@@ -194,17 +205,58 @@ export class GeminiScanner extends BaseScanner {
|
|
|
194
205
|
* Check if two paths match or are related
|
|
195
206
|
*/
|
|
196
207
|
pathsMatch(path1, path2) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// One contains the other
|
|
201
|
-
if (path1.startsWith(path2) || path2.startsWith(path1))
|
|
208
|
+
const p1 = this.toForwardSlash(path1);
|
|
209
|
+
const p2 = this.toForwardSlash(path2);
|
|
210
|
+
if (p1 === p2)
|
|
202
211
|
return true;
|
|
203
|
-
|
|
204
|
-
if (path.basename(path1) === path.basename(path2))
|
|
212
|
+
if (p1.startsWith(p2 + '/'))
|
|
205
213
|
return true;
|
|
206
214
|
return false;
|
|
207
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Find explicit project path from known fields in the session data
|
|
218
|
+
*/
|
|
219
|
+
findProjectPath(data) {
|
|
220
|
+
if (!data || typeof data !== 'object')
|
|
221
|
+
return null;
|
|
222
|
+
const keys = new Set([
|
|
223
|
+
'projectPath',
|
|
224
|
+
'project_path',
|
|
225
|
+
'cwd',
|
|
226
|
+
'working_directory',
|
|
227
|
+
'workspace',
|
|
228
|
+
'workspacePath',
|
|
229
|
+
'rootPath',
|
|
230
|
+
'repoPath',
|
|
231
|
+
]);
|
|
232
|
+
const queue = [{ value: data, depth: 0 }];
|
|
233
|
+
const maxDepth = 6;
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const current = queue.shift();
|
|
236
|
+
if (!current)
|
|
237
|
+
break;
|
|
238
|
+
const { value, depth } = current;
|
|
239
|
+
if (!value || typeof value !== 'object')
|
|
240
|
+
continue;
|
|
241
|
+
if (depth > maxDepth)
|
|
242
|
+
continue;
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
for (const item of value) {
|
|
245
|
+
queue.push({ value: item, depth: depth + 1 });
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
for (const [key, val] of Object.entries(value)) {
|
|
250
|
+
if (keys.has(key) && typeof val === 'string' && path.isAbsolute(val)) {
|
|
251
|
+
return val;
|
|
252
|
+
}
|
|
253
|
+
if (val && typeof val === 'object') {
|
|
254
|
+
queue.push({ value: val, depth: depth + 1 });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
208
260
|
/**
|
|
209
261
|
* Parse a functionCall object to extract file changes
|
|
210
262
|
*/
|
|
@@ -136,11 +136,13 @@ export class OpencodeScanner extends BaseScanner {
|
|
|
136
136
|
* Check if two paths match (session belongs to project)
|
|
137
137
|
*/
|
|
138
138
|
pathsMatch(sessionPath, projectPath) {
|
|
139
|
-
|
|
139
|
+
const s = this.toForwardSlash(sessionPath);
|
|
140
|
+
const p = this.toForwardSlash(projectPath);
|
|
141
|
+
if (s === p)
|
|
140
142
|
return true;
|
|
141
|
-
if (
|
|
143
|
+
if (s.startsWith(p + '/'))
|
|
142
144
|
return true;
|
|
143
|
-
if (
|
|
145
|
+
if (p.startsWith(s + '/'))
|
|
144
146
|
return true;
|
|
145
147
|
if (path.basename(sessionPath) === path.basename(projectPath))
|
|
146
148
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-credit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "CLI tool to track and analyze AI coding assistants' contributions in your codebase",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"build": "tsc",
|
|
12
12
|
"start": "node dist/cli.js",
|
|
13
13
|
"dev": "tsc && node dist/cli.js",
|
|
14
|
-
"pub": "npm run build && npm publish",
|
|
14
|
+
"pub": "npm version patch && npm run build && npm publish --access public",
|
|
15
15
|
"prepublishOnly": "npm run build"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"chalk": "^5.3.0",
|
|
38
38
|
"cli-table3": "^0.6.5",
|
|
39
39
|
"commander": "^12.1.0",
|
|
40
|
-
"glob": "^10.4.5"
|
|
40
|
+
"glob": "^10.4.5",
|
|
41
|
+
"ignore": "^5.3.1"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@types/node": "^20.14.0",
|
package/dist/scanners/aider.d.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { AISession, AITool } from '../types.js';
|
|
2
|
-
import { BaseScanner } from './base.js';
|
|
3
|
-
/**
|
|
4
|
-
* Scanner for Aider sessions
|
|
5
|
-
*
|
|
6
|
-
* Aider stores chat history in:
|
|
7
|
-
* <project>/.aider.chat.history.md
|
|
8
|
-
* <project>/.aider.input.history
|
|
9
|
-
* <project>/.aider/
|
|
10
|
-
*
|
|
11
|
-
* The file contains markdown-formatted conversation with
|
|
12
|
-
* code blocks that show file changes.
|
|
13
|
-
*/
|
|
14
|
-
export declare class AiderScanner extends BaseScanner {
|
|
15
|
-
get tool(): AITool;
|
|
16
|
-
get storagePath(): string;
|
|
17
|
-
/**
|
|
18
|
-
* For Aider, storage is project-local
|
|
19
|
-
*/
|
|
20
|
-
protected resolveStoragePath(): string;
|
|
21
|
-
scan(projectPath: string): AISession[];
|
|
22
|
-
/**
|
|
23
|
-
* Check if Aider history exists in the project
|
|
24
|
-
*/
|
|
25
|
-
isAvailable(): boolean;
|
|
26
|
-
/**
|
|
27
|
-
* Check if Aider history exists for a specific project
|
|
28
|
-
*/
|
|
29
|
-
isAvailableForProject(projectPath: string): boolean;
|
|
30
|
-
parseSessionFile(filePath: string, projectPath: string): AISession | null;
|
|
31
|
-
/**
|
|
32
|
-
* Deduplicate changes, keeping the latest for each file
|
|
33
|
-
*/
|
|
34
|
-
private deduplicateChanges;
|
|
35
|
-
}
|
package/dist/scanners/aider.js
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import { glob } from 'glob';
|
|
4
|
-
import { AITool } from '../types.js';
|
|
5
|
-
import { BaseScanner } from './base.js';
|
|
6
|
-
/**
|
|
7
|
-
* Scanner for Aider sessions
|
|
8
|
-
*
|
|
9
|
-
* Aider stores chat history in:
|
|
10
|
-
* <project>/.aider.chat.history.md
|
|
11
|
-
* <project>/.aider.input.history
|
|
12
|
-
* <project>/.aider/
|
|
13
|
-
*
|
|
14
|
-
* The file contains markdown-formatted conversation with
|
|
15
|
-
* code blocks that show file changes.
|
|
16
|
-
*/
|
|
17
|
-
export class AiderScanner extends BaseScanner {
|
|
18
|
-
get tool() {
|
|
19
|
-
return AITool.AIDER;
|
|
20
|
-
}
|
|
21
|
-
get storagePath() {
|
|
22
|
-
return '.aider.chat.history.md';
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* For Aider, storage is project-local
|
|
26
|
-
*/
|
|
27
|
-
resolveStoragePath() {
|
|
28
|
-
return this.storagePath;
|
|
29
|
-
}
|
|
30
|
-
scan(projectPath) {
|
|
31
|
-
const sessions = [];
|
|
32
|
-
// Check for main history file
|
|
33
|
-
const historyFile = path.join(projectPath, '.aider.chat.history.md');
|
|
34
|
-
if (fs.existsSync(historyFile)) {
|
|
35
|
-
const session = this.parseSessionFile(historyFile, projectPath);
|
|
36
|
-
if (session && session.changes.length > 0) {
|
|
37
|
-
sessions.push(session);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
// Also check .aider directory for additional history
|
|
41
|
-
const aiderDir = path.join(projectPath, '.aider');
|
|
42
|
-
if (fs.existsSync(aiderDir)) {
|
|
43
|
-
try {
|
|
44
|
-
const files = glob.sync('**/*.md', { cwd: aiderDir });
|
|
45
|
-
for (const file of files) {
|
|
46
|
-
if (file.includes('history') || file.includes('chat')) {
|
|
47
|
-
const session = this.parseSessionFile(path.join(aiderDir, file), projectPath);
|
|
48
|
-
if (session && session.changes.length > 0) {
|
|
49
|
-
sessions.push(session);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// Ignore errors
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return sessions;
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Check if Aider history exists in the project
|
|
62
|
-
*/
|
|
63
|
-
isAvailable() {
|
|
64
|
-
// For Aider, we can't check globally - it's project-specific
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Check if Aider history exists for a specific project
|
|
69
|
-
*/
|
|
70
|
-
isAvailableForProject(projectPath) {
|
|
71
|
-
const historyFile = path.join(projectPath, this.storagePath);
|
|
72
|
-
const aiderDir = path.join(projectPath, '.aider');
|
|
73
|
-
return fs.existsSync(historyFile) || fs.existsSync(aiderDir);
|
|
74
|
-
}
|
|
75
|
-
parseSessionFile(filePath, projectPath) {
|
|
76
|
-
let content;
|
|
77
|
-
try {
|
|
78
|
-
content = fs.readFileSync(filePath, 'utf-8');
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
const changes = [];
|
|
84
|
-
const fileStats = fs.statSync(filePath);
|
|
85
|
-
const sessionTimestamp = fileStats.mtime;
|
|
86
|
-
// Parse the markdown content to find file changes
|
|
87
|
-
// Aider uses various patterns:
|
|
88
|
-
// Pattern 1: ```language path/to/file.py
|
|
89
|
-
const codeBlockRegex = /```(\w+)?\s+([^\n`]+)\n([\s\S]*?)```/g;
|
|
90
|
-
let match;
|
|
91
|
-
while ((match = codeBlockRegex.exec(content)) !== null) {
|
|
92
|
-
const [, language, filePathRaw, code] = match;
|
|
93
|
-
// Skip if it looks like a diff or command output
|
|
94
|
-
if (filePathRaw.startsWith('>') || filePathRaw.startsWith('$') || filePathRaw.startsWith('#')) {
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
// Clean up the file path
|
|
98
|
-
const cleanPath = filePathRaw.trim();
|
|
99
|
-
// Skip if path contains spaces (likely not a real path) or is too long
|
|
100
|
-
if (!cleanPath || cleanPath.includes(' ') || cleanPath.length > 200) {
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
// Skip common non-file patterns
|
|
104
|
-
if (cleanPath.match(/^(bash|shell|console|output|diff|patch|error|warning|note|example)/i)) {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
const linesAdded = this.countLines(code);
|
|
108
|
-
if (linesAdded > 0) {
|
|
109
|
-
changes.push({
|
|
110
|
-
filePath: this.normalizePath(cleanPath, projectPath),
|
|
111
|
-
linesAdded,
|
|
112
|
-
linesRemoved: 0,
|
|
113
|
-
changeType: 'modify',
|
|
114
|
-
timestamp: sessionTimestamp,
|
|
115
|
-
tool: this.tool,
|
|
116
|
-
content: code,
|
|
117
|
-
addedLines: this.extractNonEmptyLines(code),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
// Pattern 2: SEARCH/REPLACE blocks (Aider's edit format)
|
|
122
|
-
// Look for file context before SEARCH/REPLACE
|
|
123
|
-
const fileEditRegex = /(?:^|\n)([^\n]+\.[a-zA-Z]+)\n```[^\n]*\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
|
|
124
|
-
while ((match = fileEditRegex.exec(content)) !== null) {
|
|
125
|
-
const [, filePathRaw, searchContent, replaceContent] = match;
|
|
126
|
-
const cleanPath = filePathRaw.trim();
|
|
127
|
-
if (cleanPath && !cleanPath.includes(' ')) {
|
|
128
|
-
const linesRemoved = this.countLines(searchContent);
|
|
129
|
-
const linesAdded = this.countLines(replaceContent);
|
|
130
|
-
if (linesAdded > 0 || linesRemoved > 0) {
|
|
131
|
-
changes.push({
|
|
132
|
-
filePath: this.normalizePath(cleanPath, projectPath),
|
|
133
|
-
linesAdded,
|
|
134
|
-
linesRemoved,
|
|
135
|
-
changeType: 'modify',
|
|
136
|
-
timestamp: sessionTimestamp,
|
|
137
|
-
tool: this.tool,
|
|
138
|
-
content: replaceContent,
|
|
139
|
-
addedLines: this.extractNonEmptyLines(replaceContent),
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
// Pattern 3: Standalone SEARCH/REPLACE without file context
|
|
145
|
-
const standaloneEditRegex = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
|
|
146
|
-
while ((match = standaloneEditRegex.exec(content)) !== null) {
|
|
147
|
-
const [fullMatch, searchContent, replaceContent] = match;
|
|
148
|
-
// Skip if already captured by fileEditRegex
|
|
149
|
-
if (content.indexOf(fullMatch) !== match.index)
|
|
150
|
-
continue;
|
|
151
|
-
const linesRemoved = this.countLines(searchContent);
|
|
152
|
-
const linesAdded = this.countLines(replaceContent);
|
|
153
|
-
if (linesAdded > 0 || linesRemoved > 0) {
|
|
154
|
-
changes.push({
|
|
155
|
-
filePath: 'unknown',
|
|
156
|
-
linesAdded,
|
|
157
|
-
linesRemoved,
|
|
158
|
-
changeType: 'modify',
|
|
159
|
-
timestamp: sessionTimestamp,
|
|
160
|
-
tool: this.tool,
|
|
161
|
-
content: replaceContent,
|
|
162
|
-
addedLines: this.extractNonEmptyLines(replaceContent),
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// Deduplicate changes by file path
|
|
167
|
-
const uniqueChanges = this.deduplicateChanges(changes);
|
|
168
|
-
if (uniqueChanges.length === 0)
|
|
169
|
-
return null;
|
|
170
|
-
return {
|
|
171
|
-
id: this.generateSessionId(filePath),
|
|
172
|
-
tool: this.tool,
|
|
173
|
-
timestamp: sessionTimestamp,
|
|
174
|
-
projectPath,
|
|
175
|
-
changes: uniqueChanges,
|
|
176
|
-
totalFilesChanged: new Set(uniqueChanges.map(c => c.filePath)).size,
|
|
177
|
-
totalLinesAdded: uniqueChanges.reduce((sum, c) => sum + c.linesAdded, 0),
|
|
178
|
-
totalLinesRemoved: uniqueChanges.reduce((sum, c) => sum + c.linesRemoved, 0),
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Deduplicate changes, keeping the latest for each file
|
|
183
|
-
*/
|
|
184
|
-
deduplicateChanges(changes) {
|
|
185
|
-
const byFile = new Map();
|
|
186
|
-
for (const change of changes) {
|
|
187
|
-
const existing = byFile.get(change.filePath);
|
|
188
|
-
if (!existing) {
|
|
189
|
-
byFile.set(change.filePath, { ...change });
|
|
190
|
-
}
|
|
191
|
-
else {
|
|
192
|
-
// Accumulate lines
|
|
193
|
-
existing.linesAdded += change.linesAdded;
|
|
194
|
-
existing.linesRemoved += change.linesRemoved;
|
|
195
|
-
if (change.addedLines && change.addedLines.length > 0) {
|
|
196
|
-
if (!existing.addedLines) {
|
|
197
|
-
existing.addedLines = [];
|
|
198
|
-
}
|
|
199
|
-
existing.addedLines.push(...change.addedLines);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return Array.from(byFile.values()).filter(c => c.filePath !== 'unknown');
|
|
204
|
-
}
|
|
205
|
-
}
|