ai-credit 1.0.3 โ†’ 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 CHANGED
@@ -3,7 +3,7 @@
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). 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
- โ”‚ 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
- โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
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
- Claude Code โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 66.7%
131
- Codex CLI โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 26.7%
132
- Opencode โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 6.7%
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 Path**: `~/.gemini/tmp/<project_hash>/chats/*.json`
243
+ - **Scan Paths**: `~/.gemini/tmp/<hash>/chats/*.json`, `~/.gemini/history/*.json`, `~/.gemini/sessions/*.json`
221
244
  - **Parsing Logic**:
222
- 1. The tool first calculates the corresponding `<project_hash>` from the target project path to locate the specific `chats` directory.
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"`. This object's structure is similar to that of Codex CLI.
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. **Extract Changed Content**: For each file change, the tool captures:
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
- 3. **Verify Against Current Codebase**: Before counting any line as an AI contribution, the tool:
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
- 4. **Calculate Statistics**: The verified lines are then aggregated into:
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(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'));
@@ -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
  */
@@ -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
  /**
@@ -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
  }
@@ -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,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
- 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;
@@ -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
  */
@@ -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)
199
- return true;
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
- // 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
  */
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-credit",
3
- "version": "1.0.3",
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",
@@ -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
- }
@@ -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
- }