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 ADDED
@@ -0,0 +1,2 @@
1
+ """How much of the code you shipped did AI write?"""
2
+ __version__ = "0.1.0"
aishare/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aishare = aishare.cli:main
@@ -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.