aishare 0.1.0__py3-none-any.whl
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.
- aishare/__init__.py +2 -0
- aishare/__main__.py +5 -0
- aishare/agentlogs.py +353 -0
- aishare/cli.py +170 -0
- aishare/share.py +296 -0
- aishare-0.1.0.dist-info/METADATA +86 -0
- aishare-0.1.0.dist-info/RECORD +10 -0
- aishare-0.1.0.dist-info/WHEEL +4 -0
- aishare-0.1.0.dist-info/entry_points.txt +2 -0
- aishare-0.1.0.dist-info/licenses/LICENSE +21 -0
aishare/__init__.py
ADDED
aishare/__main__.py
ADDED
aishare/agentlogs.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Read the session logs that AI coding agents keep on your machine, into one common shape.
|
|
2
|
+
|
|
3
|
+
Supported (all read-only, nothing leaves your machine):
|
|
4
|
+
- Claude Code: ~/.claude/projects/<project>/<session>.jsonl (and subagent logs under it)
|
|
5
|
+
- Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
6
|
+
- Gemini CLI: ~/.gemini/tmp/<project>/chats/session-*.json(l), with ~/.gemini/projects.json for folders
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator
|
|
17
|
+
|
|
18
|
+
AGENTS = {"claude": "Claude Code", "codex": "Codex", "gemini": "Gemini CLI"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Event:
|
|
23
|
+
agent: str # "claude" | "codex" | "gemini"
|
|
24
|
+
session: str
|
|
25
|
+
ts: float | None
|
|
26
|
+
cwd: str | None
|
|
27
|
+
kind: str # "prompt" | "command" | "read" | "write" | "edit" | "fetch" | "tool"
|
|
28
|
+
tool: str = ""
|
|
29
|
+
command: str = "" # shell command line
|
|
30
|
+
path: str = "" # file read/written
|
|
31
|
+
added: list[str] = field(default_factory=list) # lines the agent wrote
|
|
32
|
+
text: str = "" # prompt text, URL, etc.
|
|
33
|
+
model: str = ""
|
|
34
|
+
tokens: int = 0 # tokens used by this turn, where the log says
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_ts(s) -> float | None:
|
|
38
|
+
if not s:
|
|
39
|
+
return None
|
|
40
|
+
if isinstance(s, (int, float)):
|
|
41
|
+
return float(s) / (1000 if s > 1e11 else 1)
|
|
42
|
+
try:
|
|
43
|
+
return datetime.fromisoformat(str(s).replace("Z", "+00:00")).timestamp()
|
|
44
|
+
except ValueError:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _lines(text: str) -> list[str]:
|
|
49
|
+
return text.splitlines() if isinstance(text, str) else []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _jsonl(path: Path) -> Iterator[dict]:
|
|
53
|
+
try:
|
|
54
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
55
|
+
for line in fh:
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if not line:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
d = json.loads(line)
|
|
61
|
+
except ValueError:
|
|
62
|
+
continue
|
|
63
|
+
if isinstance(d, dict):
|
|
64
|
+
yield d
|
|
65
|
+
except OSError:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Claude Code
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
def claude_root() -> Path:
|
|
73
|
+
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude") / "projects"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _prompt_text(content) -> str:
|
|
77
|
+
if isinstance(content, str):
|
|
78
|
+
return content
|
|
79
|
+
if isinstance(content, list):
|
|
80
|
+
return "\n".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text")
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def claude_events(path: Path) -> Iterator[Event]:
|
|
85
|
+
for d in _jsonl(path):
|
|
86
|
+
typ = d.get("type")
|
|
87
|
+
if typ not in ("user", "assistant"):
|
|
88
|
+
continue
|
|
89
|
+
ts, cwd, sid = parse_ts(d.get("timestamp")), d.get("cwd"), d.get("sessionId") or path.stem
|
|
90
|
+
msg = d.get("message") or {}
|
|
91
|
+
content = msg.get("content")
|
|
92
|
+
if typ == "user":
|
|
93
|
+
if d.get("isMeta") or d.get("isCompactSummary") or d.get("toolUseResult") is not None:
|
|
94
|
+
continue
|
|
95
|
+
text = _prompt_text(content)
|
|
96
|
+
if text and not text.startswith(("<command-", "<local-command", "Caveat:", "[Request interrupted")) \
|
|
97
|
+
and not (isinstance(content, list) and any(b.get("type") == "tool_result" for b in content
|
|
98
|
+
if isinstance(b, dict))):
|
|
99
|
+
yield Event("claude", sid, ts, cwd, "prompt", text=text)
|
|
100
|
+
continue
|
|
101
|
+
usage = msg.get("usage") or {}
|
|
102
|
+
tokens = sum(int(usage.get(k) or 0) for k in ("input_tokens", "output_tokens", "cache_creation_input_tokens"))
|
|
103
|
+
model = msg.get("model") or ""
|
|
104
|
+
first = True
|
|
105
|
+
for b in content if isinstance(content, list) else []:
|
|
106
|
+
if not isinstance(b, dict) or b.get("type") != "tool_use":
|
|
107
|
+
continue
|
|
108
|
+
name, inp = b.get("name") or "", b.get("input") or {}
|
|
109
|
+
ev = Event("claude", sid, ts, cwd, "tool", tool=name, model=model, tokens=tokens if first else 0)
|
|
110
|
+
first = False
|
|
111
|
+
if name == "Bash":
|
|
112
|
+
ev.kind, ev.command = "command", str(inp.get("command") or "")
|
|
113
|
+
elif name in ("Read", "NotebookRead"):
|
|
114
|
+
ev.kind, ev.path = "read", str(inp.get("file_path") or inp.get("notebook_path") or "")
|
|
115
|
+
elif name == "Write":
|
|
116
|
+
ev.kind, ev.path, ev.added = "write", str(inp.get("file_path") or ""), _lines(inp.get("content", ""))
|
|
117
|
+
elif name == "Edit":
|
|
118
|
+
ev.kind, ev.path, ev.added = "edit", str(inp.get("file_path") or ""), _lines(inp.get("new_string", ""))
|
|
119
|
+
elif name == "MultiEdit":
|
|
120
|
+
ev.kind, ev.path = "edit", str(inp.get("file_path") or "")
|
|
121
|
+
for e in inp.get("edits") or []:
|
|
122
|
+
ev.added += _lines((e or {}).get("new_string", ""))
|
|
123
|
+
elif name == "NotebookEdit":
|
|
124
|
+
ev.kind, ev.path, ev.added = "edit", str(inp.get("notebook_path") or ""), _lines(inp.get("new_source", ""))
|
|
125
|
+
elif name in ("WebFetch", "WebSearch"):
|
|
126
|
+
ev.kind, ev.text = "fetch", str(inp.get("url") or inp.get("query") or "")
|
|
127
|
+
elif name in ("Grep", "Glob", "LS"):
|
|
128
|
+
ev.kind, ev.path = "read", str(inp.get("path") or "")
|
|
129
|
+
yield ev
|
|
130
|
+
if first and tokens: # a turn with no tool calls still used tokens
|
|
131
|
+
yield Event("claude", sid, ts, cwd, "tool", tool="", model=model, tokens=tokens)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Codex CLI
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
def codex_root() -> Path:
|
|
138
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "sessions"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
PATCH_FILE = re.compile(r"^\*\*\* (Add|Update|Delete) File: (.+)$")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_apply_patch(patch: str) -> list[tuple[str, list[str]]]:
|
|
145
|
+
"""[(path, added lines)] from Codex's *** Begin Patch format."""
|
|
146
|
+
out: list[tuple[str, list[str]]] = []
|
|
147
|
+
for line in patch.splitlines():
|
|
148
|
+
m = PATCH_FILE.match(line)
|
|
149
|
+
if m:
|
|
150
|
+
out.append((m.group(2).strip(), []))
|
|
151
|
+
elif out and line.startswith("+") and not line.startswith("+++"):
|
|
152
|
+
out[-1][1].append(line[1:])
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _codex_command(name: str, args) -> str:
|
|
157
|
+
if isinstance(args, str):
|
|
158
|
+
try:
|
|
159
|
+
args = json.loads(args)
|
|
160
|
+
except ValueError:
|
|
161
|
+
return args if name in ("shell_command",) else ""
|
|
162
|
+
if not isinstance(args, dict):
|
|
163
|
+
return ""
|
|
164
|
+
cmd = args.get("cmd", args.get("command"))
|
|
165
|
+
if isinstance(cmd, list):
|
|
166
|
+
# ["bash", "-lc", "real command"] -> "real command"
|
|
167
|
+
if len(cmd) >= 3 and cmd[1] in ("-lc", "-c") and os.path.basename(str(cmd[0])) in ("bash", "sh", "zsh"):
|
|
168
|
+
return str(cmd[2])
|
|
169
|
+
return " ".join(str(c) for c in cmd)
|
|
170
|
+
return str(cmd or "")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def codex_events(path: Path) -> Iterator[Event]:
|
|
174
|
+
sid, cwd, model = path.stem.replace("rollout-", ""), None, ""
|
|
175
|
+
last_total = 0
|
|
176
|
+
for d in _jsonl(path):
|
|
177
|
+
typ, p = d.get("type"), d.get("payload") or {}
|
|
178
|
+
ts = parse_ts(d.get("timestamp"))
|
|
179
|
+
if typ == "session_meta":
|
|
180
|
+
sid = str(p.get("id") or p.get("session_id") or sid)
|
|
181
|
+
cwd = p.get("cwd") or cwd
|
|
182
|
+
continue
|
|
183
|
+
if typ == "turn_context":
|
|
184
|
+
cwd = p.get("cwd") or cwd
|
|
185
|
+
model = p.get("model") or model
|
|
186
|
+
continue
|
|
187
|
+
if typ == "event_msg":
|
|
188
|
+
if p.get("type") == "user_message" and p.get("message"):
|
|
189
|
+
yield Event("codex", sid, ts, cwd, "prompt", text=str(p["message"]))
|
|
190
|
+
elif p.get("type") == "token_count" and isinstance(p.get("info"), dict):
|
|
191
|
+
total = (p["info"].get("total_token_usage") or {}).get("total_tokens") or 0
|
|
192
|
+
if total > last_total:
|
|
193
|
+
yield Event("codex", sid, ts, cwd, "tool", model=model, tokens=int(total - last_total))
|
|
194
|
+
last_total = total
|
|
195
|
+
continue
|
|
196
|
+
if typ != "response_item":
|
|
197
|
+
continue
|
|
198
|
+
ptype, name = p.get("type"), p.get("name") or ""
|
|
199
|
+
if ptype == "local_shell_call":
|
|
200
|
+
cmd = _codex_command("shell", (p.get("action") or {}))
|
|
201
|
+
yield Event("codex", sid, ts, cwd, "command", tool="local_shell", command=cmd, model=model)
|
|
202
|
+
elif ptype in ("function_call", "custom_tool_call"):
|
|
203
|
+
raw = p.get("arguments") if ptype == "function_call" else p.get("input")
|
|
204
|
+
if name == "apply_patch":
|
|
205
|
+
patch = raw
|
|
206
|
+
if ptype == "function_call":
|
|
207
|
+
try:
|
|
208
|
+
patch = json.loads(raw).get("input", "")
|
|
209
|
+
except (ValueError, AttributeError):
|
|
210
|
+
patch = ""
|
|
211
|
+
for fpath, added in parse_apply_patch(patch or ""):
|
|
212
|
+
yield Event("codex", sid, ts, cwd, "edit", tool="apply_patch", path=fpath, added=added, model=model)
|
|
213
|
+
elif name in ("shell", "exec_command", "shell_command", "container.exec"):
|
|
214
|
+
cmd = _codex_command(name, raw)
|
|
215
|
+
if "apply_patch" in cmd and "*** Begin Patch" in cmd: # older Codex sent patches through the shell
|
|
216
|
+
for fpath, added in parse_apply_patch(cmd):
|
|
217
|
+
yield Event("codex", sid, ts, cwd, "edit", tool="apply_patch", path=fpath, added=added,
|
|
218
|
+
model=model)
|
|
219
|
+
else:
|
|
220
|
+
yield Event("codex", sid, ts, cwd, "command", tool=name, command=cmd, model=model)
|
|
221
|
+
elif name in ("web_search", "web_fetch"):
|
|
222
|
+
yield Event("codex", sid, ts, cwd, "fetch", tool=name, text=str(raw)[:300], model=model)
|
|
223
|
+
else:
|
|
224
|
+
yield Event("codex", sid, ts, cwd, "tool", tool=name, model=model)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Gemini CLI
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
def gemini_home() -> Path:
|
|
231
|
+
return Path.home() / ".gemini"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def gemini_projects() -> dict[str, str]:
|
|
235
|
+
"""shortId -> project folder, from ~/.gemini/projects.json."""
|
|
236
|
+
for base in (gemini_home(), Path.home() / ".cache" / ".gemini"):
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads((base / "projects.json").read_text())
|
|
239
|
+
return {v: k for k, v in (data.get("projects") or {}).items()}
|
|
240
|
+
except (OSError, ValueError, AttributeError):
|
|
241
|
+
continue
|
|
242
|
+
return {}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _gemini_records(path: Path) -> tuple[dict, list[dict]]:
|
|
246
|
+
"""(metadata, messages) for both the old single-JSON and the newer JSONL session files."""
|
|
247
|
+
if path.suffix == ".json":
|
|
248
|
+
try:
|
|
249
|
+
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
250
|
+
except (OSError, ValueError):
|
|
251
|
+
return {}, []
|
|
252
|
+
return data, list(data.get("messages") or [])
|
|
253
|
+
meta: dict = {}
|
|
254
|
+
msgs: dict[str, dict] = {}
|
|
255
|
+
order: list[str] = []
|
|
256
|
+
for d in _jsonl(path):
|
|
257
|
+
if "sessionId" in d and "projectHash" in d and "type" not in d:
|
|
258
|
+
meta.update(d)
|
|
259
|
+
elif "$set" in d:
|
|
260
|
+
meta.update(d["$set"] if isinstance(d["$set"], dict) else {})
|
|
261
|
+
elif isinstance(d.get("id"), str):
|
|
262
|
+
if d["id"] in msgs: # a later patch of the same message (e.g. tool results filled in)
|
|
263
|
+
cur = msgs[d["id"]]
|
|
264
|
+
for k, v in d.items():
|
|
265
|
+
if k == "toolCalls" and isinstance(v, list):
|
|
266
|
+
known = {t.get("id"): t for t in cur.get("toolCalls") or []}
|
|
267
|
+
for t in v:
|
|
268
|
+
known.setdefault(t.get("id"), {}).update(t)
|
|
269
|
+
cur["toolCalls"] = list(known.values())
|
|
270
|
+
elif k != "$patch":
|
|
271
|
+
cur[k] = v
|
|
272
|
+
else:
|
|
273
|
+
msgs[d["id"]] = dict(d)
|
|
274
|
+
order.append(d["id"])
|
|
275
|
+
return meta, [msgs[i] for i in order]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def gemini_events(path: Path, projects: dict[str, str] | None = None) -> Iterator[Event]:
|
|
279
|
+
meta, messages = _gemini_records(path)
|
|
280
|
+
sid = str(meta.get("sessionId") or path.stem)
|
|
281
|
+
short = path.parent.parent.name if path.parent.name == "chats" else ""
|
|
282
|
+
cwd = (projects or {}).get(short)
|
|
283
|
+
for m in messages:
|
|
284
|
+
ts = parse_ts(m.get("timestamp"))
|
|
285
|
+
if m.get("type") == "user":
|
|
286
|
+
text = m.get("content")
|
|
287
|
+
if isinstance(text, list):
|
|
288
|
+
text = "\n".join(p.get("text", "") for p in text if isinstance(p, dict))
|
|
289
|
+
if text:
|
|
290
|
+
yield Event("gemini", sid, ts, cwd, "prompt", text=str(text))
|
|
291
|
+
continue
|
|
292
|
+
if m.get("type") != "gemini":
|
|
293
|
+
continue
|
|
294
|
+
tok = m.get("tokens") or {}
|
|
295
|
+
tokens = int(tok.get("total") or 0) if isinstance(tok, dict) else 0
|
|
296
|
+
model = m.get("model") or ""
|
|
297
|
+
calls = m.get("toolCalls") or []
|
|
298
|
+
for i, tc in enumerate(calls):
|
|
299
|
+
name, args = tc.get("name") or "", tc.get("args") or {}
|
|
300
|
+
ev = Event("gemini", sid, parse_ts(tc.get("timestamp")) or ts, cwd, "tool", tool=name, model=model,
|
|
301
|
+
tokens=tokens if i == 0 else 0)
|
|
302
|
+
if name in ("run_shell_command", "ShellTool"):
|
|
303
|
+
ev.kind, ev.command = "command", str(args.get("command") or "")
|
|
304
|
+
if args.get("dir_path") and not cwd:
|
|
305
|
+
ev.cwd = str(args["dir_path"])
|
|
306
|
+
elif name == "write_file":
|
|
307
|
+
ev.kind, ev.path, ev.added = "write", str(args.get("file_path") or ""), _lines(args.get("content", ""))
|
|
308
|
+
elif name in ("replace", "edit"):
|
|
309
|
+
ev.kind, ev.path, ev.added = "edit", str(args.get("file_path") or ""), _lines(args.get("new_string", ""))
|
|
310
|
+
elif name in ("read_file", "read_many_files", "glob", "search_file_content", "grep_search", "list_directory"):
|
|
311
|
+
ev.kind = "read"
|
|
312
|
+
ev.path = str(args.get("file_path") or args.get("absolute_path") or args.get("path") or
|
|
313
|
+
args.get("dir_path") or "")
|
|
314
|
+
elif name in ("web_fetch", "google_web_search"):
|
|
315
|
+
ev.kind, ev.text = "fetch", str(args.get("url") or args.get("prompt") or args.get("query") or "")[:300]
|
|
316
|
+
yield ev
|
|
317
|
+
if not calls and tokens:
|
|
318
|
+
yield Event("gemini", sid, ts, cwd, "tool", model=model, tokens=tokens)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Everything
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
def session_files(roots: dict[str, Path] | None = None) -> list[tuple[str, Path]]:
|
|
325
|
+
roots = roots or {"claude": claude_root(), "codex": codex_root(), "gemini": gemini_home() / "tmp"}
|
|
326
|
+
out: list[tuple[str, Path]] = []
|
|
327
|
+
if roots.get("claude") and roots["claude"].is_dir():
|
|
328
|
+
out += [("claude", p) for p in sorted(roots["claude"].rglob("*.jsonl"))]
|
|
329
|
+
if roots.get("codex") and roots["codex"].is_dir():
|
|
330
|
+
out += [("codex", p) for p in sorted(roots["codex"].rglob("rollout-*.jsonl"))]
|
|
331
|
+
if roots.get("gemini") and roots["gemini"].is_dir():
|
|
332
|
+
out += [("gemini", p) for p in sorted(roots["gemini"].glob("*/chats/session-*.json*"))]
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def all_events(roots: dict[str, Path] | None = None, since: float | None = None) -> Iterator[Event]:
|
|
337
|
+
projects = gemini_projects()
|
|
338
|
+
for agent, path in session_files(roots):
|
|
339
|
+
try:
|
|
340
|
+
if since and path.stat().st_mtime < since:
|
|
341
|
+
continue # untouched since before the window: nothing in it can be newer
|
|
342
|
+
except OSError:
|
|
343
|
+
continue
|
|
344
|
+
if agent == "claude":
|
|
345
|
+
it = claude_events(path)
|
|
346
|
+
elif agent == "codex":
|
|
347
|
+
it = codex_events(path)
|
|
348
|
+
else:
|
|
349
|
+
it = gemini_events(path, projects)
|
|
350
|
+
for ev in it:
|
|
351
|
+
if since and ev.ts and ev.ts < since:
|
|
352
|
+
continue
|
|
353
|
+
yield ev
|
aishare/cli.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""aishare: how much of the code you shipped did AI write?"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console, Group
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from . import __version__, agentlogs
|
|
17
|
+
from .share import NAMES, AgentIndex, Summary, analyze_repo, find_repos, identities
|
|
18
|
+
|
|
19
|
+
COLORS = {"claude": "#d97757", "codex": "#10a37f", "gemini": "#4285f4", "copilot": "#a371f7", "cursor": "#e6e6e6",
|
|
20
|
+
"aider": "#14b014", "devin": "#3b82f6", "windsurf": "#09b6a2", "amp": "#f34e3f", "you": "#5c6370"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def bar(parts: list[tuple[str, int]], total: int, width: int = 48) -> Text:
|
|
24
|
+
t = Text()
|
|
25
|
+
used = 0
|
|
26
|
+
for key, n in parts:
|
|
27
|
+
w = round(width * n / total) if total else 0
|
|
28
|
+
w = min(w, width - used)
|
|
29
|
+
if n and w == 0:
|
|
30
|
+
w = 1
|
|
31
|
+
t.append("█" * w, COLORS.get(key, "white"))
|
|
32
|
+
used += w
|
|
33
|
+
if used < width:
|
|
34
|
+
t.append("█" * (width - used), COLORS["you"])
|
|
35
|
+
return t
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render(s: Summary, console: Console, top: int = 8) -> None:
|
|
39
|
+
if not s.total:
|
|
40
|
+
console.print(Text("No lines of yours from that period are still in these repos. "
|
|
41
|
+
"Try a wider window with --since, or point it at a folder of repos.", "yellow"))
|
|
42
|
+
return
|
|
43
|
+
head = Text.assemble((f"{s.share:.0%}", "bold #d97757"), " of the code you shipped ", s.period(),
|
|
44
|
+
" was written by ", ("AI", "bold"), ".")
|
|
45
|
+
parts: list = [head, Text(f"{s.ai:,} of {s.total:,} lines still in your code, across {len(s.repos)} "
|
|
46
|
+
f"repo{'s' * (len(s.repos) != 1)}", "dim"), Text("")]
|
|
47
|
+
agents = s.by_agent.most_common()
|
|
48
|
+
parts.append(bar(agents, s.total))
|
|
49
|
+
legend = Text()
|
|
50
|
+
for a, n in agents:
|
|
51
|
+
legend.append("■ ", COLORS.get(a, "white"))
|
|
52
|
+
legend.append(f"{NAMES.get(a, a)} {n / s.total:.0%} ")
|
|
53
|
+
legend.append("■ ", COLORS["you"])
|
|
54
|
+
legend.append(f"You {1 - s.share:.0%}")
|
|
55
|
+
parts += [legend, Text("")]
|
|
56
|
+
|
|
57
|
+
months = s.months()
|
|
58
|
+
if len(months) > 1:
|
|
59
|
+
g = Table.grid(padding=(0, 1))
|
|
60
|
+
g.add_column(no_wrap=True, style="dim")
|
|
61
|
+
g.add_column()
|
|
62
|
+
g.add_column(justify="right", style="dim")
|
|
63
|
+
for m, c in months.items():
|
|
64
|
+
tot = sum(c.values())
|
|
65
|
+
ai = tot - c.get("you", 0)
|
|
66
|
+
label = datetime.strptime(m, "%Y-%m").strftime("%b")
|
|
67
|
+
g.add_row(label, bar([(a, n) for a, n in c.most_common() if a != "you"], tot, 30),
|
|
68
|
+
f"{ai / tot:.0%}" if tot else "")
|
|
69
|
+
parts += [Text("Month by month", "bold"), g, Text("")]
|
|
70
|
+
|
|
71
|
+
repos = sorted((r for r in s.repos if r.total), key=lambda r: -r.total)
|
|
72
|
+
if repos:
|
|
73
|
+
t = Table(box=None, header_style="dim", padding=(0, 2))
|
|
74
|
+
t.add_column("Repo", no_wrap=True)
|
|
75
|
+
t.add_column("Your lines", justify="right")
|
|
76
|
+
t.add_column("AI", justify="right")
|
|
77
|
+
t.add_column("Mostly", no_wrap=True)
|
|
78
|
+
for r in repos[:top]:
|
|
79
|
+
main = r.by_agent.most_common(1)
|
|
80
|
+
t.add_row(Text(r.repo.name, "bold"), f"{r.total:,}", f"{r.ai / r.total:.0%}",
|
|
81
|
+
Text(NAMES.get(main[0][0], main[0][0]), COLORS.get(main[0][0], "")) if main else Text("—", "dim"))
|
|
82
|
+
if len(repos) > top:
|
|
83
|
+
t.add_row(Text(f"+ {len(repos) - top} more", "dim"), "", "", "")
|
|
84
|
+
parts += [t, Text("")]
|
|
85
|
+
|
|
86
|
+
via_logs = sum(r.via_logs for r in s.repos)
|
|
87
|
+
via_tr = sum(r.via_trailers for r in s.repos)
|
|
88
|
+
how = []
|
|
89
|
+
if via_logs:
|
|
90
|
+
how.append(f"{via_logs:,} lines matched to agent session logs")
|
|
91
|
+
if via_tr:
|
|
92
|
+
how.append(f"{via_tr:,} from commits with an AI co-author")
|
|
93
|
+
if how:
|
|
94
|
+
parts.append(Text("How we know: " + "; ".join(how) + ".", "dim"))
|
|
95
|
+
if not s.log_events:
|
|
96
|
+
parts.append(Text("No Claude Code, Codex or Gemini CLI logs found here, so only commit co-author trailers "
|
|
97
|
+
"were counted.", "dim"))
|
|
98
|
+
console.print(Panel(Group(*parts), title="[bold]Who wrote your code?[/]", border_style="#d97757", padding=(1, 2)))
|
|
99
|
+
console.print(Text(" Share: ", "dim") + Text(s.share_line(), "italic"))
|
|
100
|
+
console.print()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_date(s: str) -> float:
|
|
104
|
+
for fmt in ("%Y-%m-%d", "%Y-%m", "%Y"):
|
|
105
|
+
try:
|
|
106
|
+
return time.mktime(datetime.strptime(s, fmt).timetuple())
|
|
107
|
+
except ValueError:
|
|
108
|
+
continue
|
|
109
|
+
raise argparse.ArgumentTypeError(f"not a date: {s} (use YYYY, YYYY-MM or YYYY-MM-DD)")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(argv: list[str] | None = None) -> int:
|
|
113
|
+
ap = argparse.ArgumentParser(prog="aishare", description="How much of the code you shipped did AI write? "
|
|
114
|
+
"Matches git blame against your AI agents' session logs and AI co-author trailers.")
|
|
115
|
+
ap.add_argument("paths", nargs="*", default=["."], help="repos, or folders to search for repos (default: .)")
|
|
116
|
+
ap.add_argument("--since", type=parse_date, help="start of the window (default: Jan 1 this year)")
|
|
117
|
+
ap.add_argument("--until", type=parse_date, help="end of the window (default: now)")
|
|
118
|
+
ap.add_argument("--email", action="append", default=[], help="another email or name you commit as (repeatable)")
|
|
119
|
+
ap.add_argument("--top", type=int, default=8, help="rows in the repo table")
|
|
120
|
+
ap.add_argument("--json", action="store_true", help="print numbers as JSON")
|
|
121
|
+
ap.add_argument("-V", "--version", action="version", version=f"aishare {__version__}")
|
|
122
|
+
a = ap.parse_args(argv)
|
|
123
|
+
console = Console(stderr=a.json)
|
|
124
|
+
|
|
125
|
+
now = time.time()
|
|
126
|
+
since = a.since or time.mktime((time.localtime(now).tm_year, 1, 1, 0, 0, 0, 0, 0, -1))
|
|
127
|
+
until = a.until or now
|
|
128
|
+
me = identities(a.email)
|
|
129
|
+
if not me:
|
|
130
|
+
console.print("[yellow]Couldn't read your git identity.[/] Set it with git config --global user.email, "
|
|
131
|
+
"or pass --email you@example.com")
|
|
132
|
+
return 2
|
|
133
|
+
repos = find_repos([Path(p) for p in a.paths])
|
|
134
|
+
if not repos:
|
|
135
|
+
console.print("[yellow]No git repos found there.[/]")
|
|
136
|
+
return 2
|
|
137
|
+
|
|
138
|
+
index = AgentIndex()
|
|
139
|
+
seen = {}
|
|
140
|
+
with console.status("Reading agent session logs…") if not a.json else _null():
|
|
141
|
+
for ev in agentlogs.all_events(since=since - 86400):
|
|
142
|
+
index.add(ev)
|
|
143
|
+
seen[ev.agent] = seen.get(ev.agent, 0) + 1
|
|
144
|
+
results = []
|
|
145
|
+
with console.status(f"Running git blame across {len(repos)} repo(s)…") if not a.json else _null():
|
|
146
|
+
for r in repos:
|
|
147
|
+
results.append(analyze_repo(r, me, since, until, index))
|
|
148
|
+
s = Summary(results, since, until, log_events=index.events)
|
|
149
|
+
if a.json:
|
|
150
|
+
print(json.dumps({
|
|
151
|
+
"since": since, "until": until, "lines": s.total, "ai_lines": s.ai, "share": round(s.share, 4),
|
|
152
|
+
"by_agent": dict(s.by_agent), "months": {m: dict(c) for m, c in s.months().items()},
|
|
153
|
+
"repos": [{"repo": str(r.repo), "lines": r.total, "ai_lines": r.ai, "by_agent": dict(r.by_agent),
|
|
154
|
+
"via_logs": r.via_logs, "via_trailers": r.via_trailers} for r in results],
|
|
155
|
+
}, indent=2))
|
|
156
|
+
else:
|
|
157
|
+
render(s, console, a.top)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _null:
|
|
162
|
+
def __enter__(self):
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *a):
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
sys.exit(main())
|
aishare/share.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Who wrote the code you shipped? Match the lines git blames on you against what your AI agents wrote."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .agentlogs import Event
|
|
12
|
+
|
|
13
|
+
# Commit trailers and bodies that name an AI tool. (agent key, pattern)
|
|
14
|
+
TRAILER_AGENTS = [
|
|
15
|
+
("claude", re.compile(r"noreply@anthropic\.com|\bclaude\b|claude\.ai/code|claude\.com/claude-code", re.I)),
|
|
16
|
+
("codex", re.compile(r"\bcodex\b|openai\.com", re.I)),
|
|
17
|
+
("copilot", re.compile(r"\bcopilot\b|copilot-swe-agent", re.I)),
|
|
18
|
+
("cursor", re.compile(r"cursoragent@cursor\.com|\bcursor agent\b|\bcursor\b", re.I)),
|
|
19
|
+
("gemini", re.compile(r"\bgemini\b|gemini-code-assist|google-labs-jules|\bjules\b", re.I)),
|
|
20
|
+
("aider", re.compile(r"\baider\b", re.I)),
|
|
21
|
+
("devin", re.compile(r"\bdevin\b|devin-ai-integration", re.I)),
|
|
22
|
+
("windsurf", re.compile(r"\bwindsurf\b|\bcodeium\b|\bcascade\b", re.I)),
|
|
23
|
+
("amp", re.compile(r"ampcode\.com", re.I)),
|
|
24
|
+
]
|
|
25
|
+
AI_LINE = re.compile(r"^(co-authored-by|generated[- ]by|generated with|assisted-by|ai-assisted):", re.I)
|
|
26
|
+
GENERATED_WITH = re.compile(r"generated with \[?(claude code|codex|cursor|gemini|aider|copilot)", re.I)
|
|
27
|
+
BOT_AUTHORS = re.compile(r"copilot-swe-agent|devin-ai-integration|google-labs-jules|cursor.*agent|claude\[bot\]|"
|
|
28
|
+
r"codex\[bot\]|openai-codex", re.I)
|
|
29
|
+
|
|
30
|
+
NAMES = {"claude": "Claude", "codex": "Codex", "gemini": "Gemini", "copilot": "Copilot", "cursor": "Cursor",
|
|
31
|
+
"aider": "Aider", "devin": "Devin", "windsurf": "Windsurf", "amp": "Amp"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def norm(line: str) -> str | None:
|
|
35
|
+
"""Whitespace-insensitive form of a line, or None for lines too trivial to attribute (braces, blanks)."""
|
|
36
|
+
s = re.sub(r"\s+", " ", line.strip())
|
|
37
|
+
if len(s) < 4 or not re.search(r"[A-Za-z0-9]{2}", s):
|
|
38
|
+
return None
|
|
39
|
+
return s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def git(repo: Path, *args: str, check: bool = False) -> str:
|
|
43
|
+
try:
|
|
44
|
+
p = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, errors="replace",
|
|
45
|
+
timeout=300)
|
|
46
|
+
except (OSError, subprocess.SubprocessError):
|
|
47
|
+
return ""
|
|
48
|
+
if check and p.returncode:
|
|
49
|
+
raise RuntimeError(p.stderr.strip())
|
|
50
|
+
return p.stdout
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_repos(paths: list[Path], depth: int = 4) -> list[Path]:
|
|
54
|
+
out: list[Path] = []
|
|
55
|
+
skip = {"node_modules", ".venv", "venv", "vendor", "target", "dist", "build", "Library", ".cache", ".Trash"}
|
|
56
|
+
|
|
57
|
+
def walk(d: Path, level: int):
|
|
58
|
+
if (d / ".git").exists():
|
|
59
|
+
out.append(d)
|
|
60
|
+
return
|
|
61
|
+
if level >= depth:
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
for c in sorted(d.iterdir()):
|
|
65
|
+
if c.is_dir() and not c.is_symlink() and not c.name.startswith(".") and c.name not in skip:
|
|
66
|
+
walk(c, level + 1)
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
for p in paths:
|
|
71
|
+
p = p.expanduser().resolve()
|
|
72
|
+
top = git(p, "rev-parse", "--show-toplevel").strip()
|
|
73
|
+
if top:
|
|
74
|
+
out.append(Path(top))
|
|
75
|
+
else:
|
|
76
|
+
walk(p, 0)
|
|
77
|
+
seen, uniq = set(), []
|
|
78
|
+
for r in out:
|
|
79
|
+
if r not in seen:
|
|
80
|
+
seen.add(r)
|
|
81
|
+
uniq.append(r)
|
|
82
|
+
return uniq
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def identities(extra: list[str] = ()) -> set[str]:
|
|
86
|
+
ids = {e.lower() for e in extra}
|
|
87
|
+
for key in ("user.email", "user.name"):
|
|
88
|
+
try:
|
|
89
|
+
v = subprocess.run(["git", "config", "--global", key], capture_output=True, text=True).stdout.strip()
|
|
90
|
+
except OSError:
|
|
91
|
+
v = ""
|
|
92
|
+
if v:
|
|
93
|
+
ids.add(v.lower())
|
|
94
|
+
return ids
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def trailer_agent(message: str) -> str | None:
|
|
98
|
+
for line in message.splitlines():
|
|
99
|
+
line = line.strip()
|
|
100
|
+
if AI_LINE.match(line) or GENERATED_WITH.search(line):
|
|
101
|
+
for key, rx in TRAILER_AGENTS:
|
|
102
|
+
if rx.search(line):
|
|
103
|
+
return key
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class AgentLine:
|
|
109
|
+
agent: str
|
|
110
|
+
ts: float
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AgentIndex:
|
|
114
|
+
"""Lines each agent wrote, per absolute file path, with when it wrote them."""
|
|
115
|
+
|
|
116
|
+
def __init__(self):
|
|
117
|
+
self.by_file: dict[str, dict[str, list[AgentLine]]] = defaultdict(lambda: defaultdict(list))
|
|
118
|
+
self.events = 0
|
|
119
|
+
|
|
120
|
+
def add(self, ev: Event) -> None:
|
|
121
|
+
if ev.kind not in ("write", "edit") or not ev.path or not ev.added:
|
|
122
|
+
return
|
|
123
|
+
p = ev.path
|
|
124
|
+
if not os.path.isabs(p):
|
|
125
|
+
if not ev.cwd:
|
|
126
|
+
return
|
|
127
|
+
p = os.path.join(ev.cwd, p)
|
|
128
|
+
p = os.path.normpath(p)
|
|
129
|
+
self.events += 1
|
|
130
|
+
bucket = self.by_file[p]
|
|
131
|
+
for line in ev.added:
|
|
132
|
+
n = norm(line)
|
|
133
|
+
if n:
|
|
134
|
+
bucket[n].append(AgentLine(ev.agent, ev.ts or 0.0))
|
|
135
|
+
|
|
136
|
+
def lookup(self, path: str, line: str, committed: float, slack: float = 120) -> str | None:
|
|
137
|
+
"""The agent that wrote this exact line in this file before it was committed, if any."""
|
|
138
|
+
n = norm(line)
|
|
139
|
+
if not n:
|
|
140
|
+
return None
|
|
141
|
+
hits = self.by_file.get(os.path.normpath(path), {}).get(n)
|
|
142
|
+
if not hits:
|
|
143
|
+
return None
|
|
144
|
+
before = [h for h in hits if h.ts <= committed + slack]
|
|
145
|
+
return before[-1].agent if before else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class RepoResult:
|
|
150
|
+
repo: Path
|
|
151
|
+
total: int = 0 # substantive lines you shipped in the window that are still in the code
|
|
152
|
+
by_agent: Counter = field(default_factory=Counter) # agent -> lines
|
|
153
|
+
by_month: dict[str, Counter] = field(default_factory=lambda: defaultdict(Counter)) # "2026-03" -> {"you":n, agent:n}
|
|
154
|
+
files: Counter = field(default_factory=Counter) # file -> AI lines
|
|
155
|
+
file_totals: Counter = field(default_factory=Counter)
|
|
156
|
+
via_logs: int = 0
|
|
157
|
+
via_trailers: int = 0
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def ai(self) -> int:
|
|
161
|
+
return sum(self.by_agent.values())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def blame_file(repo: Path, rel: str):
|
|
165
|
+
"""Yield (sha, author_mail, author_name, author_time, line) for every line at HEAD."""
|
|
166
|
+
out = git(repo, "blame", "--line-porcelain", "-w", "HEAD", "--", rel)
|
|
167
|
+
sha = mail = name = None
|
|
168
|
+
t = 0
|
|
169
|
+
for line in out.splitlines():
|
|
170
|
+
if line.startswith("\t"):
|
|
171
|
+
yield sha, mail, name, t, line[1:]
|
|
172
|
+
continue
|
|
173
|
+
head, _, rest = line.partition(" ")
|
|
174
|
+
if re.fullmatch(r"[0-9a-f]{40}", head):
|
|
175
|
+
sha = head
|
|
176
|
+
elif head == "author-mail":
|
|
177
|
+
mail = rest.strip("<>").lower()
|
|
178
|
+
elif head == "author":
|
|
179
|
+
name = rest.lower()
|
|
180
|
+
elif head == "author-time":
|
|
181
|
+
t = int(rest)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def analyze_repo(repo: Path, me: set[str], since: float, until: float, index: AgentIndex) -> RepoResult:
|
|
185
|
+
res = RepoResult(repo)
|
|
186
|
+
if not git(repo, "rev-parse", "--verify", "-q", "HEAD").strip():
|
|
187
|
+
return res
|
|
188
|
+
# which commits name an AI tool, and which are by agent bots
|
|
189
|
+
log = git(repo, "log", "--all", f"--since=@{int(since)}", f"--until=@{int(until)}", "--format=%H%x00%an%x00%ae%x00%B%x1e")
|
|
190
|
+
ai_commit: dict[str, str] = {}
|
|
191
|
+
bot_commit: set[str] = set()
|
|
192
|
+
for rec in log.split("\x1e"):
|
|
193
|
+
parts = rec.strip("\n").split("\x00")
|
|
194
|
+
if len(parts) < 4:
|
|
195
|
+
continue
|
|
196
|
+
sha, an, ae, body = parts[0], parts[1], parts[2], parts[3]
|
|
197
|
+
agent = trailer_agent(body)
|
|
198
|
+
if BOT_AUTHORS.search(f"{an} {ae}"):
|
|
199
|
+
bot_commit.add(sha)
|
|
200
|
+
agent = agent or next((k for k, rx in TRAILER_AGENTS if rx.search(f"{an} {ae}")), "copilot")
|
|
201
|
+
if agent:
|
|
202
|
+
ai_commit[sha] = agent
|
|
203
|
+
# files touched in the window that still exist
|
|
204
|
+
names = git(repo, "log", f"--since=@{int(since)}", f"--until=@{int(until)}", "--name-only", "--format=", "HEAD")
|
|
205
|
+
tracked = set(git(repo, "ls-files").splitlines())
|
|
206
|
+
files = sorted({n for n in names.splitlines() if n and n in tracked})
|
|
207
|
+
for rel in files:
|
|
208
|
+
if is_generated(rel):
|
|
209
|
+
continue
|
|
210
|
+
abspath = str(repo / rel)
|
|
211
|
+
for sha, mail, name, t, line in blame_file(repo, rel):
|
|
212
|
+
if not (since <= t <= until) or not norm(line):
|
|
213
|
+
continue
|
|
214
|
+
mine = (mail in me or name in me)
|
|
215
|
+
if not mine and sha not in bot_commit:
|
|
216
|
+
continue
|
|
217
|
+
res.total += 1
|
|
218
|
+
res.file_totals[rel] += 1
|
|
219
|
+
month = _month(t)
|
|
220
|
+
agent = index.lookup(abspath, line, t)
|
|
221
|
+
if agent:
|
|
222
|
+
res.via_logs += 1
|
|
223
|
+
elif sha in ai_commit:
|
|
224
|
+
agent = ai_commit[sha]
|
|
225
|
+
res.via_trailers += 1
|
|
226
|
+
if agent:
|
|
227
|
+
res.by_agent[agent] += 1
|
|
228
|
+
res.files[rel] += 1
|
|
229
|
+
res.by_month[month][agent] += 1
|
|
230
|
+
else:
|
|
231
|
+
res.by_month[month]["you"] += 1
|
|
232
|
+
return res
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
GENERATED = re.compile(r"(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|uv\.lock|Cargo\.lock|"
|
|
236
|
+
r"go\.sum|.*\.min\.(js|css)|.*\.map|.*\.svg|.*\.ipynb|.*\.snap|.*\.lock)$|(^|/)(dist|build|vendor|"
|
|
237
|
+
r"node_modules|__snapshots__)/")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def is_generated(rel: str) -> bool:
|
|
241
|
+
return bool(GENERATED.search(rel))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _month(t: float) -> str:
|
|
245
|
+
import time
|
|
246
|
+
return time.strftime("%Y-%m", time.localtime(t))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class Summary:
|
|
251
|
+
repos: list[RepoResult]
|
|
252
|
+
since: float
|
|
253
|
+
until: float
|
|
254
|
+
log_events: int = 0
|
|
255
|
+
agents_seen: Counter = field(default_factory=Counter)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def total(self) -> int:
|
|
259
|
+
return sum(r.total for r in self.repos)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def by_agent(self) -> Counter:
|
|
263
|
+
c = Counter()
|
|
264
|
+
for r in self.repos:
|
|
265
|
+
c.update(r.by_agent)
|
|
266
|
+
return Counter(dict(sorted(c.items(), key=lambda kv: (-kv[1], kv[0])))) # stable order for ties
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def ai(self) -> int:
|
|
270
|
+
return sum(self.by_agent.values())
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def share(self) -> float:
|
|
274
|
+
return self.ai / self.total if self.total else 0.0
|
|
275
|
+
|
|
276
|
+
def months(self) -> dict[str, Counter]:
|
|
277
|
+
out: dict[str, Counter] = defaultdict(Counter)
|
|
278
|
+
for r in self.repos:
|
|
279
|
+
for m, c in r.by_month.items():
|
|
280
|
+
out[m].update(c)
|
|
281
|
+
return dict(sorted(out.items()))
|
|
282
|
+
|
|
283
|
+
def period(self) -> str:
|
|
284
|
+
import time
|
|
285
|
+
y0, y1 = time.localtime(self.since).tm_year, time.localtime(self.until).tm_year
|
|
286
|
+
jan1 = time.mktime((y0, 1, 1, 0, 0, 0, 0, 0, -1))
|
|
287
|
+
if y0 == y1 and abs(self.since - jan1) < 86400:
|
|
288
|
+
return f"in {y0}"
|
|
289
|
+
t = time.localtime(self.since)
|
|
290
|
+
return f"since {time.strftime('%b', t)} {t.tm_mday}, {t.tm_year}"
|
|
291
|
+
|
|
292
|
+
def share_line(self) -> str:
|
|
293
|
+
top = ", ".join(f"{NAMES.get(a, a)} {n / self.total:.0%}" for a, n in self.by_agent.most_common(3)) \
|
|
294
|
+
if self.total else ""
|
|
295
|
+
s = f"{self.share:.0%} of the code I shipped {self.period()} was written by AI"
|
|
296
|
+
return s + (f" ({top})" if top and self.ai else "") + ". How much of yours? uvx aishare"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aishare
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: How much of the code you shipped did AI write? Matches git blame against Claude Code, Codex and Gemini CLI session logs and AI co-author trailers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ArkaKhorchidian/aishare
|
|
6
|
+
Project-URL: Issues, https://github.com/ArkaKhorchidian/aishare/issues
|
|
7
|
+
Author: Arka Khorchidian
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,claude-code,cli,codex,copilot,developer-tools,gemini-cli,git,git-blame,statistics
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: rich>=13
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# aishare
|
|
25
|
+
|
|
26
|
+
**How much of the code you shipped did AI write?** aishare looks at every line git blames on you this year that's still in your code, and works out which ones came from Claude Code, Codex, Gemini CLI, Copilot, Cursor and friends.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uvx aishare ~/code
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
<p align="center"><img src="https://raw.githubusercontent.com/ArkaKhorchidian/aishare/main/docs/demo.gif" alt="aishare demo" width="760"></p>
|
|
33
|
+
|
|
34
|
+
<sub>Demo on a made-up developer's year (three repos plus agent logs) generated by <a href="docs/demo.py">docs/demo.py</a>. Run it on your own code to get your number.</sub>
|
|
35
|
+
|
|
36
|
+
## How it knows
|
|
37
|
+
|
|
38
|
+
Two independent signals, both read locally:
|
|
39
|
+
|
|
40
|
+
1. **Agent session logs.** Coding agents keep a transcript of every file they write or edit. aishare reads them and records the exact lines each agent produced, per file, and when:
|
|
41
|
+
- **Claude Code**: `~/.claude/projects/*/*.jsonl` (`Write`, `Edit`, `MultiEdit`)
|
|
42
|
+
- **Codex CLI**: `~/.codex/sessions/**/rollout-*.jsonl` (`apply_patch`)
|
|
43
|
+
- **Gemini CLI**: `~/.gemini/tmp/*/chats/session-*` (`write_file`, `replace`)
|
|
44
|
+
|
|
45
|
+
Then it runs `git blame` on the files you changed in the period. A line counts as AI-written if an agent wrote that exact line (ignoring whitespace) into that file **before** the commit that introduced it. Lines you wrote earlier that an agent merely re-saved don't count, and lines you later rewrote by hand become yours.
|
|
46
|
+
|
|
47
|
+
2. **AI co-author trailers.** Commits that say `Co-authored-by: Claude / Copilot / Cursor Agent / Codex / Gemini / Aider / Devin…` or "Generated with Claude Code", plus commits authored by agent bots (Copilot coding agent, Devin, Jules).
|
|
48
|
+
|
|
49
|
+
Only substantive lines count: blank lines, lone braces and the like are skipped on both sides, and lockfiles, minified files and build output are ignored.
|
|
50
|
+
|
|
51
|
+
## What it can't see
|
|
52
|
+
|
|
53
|
+
- **Inline autocomplete** (Copilot, Cursor Tab, Supermaven) leaves no log or trailer, so it's counted as yours. Real numbers are usually higher.
|
|
54
|
+
- **Chat copy-paste** from ChatGPT or Claude.ai in a browser.
|
|
55
|
+
- **Agents that don't keep local logs.** Cursor stores its history in a database, not supported yet.
|
|
56
|
+
|
|
57
|
+
So treat the result as a floor.
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
aishare # the repo you're in
|
|
63
|
+
aishare ~/code # every repo under a folder
|
|
64
|
+
aishare --since 2026-06 # a different window (YYYY, YYYY-MM or YYYY-MM-DD)
|
|
65
|
+
aishare --email old@work.com # also count commits under another email
|
|
66
|
+
aishare --json # numbers for scripts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
It uses your global `git config user.email` and `user.name` to decide which lines are yours.
|
|
70
|
+
|
|
71
|
+
## Privacy
|
|
72
|
+
|
|
73
|
+
Everything runs on your machine. aishare reads git and your agents' log files and sends nothing anywhere.
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uvx aishare # run without installing
|
|
79
|
+
pipx install aishare # or install it
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Requires Python 3.9+ and git. The only dependency is `rich`.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
aishare/__init__.py,sha256=KhZhX1KqF3hsiK4B4fRT4AJ8S_p7i5yF5dztnwMv3nI,75
|
|
2
|
+
aishare/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
aishare/agentlogs.py,sha256=KZ-w1mVwluDN0pBELaVm84H_kNJjp83UUwzOHbFRYig,15970
|
|
4
|
+
aishare/cli.py,sha256=E-1nhjvA5pbZdNa-anRNr8mabpNpbQxrTpFXVUVCa4I,7335
|
|
5
|
+
aishare/share.py,sha256=W-N4_pQOQxCBDd6aP5QEabb5YakcdAqOhMEwJgvvrzc,10878
|
|
6
|
+
aishare-0.1.0.dist-info/METADATA,sha256=4uirLHgrfdvTGtCd--C6QPMYfsRqTd3RT96k8MLCeso,3891
|
|
7
|
+
aishare-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
aishare-0.1.0.dist-info/entry_points.txt,sha256=hGBDMPZjCZNi8PejLWWRBD-ixVc7Pm0ZlUG7uq6SPA0,45
|
|
9
|
+
aishare-0.1.0.dist-info/licenses/LICENSE,sha256=mxFQpL6plnvuVMkJNW-M1_rxIlAzORcfDhnP1UETuKI,1073
|
|
10
|
+
aishare-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arka Khorchidian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|