ai-credit 1.0.3 โ†’ 1.0.5

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 CHANGED
@@ -3,7 +3,9 @@
3
3
  [![npm version](https://img.shields.io/npm/v/ai-credit.svg)](https://www.npmjs.com/package/ai-credit)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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/Windows). Supports **Claude Code**, **Codex CLI**, **Gemini CLI**, and **Opencode**.
7
+
8
+ <img width="700" height="700" alt="image" src="https://github.com/user-attachments/assets/48545b91-8d20-4946-bc1c-f55762c01539" />
7
9
 
8
10
  ## Quick Start
9
11
 
@@ -18,7 +20,7 @@ ai-credit
18
20
 
19
21
  ## Features
20
22
 
21
- - ๐Ÿ” **Auto-detection**: Automatically finds AI tool session data on your system
23
+ - ๐Ÿ” **Auto-detection**: Automatically finds AI tool session data on your system (macOS/Linux/Windows)
22
24
  - ๐Ÿ“Š **Detailed Statistics**: Lines of code, files modified, contribution ratios
23
25
  - ๐Ÿค– **Multi-tool Support**: Claude Code, Codex CLI, Gemini CLI, Opencode
24
26
  - ๐Ÿ“ˆ **Visual Reports**: Console, JSON, and Markdown output formats
@@ -72,6 +74,7 @@ Shows which AI tools have data available on your system:
72
74
  Claude Code ~/.claude/projects/ โœ“ Available
73
75
  Codex CLI ~/.codex/sessions/ โœ“ Available
74
76
  Gemini CLI ~/.gemini/tmp/ โœ— Not found
77
+ Opencode ~/.local/share/opencode/ โœ“ Available
75
78
  ```
76
79
 
77
80
  ### File-level Analysis
@@ -101,35 +104,57 @@ Lists all AI sessions for the repository.
101
104
  ## Output Example
102
105
 
103
106
  ```
104
- โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
105
- โ”‚ AI Contribution Analysis โ”‚
106
- โ”‚ Repository: /path/to/your/repo โ”‚
107
- โ”‚ Scan time: 2024-01-15 14:30:00 โ”‚
108
- โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
109
-
110
- ๐Ÿ“Š Overview
111
- โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“
112
- โ”ƒ Metric โ”ƒ Value โ”ƒ AI Contribution โ”ƒ
113
- โ”กโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ฉ
114
- โ”‚ Total Files โ”‚ 150 โ”‚ 45 (30.0%) โ”‚
115
- โ”‚ Total Lines โ”‚ 12500 โ”‚ 3750 (30.0%) โ”‚
116
- โ”‚ AI Sessions โ”‚ 28 โ”‚ - โ”‚
117
- โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
118
-
119
- ๐Ÿค– Contribution by AI Tool
120
- โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“
121
- โ”ƒ Tool โ”ƒ Sessions โ”ƒ Files โ”ƒ Lines Added โ”ƒ
122
- โ”กโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ฉ
123
- โ”‚ Claude Code โ”‚ 15 โ”‚ 30 โ”‚ +2500 โ”‚
124
- โ”‚ Codex CLI โ”‚ 10 โ”‚ 20 โ”‚ +1000 โ”‚
125
- โ”‚ Opencode โ”‚ 3 โ”‚ 5 โ”‚ +250 โ”‚
126
- โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
107
+ ai-credit (main) npx ai-credit
108
+ Leave a ๐ŸŒŸ star if you like it: https://github.com/debugtheworldbot/ai-credit
109
+
110
+ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
111
+ โ”‚ AI Contribution Analysis โ”‚
112
+ โ”‚ Repository: /Users/eric/Developer/ai-credit โ”‚
113
+ โ”‚ Scan time: 2/2/2026, 4:22:53 PM โ”‚
114
+ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
115
+ ๐Ÿ“Š Overview
116
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
117
+ โ”‚ Metric โ”‚ Value โ”‚ AI Contribution โ”‚
118
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
119
+ โ”‚ Total Files โ”‚ 18 โ”‚ 15 (83.3%) โ”‚
120
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
121
+ โ”‚ Total Lines โ”‚ 3496 โ”‚ 1660 (47.5%) โ”‚
122
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
123
+ โ”‚ AI Sessions โ”‚ 6 โ”‚ - โ”‚
124
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
125
+
126
+ ๐Ÿค– Contribution by AI Tool
127
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
128
+ โ”‚ Tool / Model โ”‚ Sessions โ”‚ Files โ”‚ Lines Added โ”‚ Lines Removed โ”‚ Share โ”‚
129
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
130
+ โ”‚ Opencode โ”‚ 2 โ”‚ 12 โ”‚ +558 โ”‚ -128 โ”‚ 32.2% โ”‚
131
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
132
+ โ”‚ โ””โ”€ kimi-k2.5-free โ”‚ 2 โ”‚ 12 โ”‚ +558 โ”‚ -128 โ”‚ 100.0% (of tool) โ”‚
133
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
134
+ โ”‚ Codex CLI โ”‚ 1 โ”‚ 11 โ”‚ +482 โ”‚ -303 โ”‚ 27.8% โ”‚
135
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
136
+ โ”‚ โ””โ”€ gpt-5.2-codex โ”‚ 1 โ”‚ 11 โ”‚ +482 โ”‚ -303 โ”‚ 100.0% (of tool) โ”‚
137
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
138
+ โ”‚ Gemini CLI โ”‚ 2 โ”‚ 11 โ”‚ +357 โ”‚ -262 โ”‚ 20.6% โ”‚
139
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
140
+ โ”‚ โ””โ”€ gemini-2.5-pro โ”‚ 1 โ”‚ 8 โ”‚ +330 โ”‚ -237 โ”‚ 92.4% (of tool) โ”‚
141
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
142
+ โ”‚ โ””โ”€ gemini-3-pro-preview โ”‚ 1 โ”‚ 5 โ”‚ +27 โ”‚ -25 โ”‚ 7.6% (of tool) โ”‚
143
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
144
+ โ”‚ Claude Code โ”‚ 1 โ”‚ 5 โ”‚ +338 โ”‚ -452 โ”‚ 19.5% โ”‚
145
+ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
146
+ โ”‚ โ””โ”€ claude-opus-4-5-20251101 โ”‚ 1 โ”‚ 5 โ”‚ +338 โ”‚ -452 โ”‚ 100.0% (of tool) โ”‚
147
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
127
148
 
128
149
  ๐Ÿ“ˆ Contribution Distribution
129
150
 
130
- Claude Code โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 66.7%
131
- Codex CLI โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 26.7%
132
- Opencode โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 6.7%
151
+ ๐ŸŸง๐ŸŸง๐ŸŸง๐ŸŸง๐ŸŸง๐ŸŸง๐ŸŸฆ๐ŸŸฆ๐ŸŸฆ๐ŸŸฆ๐ŸŸฆ๐ŸŸช๐ŸŸช๐ŸŸช๐ŸŸช๐ŸŸช๐ŸŸช๐ŸŸฉ๐ŸŸฉ๐ŸŸฉโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœโฌœ
152
+
153
+ ๐ŸŸ  Opencode 15.3% (534 lines)
154
+ ๐Ÿ”ต Codex CLI 13.2% (461 lines)
155
+ ๐ŸŸฃ Gemini CLI 9.8% (342 lines)
156
+ ๐ŸŸข Claude Code 9.2% (323 lines)
157
+ โšช Unknown/Human 52.5% (1836 lines)
133
158
  ```
134
159
 
135
160
  ## Supported AI Tools
@@ -217,13 +242,14 @@ Here's a detailed breakdown of the parsing method for each supported tool:
217
242
  ### 3. Gemini CLI
218
243
 
219
244
  - **File Format**: JSON (`.json`), where one file represents a complete session.
220
- - **Scan Path**: `~/.gemini/tmp/<project_hash>/chats/*.json`
245
+ - **Scan Paths**: `~/.gemini/tmp/<hash>/chats/*.json`, `~/.gemini/history/*.json`, `~/.gemini/sessions/*.json`
221
246
  - **Parsing Logic**:
222
- 1. The tool first calculates the corresponding `<project_hash>` from the target project path to locate the specific `chats` directory.
247
+ 1. The scanner searches Geminiโ€™s session JSON files under common locations (`tmp`, `history`, `sessions`).
223
248
  2. It parses the entire JSON file, which typically contains a `"messages"` or `"turns"` array logging the conversation history.
224
249
  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"`. This object's structure is similar to that of Codex CLI.
250
+ 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
251
  5. It extracts the `"name"` (function name) and `"args"` (arguments dictionary) from the `functionCall` object.
252
+ 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
253
 
228
254
  **Example (Simplified Gemini CLI JSON Fragment):**
229
255
 
@@ -250,6 +276,46 @@ Here's a detailed breakdown of the parsing method for each supported tool:
250
276
  }
251
277
  ```
252
278
 
279
+ ### 4. Opencode
280
+
281
+ - **File Format**: JSON (`.json`) session and message files.
282
+ - **Scan Paths**:
283
+ - Sessions: `~/.local/share/opencode/storage/session/**/*.json`
284
+ - Messages: `~/.local/share/opencode/storage/message/<session-id>/*.json`
285
+ - **Parsing Logic**:
286
+ 1. The scanner reads all session JSON files (stored under project-hash subfolders).
287
+ 2. It filters sessions by project path using `directory` or `projectPath` in the session metadata.
288
+ 3. If message files exist for the session, it parses each message and looks for `summary.diffs`.
289
+ 4. If no message-level diffs are found, it falls back to `summary.diffs` in the session file.
290
+ 5. Each diff entry provides:
291
+ - `file`: relative file path
292
+ - `before`: previous content
293
+ - `after`: new content
294
+ - `additions` / `deletions`: optional precomputed line counts
295
+ 6. Lines added/removed are taken from `additions`/`deletions` when present, otherwise computed from `before`/`after`.
296
+ 7. The scanner also extracts the model from message data (e.g., `model.modelID`) when available.
297
+
298
+ **Example (Simplified Opencode Message JSON):**
299
+
300
+ ```json
301
+ {
302
+ "sessionID": "sess_abc123",
303
+ "time": { "created": "2026-02-02T10:15:00Z" },
304
+ "model": { "modelID": "kimi-k2.5-free" },
305
+ "summary": {
306
+ "diffs": [
307
+ {
308
+ "file": "src/index.ts",
309
+ "before": "console.log('old');\n",
310
+ "after": "console.log('new');\n",
311
+ "additions": 1,
312
+ "deletions": 1
313
+ }
314
+ ]
315
+ }
316
+ }
317
+ ```
318
+
253
319
  ### Summary
254
320
 
255
321
  In essence, `ai-credit` features a specialized scanner for each supported AI tool. Each scanner is programmed to know its corresponding tool's log storage location and data structure. During analysis, the main program invokes all available scanners, collects all `FileChange` events related to the target project, and then aggregates, deduplicates, and analyzes these events to generate the final contribution report.
@@ -266,20 +332,23 @@ The tool applies a strict verification rule when calculating AI contribution sta
266
332
 
267
333
  1. **Parse AI Session Logs**: The scanner reads session files from each AI tool and extracts file change events (writes, edits, patches).
268
334
 
269
- 2. **Extract Changed Content**: For each file change, the tool captures:
335
+ 2. **Build Repository File Set**: The tool gathers repository files using text-file extensions, excluding common build/vendor folders and honoring the root `.gitignore`.
336
+
337
+ 3. **Extract Changed Content**: For each file change, the tool captures:
270
338
  - The file path
271
339
  - Lines added (new content)
272
340
  - Lines removed (old content)
273
341
 
274
- 3. **Verify Against Current Codebase**: Before counting any line as an AI contribution, the tool:
342
+ 4. **Verify Against Current Codebase**: Before counting any line as an AI contribution, the tool:
275
343
  - Reads the current content of the target file from the repository
276
344
  - For each line that AI claims to have added, checks if an **identical line** exists in the current file
277
345
  - Only lines that match exactly (character-for-character) are counted
278
346
 
279
- 4. **Calculate Statistics**: The verified lines are then aggregated into:
347
+ 5. **Calculate Statistics**: The verified lines are then aggregated into:
280
348
  - Per-file contribution counts
281
349
  - Per-tool contribution totals
282
350
  - Overall repository contribution ratios
351
+ - **Sessions, files, and models are counted only when at least one verified line exists**
283
352
 
284
353
  ### Example
285
354
 
@@ -312,6 +381,8 @@ This methodology ensures that:
312
381
  - Cannot detect AI-generated code that was copy-pasted manually
313
382
  - Accuracy depends on the completeness of AI tool session logs
314
383
  - Some AI tools may not record all file operations
384
+ - Files ignored by the root `.gitignore` are excluded from Total Files/Lines
385
+ - Windows support for some tools depends on their session storage format compatibility
315
386
 
316
387
  ## Contributing
317
388
 
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(sessions, repoFileIndex);
64
- const byFile = this.computeFileStats(sessions, repoFileIndex);
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
- const files = glob.sync('**/*', {
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
- return files.filter(file => {
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
- for (const change of session.changes) {
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 (approximated by session presence)
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
- let modelStats = toolStats.byModel.get(modelName);
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 * as path from 'path';
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-contrib')
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
@@ -76,6 +76,7 @@ export class ConsoleReporter {
76
76
  printToolBreakdown(stats) {
77
77
  if (stats.byTool.size === 0) {
78
78
  console.log(chalk.yellow('No AI contributions found.'));
79
+ console.log();
79
80
  return;
80
81
  }
81
82
  console.log(chalk.bold('๐Ÿค– Contribution by AI Tool'));
@@ -45,10 +45,21 @@ export declare abstract class BaseScanner {
45
45
  * Compute added lines using LCS diff (non-empty lines only)
46
46
  */
47
47
  protected diffAddedLines(before: string | undefined, after: string | undefined): string[];
48
+ /**
49
+ * Compute added/removed line counts using LCS diff (non-empty lines only)
50
+ */
51
+ protected diffLineCounts(before: string | undefined, after: string | undefined): {
52
+ added: number;
53
+ removed: number;
54
+ };
48
55
  /**
49
56
  * Extract added lines from a unified diff
50
57
  */
51
58
  protected extractAddedLinesFromDiff(diff: string | undefined): string[];
59
+ /**
60
+ * Normalize separators to forward slashes (consistent with glob output)
61
+ */
62
+ protected toForwardSlash(p: string): string;
52
63
  /**
53
64
  * Normalize file path relative to project
54
65
  */
@@ -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
@@ -89,6 +89,40 @@ export class BaseScanner {
89
89
  }
90
90
  return added.reverse();
91
91
  }
92
+ /**
93
+ * Compute added/removed line counts using LCS diff (non-empty lines only)
94
+ */
95
+ diffLineCounts(before, after) {
96
+ const beforeLines = this.extractNonEmptyLines(before);
97
+ const afterLines = this.extractNonEmptyLines(after);
98
+ if (beforeLines.length === 0)
99
+ return { added: afterLines.length, removed: 0 };
100
+ if (afterLines.length === 0)
101
+ return { added: 0, removed: beforeLines.length };
102
+ const m = beforeLines.length;
103
+ const n = afterLines.length;
104
+ let prev = new Array(n + 1).fill(0);
105
+ let curr = new Array(n + 1).fill(0);
106
+ for (let i = 1; i <= m; i++) {
107
+ for (let j = 1; j <= n; j++) {
108
+ if (beforeLines[i - 1] === afterLines[j - 1]) {
109
+ curr[j] = prev[j - 1] + 1;
110
+ }
111
+ else {
112
+ curr[j] = Math.max(prev[j], curr[j - 1]);
113
+ }
114
+ }
115
+ const temp = prev;
116
+ prev = curr;
117
+ curr = temp;
118
+ curr.fill(0);
119
+ }
120
+ const lcs = prev[n];
121
+ return {
122
+ added: afterLines.length - lcs,
123
+ removed: beforeLines.length - lcs,
124
+ };
125
+ }
92
126
  /**
93
127
  * Extract added lines from a unified diff
94
128
  */
@@ -107,14 +141,20 @@ export class BaseScanner {
107
141
  }
108
142
  return added;
109
143
  }
144
+ /**
145
+ * Normalize separators to forward slashes (consistent with glob output)
146
+ */
147
+ toForwardSlash(p) {
148
+ return p.replace(/\\/g, '/');
149
+ }
110
150
  /**
111
151
  * Normalize file path relative to project
112
152
  */
113
153
  normalizePath(filePath, projectPath) {
114
154
  if (path.isAbsolute(filePath)) {
115
- return path.relative(projectPath, filePath);
155
+ return this.toForwardSlash(path.relative(projectPath, filePath));
116
156
  }
117
- return filePath;
157
+ return this.toForwardSlash(filePath);
118
158
  }
119
159
  /**
120
160
  * 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
  /**
@@ -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(/\//g, '-').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
- decodedPath.endsWith(projectPath) ||
68
- projectPath.endsWith(decodedPath.slice(1)) ||
80
+ normDecoded.endsWith(normProject) ||
81
+ normProject.endsWith(normDecoded.slice(1)) ||
69
82
  path.basename(decodedPath) === projectBasename) {
70
83
  possibleDirs.add(fullDir);
71
84
  }
@@ -174,18 +187,28 @@ export class ClaudeScanner extends BaseScanner {
174
187
  let addedLines = [];
175
188
  if (writeOps.includes(toolName)) {
176
189
  changeType = oldContent ? 'modify' : 'create';
177
- linesAdded = this.countLines(newContent);
178
- linesRemoved = this.countLines(oldContent);
179
- addedLines = this.extractNonEmptyLines(newContent);
190
+ const stats = this.diffLineCounts(oldContent, newContent);
191
+ linesAdded = stats.added;
192
+ linesRemoved = stats.removed;
193
+ if (oldContent && newContent) {
194
+ addedLines = this.diffAddedLines(oldContent, newContent);
195
+ }
196
+ else {
197
+ addedLines = this.extractNonEmptyLines(newContent);
198
+ }
180
199
  }
181
200
  else if (editOps.includes(toolName)) {
182
201
  changeType = 'modify';
183
- linesAdded = this.countLines(newContent);
184
- linesRemoved = this.countLines(oldContent);
185
202
  if (oldContent && newContent) {
203
+ const stats = this.diffLineCounts(oldContent, newContent);
204
+ linesAdded = stats.added;
205
+ linesRemoved = stats.removed;
186
206
  addedLines = this.diffAddedLines(oldContent, newContent);
187
207
  }
188
208
  else {
209
+ const stats = this.diffLineCounts(oldContent, newContent);
210
+ linesAdded = stats.added;
211
+ linesRemoved = stats.removed;
189
212
  addedLines = this.extractNonEmptyLines(newContent);
190
213
  }
191
214
  }
@@ -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
  */
@@ -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
- return null;
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
- if (sessionPath === projectPath)
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 (sessionPath.startsWith(projectPath + '/'))
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.input || '';
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 filePath = this.normalizePath(currentFile, projectPath);
192
- changes.push({
193
- filePath,
194
- linesAdded,
195
- linesRemoved,
196
- changeType,
197
- timestamp: timestamp ? new Date(timestamp) : new Date(),
198
- tool: this.tool,
199
- content: addedLines.join('\n'),
200
- addedLines,
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,20 +308,27 @@ 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
- filePath = this.normalizePath(filePath, projectPath);
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;
262
317
  let addedLines = [];
263
318
  if (writeOps.includes(funcName)) {
264
- changeType = 'create';
265
- linesAdded = this.countLines(newContent);
266
- addedLines = this.extractNonEmptyLines(newContent);
319
+ changeType = oldContent ? 'modify' : 'create';
320
+ const stats = this.diffLineCounts(oldContent, newContent);
321
+ linesAdded = stats.added;
322
+ linesRemoved = stats.removed;
323
+ if (oldContent && newContent) {
324
+ addedLines = this.diffAddedLines(oldContent, newContent);
325
+ }
326
+ else {
327
+ addedLines = this.extractNonEmptyLines(newContent);
328
+ }
267
329
  }
268
330
  else if (editOps.includes(funcName)) {
269
331
  changeType = 'modify';
270
- linesAdded = this.countLines(newContent);
271
- linesRemoved = this.countLines(oldContent);
272
332
  if ((funcName === 'apply_diff' || funcName === 'patch') && args.diff) {
273
333
  const diffStats = this.parseDiff(args.diff);
274
334
  linesAdded = diffStats.added;
@@ -276,9 +336,15 @@ export class CodexScanner extends BaseScanner {
276
336
  addedLines = this.extractAddedLinesFromDiff(args.diff);
277
337
  }
278
338
  else if (oldContent && newContent) {
339
+ const stats = this.diffLineCounts(oldContent, newContent);
340
+ linesAdded = stats.added;
341
+ linesRemoved = stats.removed;
279
342
  addedLines = this.diffAddedLines(oldContent, newContent);
280
343
  }
281
344
  else {
345
+ const stats = this.diffLineCounts(oldContent, newContent);
346
+ linesAdded = stats.added;
347
+ linesRemoved = stats.removed;
282
348
  addedLines = this.extractNonEmptyLines(newContent);
283
349
  }
284
350
  }
@@ -288,7 +354,7 @@ export class CodexScanner extends BaseScanner {
288
354
  if (linesAdded === 0 && linesRemoved === 0)
289
355
  return null;
290
356
  return {
291
- filePath,
357
+ filePath: resolvedPath,
292
358
  linesAdded,
293
359
  linesRemoved,
294
360
  changeType,
@@ -301,7 +367,7 @@ export class CodexScanner extends BaseScanner {
301
367
  /**
302
368
  * Parse a legacy tool_call object to extract file changes
303
369
  */
304
- parseToolCall(toolCall, projectPath, timestamp) {
370
+ parseToolCall(toolCall, projectPath, sessionCwd, timestamp) {
305
371
  const func = toolCall.function;
306
372
  if (!func)
307
373
  return null;
@@ -318,7 +384,7 @@ export class CodexScanner extends BaseScanner {
318
384
  catch {
319
385
  return null;
320
386
  }
321
- return this.parseFunctionCall(funcName, args, projectPath, timestamp);
387
+ return this.parseFunctionCall(funcName, args, projectPath, sessionCwd, timestamp);
322
388
  }
323
389
  /**
324
390
  * 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
  */
@@ -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
- return crypto.createHash('md5').update(projectPath).digest('hex').substring(0, 16);
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 = data.projectPath || data.project_path || data.cwd || data.working_directory || null;
104
- // Filter by project path if available
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
- // Exact match
198
- if (path1 === path2)
208
+ const p1 = this.toForwardSlash(path1);
209
+ const p2 = this.toForwardSlash(path2);
210
+ if (p1 === p2)
199
211
  return true;
200
- // One contains the other
201
- if (path1.startsWith(path2) || path2.startsWith(path1))
202
- return true;
203
- // Same basename (project name)
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
  */
@@ -232,9 +284,15 @@ export class GeminiScanner extends BaseScanner {
232
284
  let linesRemoved = 0;
233
285
  let addedLines = [];
234
286
  if (writeOps.includes(funcName)) {
235
- linesAdded = this.countLines(newContent);
236
- linesRemoved = this.countLines(oldContent); // Usually 0 for write, unless overwriting
237
- addedLines = this.extractNonEmptyLines(newContent);
287
+ const stats = this.calculateDiffStats(oldContent, newContent);
288
+ linesAdded = stats.added;
289
+ linesRemoved = stats.removed;
290
+ if (oldContent && newContent) {
291
+ addedLines = this.diffAddedLines(oldContent, newContent);
292
+ }
293
+ else {
294
+ addedLines = this.extractNonEmptyLines(newContent);
295
+ }
238
296
  }
239
297
  else if (editOps.includes(funcName)) {
240
298
  // Use LCS for edits to be accurate
@@ -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
- if (sessionPath === projectPath)
139
+ const s = this.toForwardSlash(sessionPath);
140
+ const p = this.toForwardSlash(projectPath);
141
+ if (s === p)
140
142
  return true;
141
- if (sessionPath.startsWith(projectPath + path.sep))
143
+ if (s.startsWith(p + '/'))
142
144
  return true;
143
- if (projectPath.startsWith(sessionPath + path.sep))
145
+ if (p.startsWith(s + '/'))
144
146
  return true;
145
147
  if (path.basename(sessionPath) === path.basename(projectPath))
146
148
  return true;
@@ -160,9 +162,10 @@ export class OpencodeScanner extends BaseScanner {
160
162
  : 'modify';
161
163
  // Use opencode's provided diff stats if available (most accurate)
162
164
  // additions/deletions are pre-calculated by opencode
165
+ const diffStats = this.diffLineCounts(beforeContent, afterContent);
163
166
  const addedLines = this.diffAddedLines(beforeContent, afterContent);
164
- const linesAdded = typeof diff.additions === 'number' ? diff.additions : addedLines.length;
165
- const linesRemoved = typeof diff.deletions === 'number' ? diff.deletions : 0;
167
+ const linesAdded = typeof diff.additions === 'number' ? diff.additions : diffStats.added;
168
+ const linesRemoved = typeof diff.deletions === 'number' ? diff.deletions : diffStats.removed;
166
169
  return {
167
170
  filePath,
168
171
  linesAdded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-credit",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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,10 @@
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
+ "v-patch": "npm version patch",
15
+ "v-minor": "npm version minor",
16
+ "v-major": "npm version major",
17
+ "pub": "npm run build && npm publish --access public",
15
18
  "prepublishOnly": "npm run build"
16
19
  },
17
20
  "keywords": [
@@ -37,7 +40,8 @@
37
40
  "chalk": "^5.3.0",
38
41
  "cli-table3": "^0.6.5",
39
42
  "commander": "^12.1.0",
40
- "glob": "^10.4.5"
43
+ "glob": "^10.4.5",
44
+ "ignore": "^5.3.1"
41
45
  },
42
46
  "devDependencies": {
43
47
  "@types/node": "^20.14.0",