agentledger-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ snowflake.local.yml
2
+ output/**
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
6
+ .venv-dashboard/
7
+ .DS_Store
8
+ client/dist/
9
+ .wrangler/
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentledger-cli
3
+ Version: 0.1.0
4
+ Summary: Report Claude Code, Cursor, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake Native App.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: snowflake-connector-python>=3.12
7
+ Description-Content-Type: text/markdown
8
+
9
+ # agentledger
10
+
11
+ Reports your Claude Code, Cursor CLI, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake
12
+ Native App, so your team sees agent cost, prompts and tool use per developer, repo, branch and
13
+ pull request.
14
+
15
+ ```bash
16
+ uv tool install agentledger-cli # or: pipx install agentledger-cli
17
+ agentledger install
18
+ ```
19
+
20
+ `install` checks that your Snowflake connection can report to the app, then sets up every agent
21
+ it finds:
22
+
23
+ | Agent | What it adds |
24
+ |---|---|
25
+ | Claude Code | `Stop` and `SessionEnd` hooks in `~/.claude/settings.json` |
26
+ | Cursor CLI | `stop` and `sessionEnd` hooks in `~/.cursor/hooks.json` |
27
+ | Gemini CLI | `AfterAgent` and `SessionEnd` hooks in `~/.gemini/settings.json` (`$GEMINI_CLI_HOME` moves it); Gemini CLI runs hooks in trusted folders |
28
+ | Cortex Code | the app's git plugin (`cortex plugin install <APP>.PLUGINS.AGENTLEDGER`); Snowflake records its sessions already |
29
+
30
+ After each turn the hook queues the session and returns at once. A background process loads it
31
+ into the app over your own connection, at most every two minutes per agent, so a session is
32
+ always stored as yours. Logs are in `~/.cache/agentledger/`.
33
+
34
+ ## Requirements
35
+
36
+ - A Snowflake connection in `~/.snowflake/connections.toml` that works without a prompt (key
37
+ pair or cached SSO), whose role an admin has granted the reporter role:
38
+ `GRANT APPLICATION ROLE AGENTLEDGER.APP_REPORTER TO ROLE <role>;`
39
+ - Python 3.10 or later.
40
+
41
+ ## Options
42
+
43
+ ```bash
44
+ agentledger install --connection work --role DEVELOPER # pick the connection and role
45
+ agentledger install --only ~/work # report only sessions in these folders
46
+ agentledger install --app AGENTLEDGER_PROD # the app's name, if it isn't AGENTLEDGER
47
+ agentledger status # what's set up, and each agent's latest report
48
+ agentledger upload claude ~/.claude/projects/*/*.jsonl # report earlier sessions
49
+ agentledger upload gemini ~/.gemini/tmp/*/chats/*.jsonl
50
+ agentledger uninstall # remove the hooks and the plugin
51
+ ```
52
+
53
+ Settings live in `~/.snowflake/agentledger.json`, which the Cortex Code plugin reads too.
@@ -0,0 +1,45 @@
1
+ # agentledger
2
+
3
+ Reports your Claude Code, Cursor CLI, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake
4
+ Native App, so your team sees agent cost, prompts and tool use per developer, repo, branch and
5
+ pull request.
6
+
7
+ ```bash
8
+ uv tool install agentledger-cli # or: pipx install agentledger-cli
9
+ agentledger install
10
+ ```
11
+
12
+ `install` checks that your Snowflake connection can report to the app, then sets up every agent
13
+ it finds:
14
+
15
+ | Agent | What it adds |
16
+ |---|---|
17
+ | Claude Code | `Stop` and `SessionEnd` hooks in `~/.claude/settings.json` |
18
+ | Cursor CLI | `stop` and `sessionEnd` hooks in `~/.cursor/hooks.json` |
19
+ | Gemini CLI | `AfterAgent` and `SessionEnd` hooks in `~/.gemini/settings.json` (`$GEMINI_CLI_HOME` moves it); Gemini CLI runs hooks in trusted folders |
20
+ | Cortex Code | the app's git plugin (`cortex plugin install <APP>.PLUGINS.AGENTLEDGER`); Snowflake records its sessions already |
21
+
22
+ After each turn the hook queues the session and returns at once. A background process loads it
23
+ into the app over your own connection, at most every two minutes per agent, so a session is
24
+ always stored as yours. Logs are in `~/.cache/agentledger/`.
25
+
26
+ ## Requirements
27
+
28
+ - A Snowflake connection in `~/.snowflake/connections.toml` that works without a prompt (key
29
+ pair or cached SSO), whose role an admin has granted the reporter role:
30
+ `GRANT APPLICATION ROLE AGENTLEDGER.APP_REPORTER TO ROLE <role>;`
31
+ - Python 3.10 or later.
32
+
33
+ ## Options
34
+
35
+ ```bash
36
+ agentledger install --connection work --role DEVELOPER # pick the connection and role
37
+ agentledger install --only ~/work # report only sessions in these folders
38
+ agentledger install --app AGENTLEDGER_PROD # the app's name, if it isn't AGENTLEDGER
39
+ agentledger status # what's set up, and each agent's latest report
40
+ agentledger upload claude ~/.claude/projects/*/*.jsonl # report earlier sessions
41
+ agentledger upload gemini ~/.gemini/tmp/*/chats/*.jsonl
42
+ agentledger uninstall # remove the hooks and the plugin
43
+ ```
44
+
45
+ Settings live in `~/.snowflake/agentledger.json`, which the Cortex Code plugin reads too.
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "agentledger-cli"
3
+ dynamic = ["version"]
4
+ description = "Report Claude Code, Cursor, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake Native App."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = ["snowflake-connector-python>=3.12"]
8
+
9
+ [project.scripts]
10
+ agentledger = "agentledger.cli:main"
11
+
12
+ # The distribution is agentledger-cli (agent-ledger on PyPI is someone else's); the module and the
13
+ # command stay agentledger.
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/agentledger"]
16
+
17
+ [tool.hatch.version]
18
+ path = "src/agentledger/__init__.py"
19
+
20
+ # src/agentledger/git_context.py links to the Cortex Code plugin's hook, which the app also ships;
21
+ # the source distribution carries the file itself.
22
+ [tool.hatch.build.targets.sdist]
23
+ exclude = ["src/agentledger/git_context.py"]
24
+
25
+ [tool.hatch.build.targets.sdist.force-include]
26
+ "../plugin/scripts/git_context.py" = "src/agentledger/git_context.py"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
@@ -0,0 +1,2 @@
1
+ """Report coding-agent sessions to the AgentLedger Snowflake Native App."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,8 @@
1
+ """The agents agentledger sets up. Each module names itself (ID, NAME) and can tell whether the
2
+ agent is on this machine (detected), whether it's set up (hooked), and install / uninstall
3
+ itself. Agents whose sessions are loaded from the machine also turn a hook call into a job
4
+ (job), find their newest session (newest), and read a session into the app's shape (payload)."""
5
+ from . import claude, cortex, cursor, gemini
6
+
7
+ AGENTS = {agent.ID: agent for agent in (claude, cursor, gemini, cortex)}
8
+ REPORTERS = {id: agent for id, agent in AGENTS.items() if hasattr(agent, "payload")}
@@ -0,0 +1,258 @@
1
+ """Claude Code sessions, hooked from ~/.claude/settings.json (Stop and SessionEnd).
2
+
3
+ The transcript is ~/.claude/projects/<project>/<session id>.jsonl. Claude Code writes each API
4
+ response as several lines (thinking, text, tool calls) sharing one message id, with the final
5
+ usage on the last of them; each response becomes one of the app's model calls, with its model,
6
+ token counts and timing. A prompt the developer typed starts a turn (step 0). Background-task
7
+ notices and Claude Code's own meta messages continue the current turn, and local slash commands
8
+ and interrupts are left out. Tool calls keep their arguments and results (clipped); names are
9
+ mapped to the ones the trace's file cards understand, Read results are renumbered in Cortex
10
+ Code's "12... text" form, and Edit results get the "Edit location" line Cortex Code's carry.
11
+ Thinking is not loaded, and <system-reminder> blocks and client harness context (T3 Code's
12
+ attachment notes) are removed from prompts. The session is attributed to the git repository its
13
+ tool calls touched most, else each prompt to the folder it ran in.
14
+ """
15
+ import json
16
+ import os
17
+ import re
18
+ from collections import OrderedDict
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
+ from .. import hooks
23
+ from ..repo import PATH_ARGS, repo_of_paths, repo_state, toplevel
24
+ from ..trace import RESULT_CHARS, clip, clip_args, request
25
+
26
+ ID, NAME = "claude", "Claude Code"
27
+ HOME = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude").expanduser()
28
+ SETTINGS = HOME / "settings.json"
29
+ PROJECTS = HOME / "projects"
30
+ EVENTS = ("Stop", "SessionEnd")
31
+
32
+ TOOL_NAMES = {"Read": "read", "Edit": "edit", "Write": "write", "Bash": "bash", "Grep": "grep", "Glob": "glob",
33
+ "Skill": "skill", "Agent": "task", "Task": "task", "WebFetch": "web_fetch", "WebSearch": "web_search",
34
+ "AskUserQuestion": "ask_user_question", "NotebookEdit": "notebook_edit", "TodoWrite": "todo_write"}
35
+ READ_LINES = 400
36
+ REMINDER = re.compile(r"<system-reminder>.*?</system-reminder>\s*", re.S)
37
+ # Context a client harness adds around the typed prompt (T3 Code's attachment metadata).
38
+ HARNESS = re.compile(r"<t3_context\b.*?</t3_context>\s*|^\[Attached image .*? is saved at: .*?\]\s*$", re.S | re.M)
39
+ CLAUDE_READ_LINE = re.compile(r"^\s*(\d+)\t(.*)$")
40
+ # User messages that aren't prompts to the agent: local slash commands and their output, interrupts.
41
+ NOT_PROMPTS = ("<command-name>", "<local-command-stdout>", "<local-command-caveat>", "[Request interrupted by user")
42
+
43
+
44
+ def detected():
45
+ return HOME.is_dir()
46
+
47
+
48
+ def hooked():
49
+ return hooks.present(SETTINGS, nested=True)
50
+
51
+
52
+ def install(command, config):
53
+ if hooks.install(SETTINGS, EVENTS, command, nested=True):
54
+ return f"hook added to {SETTINGS}"
55
+ return None
56
+
57
+
58
+ def uninstall(config):
59
+ return hooks.remove(SETTINGS, nested=True)
60
+
61
+
62
+ def job(hook):
63
+ """The session a Stop / SessionEnd hook is about, or None."""
64
+ transcript = Path(hook.get("transcript_path") or "").expanduser().resolve()
65
+ if transcript.suffix != ".jsonl" or PROJECTS.resolve() not in transcript.parents or not transcript.is_file():
66
+ return None
67
+ cwd = hook.get("cwd") if isinstance(hook.get("cwd"), str) else None
68
+ return {"path": str(transcript), "label": transcript.stem, "folders": [cwd] if cwd else [], "folder": cwd}
69
+
70
+
71
+ def newest():
72
+ return max(PROJECTS.glob("*/*.jsonl"), key=lambda p: p.stat().st_mtime, default=None)
73
+
74
+
75
+ def _ts(value):
76
+ """Transcript timestamps (2026-10-09T00:50:29.548Z) as the naive UTC text Snowflake parses."""
77
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
78
+
79
+
80
+ def _text(content):
81
+ """Text of a message's content: a string, or its text blocks; images become "[image]" only when
82
+ there's no text (a prompt names its own attachments)."""
83
+ if isinstance(content, str):
84
+ return content
85
+ blocks = [b for b in content if isinstance(b, dict)] if isinstance(content, list) else []
86
+ texts = [b.get("text", "") for b in blocks if b.get("type") == "text"]
87
+ return "\n".join(texts) if any(t.strip() for t in texts) else "\n".join(
88
+ "[image]" for b in blocks if b.get("type") == "image")
89
+
90
+
91
+ def prompt_of(entry):
92
+ """The prompt a user line carries, or None when it isn't one the developer typed."""
93
+ if entry.get("isMeta") or entry.get("turnOrigin") == "task_notification":
94
+ return None
95
+ content = entry["message"].get("content")
96
+ if isinstance(content, list) and any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content):
97
+ return None
98
+ text = HARNESS.sub("", REMINDER.sub("", _text(content))).strip()
99
+ if not text or text.startswith(NOT_PROMPTS) or text.startswith("<task-notification>"):
100
+ return None
101
+ return text
102
+
103
+
104
+ def edit_location(patch, old_string, new_string):
105
+ """Where an Edit's new text landed, from the structured patch Claude Code keeps beside the result,
106
+ in the words Cortex Code's edit results use ("Edit location: lines 12-16"), so the trace's diff
107
+ card can number its lines. None without a patch."""
108
+ old = (old_string or "").split("\n")
109
+ skip = next((i for i, line in enumerate(old) if line.strip()), 0) # leading blank lines
110
+ for hunk in patch or []:
111
+ n = hunk.get("oldStart")
112
+ for line in hunk.get("lines") or []:
113
+ if line[:1] not in (" ", "-"):
114
+ continue
115
+ if n is not None and line[1:] == old[skip]:
116
+ start = n - skip
117
+ return f"Edit location: lines {start}-{start + max(len((new_string or '').split(chr(10))), 1) - 1}"
118
+ n = None if n is None else n + 1
119
+ return None
120
+
121
+
122
+ def _result(name, block, tool_result=None, args=None):
123
+ text = _text(block.get("content"))
124
+ if name == "edit" and isinstance(tool_result, dict):
125
+ if where := edit_location(tool_result.get("structuredPatch"), (args or {}).get("old_string"),
126
+ (args or {}).get("new_string")):
127
+ text = f"{text}\n{where}"
128
+ if name == "read":
129
+ lines = text.splitlines()[:READ_LINES]
130
+ numbered = [CLAUDE_READ_LINE.match(line) for line in lines]
131
+ if numbered and all(numbered):
132
+ text = "\n".join(f"{m.group(1)}... {m.group(2)}" for m in numbered)
133
+ # The trace marks a tool call failed when its result reads as an error.
134
+ if block.get("is_error") and not text.lstrip().lower().startswith("error"):
135
+ text = "Error: " + text
136
+ return clip(text, RESULT_CHARS)
137
+
138
+
139
+ def model_calls(entries):
140
+ """One dict per API response on the main thread, in order: prompt (for turn starts), step,
141
+ model, usage, response text, tool calls, start (when its input was sent) and end."""
142
+ calls = OrderedDict()
143
+ results = {}
144
+ last_input, step, pending_prompt = None, 0, None
145
+ for e in entries:
146
+ if e.get("isSidechain"):
147
+ continue
148
+ kind = e.get("type")
149
+ if kind == "user":
150
+ last_input = e["timestamp"]
151
+ content = e["message"].get("content")
152
+ for block in content if isinstance(content, list) else []:
153
+ if isinstance(block, dict) and block.get("type") == "tool_result":
154
+ # Claude Code writes each result on its own line, with its details (an edit's
155
+ # structured patch) in toolUseResult.
156
+ results[block.get("tool_use_id")] = (block, e.get("toolUseResult"))
157
+ if (prompt := prompt_of(e)) is not None:
158
+ # Prompts sent before the agent answered (e.g. after an interrupt) open one turn together.
159
+ pending_prompt = prompt if pending_prompt is None else f"{pending_prompt}\n\n{prompt}"
160
+ step = 0
161
+ elif kind == "assistant":
162
+ m = e["message"]
163
+ if m.get("model") == "<synthetic>" or not m.get("id"):
164
+ continue
165
+ call = calls.get(m["id"])
166
+ if call is None:
167
+ call = calls[m["id"]] = {"prompt": pending_prompt, "step": step, "model": m.get("model"),
168
+ "start": last_input or e["timestamp"], "end": e["timestamp"],
169
+ "usage": {}, "texts": [], "tools": [], "error": e.get("isApiErrorMessage")}
170
+ pending_prompt, step = None, step + 1
171
+ call["end"] = e["timestamp"]
172
+ usage = m.get("usage") or {}
173
+ if (usage.get("output_tokens") or 0) >= (call["usage"].get("output_tokens") or 0):
174
+ call["usage"] = usage
175
+ for block in m.get("content") or []:
176
+ if block.get("type") == "text" and block.get("text", "").strip():
177
+ call["texts"].append(block["text"].strip())
178
+ elif block.get("type") == "tool_use":
179
+ name = block.get("name") or "tool"
180
+ call["tools"].append({"id": block.get("id"), "name": TOOL_NAMES.get(name, name.lower()),
181
+ "args": clip_args(block.get("input"))})
182
+ for call in calls.values():
183
+ for tool in call["tools"]:
184
+ block = results.get(tool["id"])
185
+ tool["result"] = _result(tool["name"], block[0], block[1], tool["args"]) if block else None
186
+ return list(calls.values())
187
+
188
+
189
+ def to_requests(calls, session_id):
190
+ requests = []
191
+ for i, call in enumerate(calls):
192
+ u = call["usage"]
193
+ cache_read, cache_write = u.get("cache_read_input_tokens") or 0, u.get("cache_creation_input_tokens") or 0
194
+ requests.append(request(
195
+ f"claude-{session_id[:8]}-{i}", call["step"], _ts(call["start"]), _ts(call["end"]),
196
+ call["prompt"] if call["step"] == 0 else None, "\n\n".join(call["texts"]) or None, call["tools"],
197
+ model=call["model"],
198
+ status="ERROR" if call["error"] else "SUCCESS",
199
+ # The app's input count includes cached input, as Snowflake's telemetry does.
200
+ input_tokens=(u.get("input_tokens") or 0) + cache_read + cache_write,
201
+ output_tokens=u.get("output_tokens") or 0,
202
+ cache_read_tokens=cache_read,
203
+ cache_write_tokens=cache_write))
204
+ return requests
205
+
206
+
207
+ def git_reports(entries, repo_dir=None):
208
+ """One report per prompt, like the git hook's UserPromptSubmit: the git state of the folder the
209
+ prompt ran in (or of repo_dir), with the running prompt count. The branch is the one Claude Code
210
+ recorded with the prompt when it ran in that repository, so work on earlier branches keeps its
211
+ branch; the commit and dirty flag are known only for the branch checked out now."""
212
+ reports, states, tops = [], {}, {}
213
+ for e in entries:
214
+ if e.get("type") != "user" or e.get("isSidechain") or prompt_of(e) is None:
215
+ continue
216
+ folder = repo_dir or e.get("cwd")
217
+ if folder not in states:
218
+ states[folder] = repo_state(folder)
219
+ state = states[folder]
220
+ if not state:
221
+ continue
222
+ report = {"observed_at": _ts(e["timestamp"]), "prompt_seq": len(reports) + 1, **state}
223
+ cwd, recorded = e.get("cwd"), e.get("gitBranch")
224
+ if recorded and cwd:
225
+ for path in {cwd, folder}:
226
+ if path not in tops:
227
+ tops[path] = toplevel(path)
228
+ if tops[cwd] and tops[cwd] == tops[folder]:
229
+ branch = None if recorded == "HEAD" else recorded
230
+ if branch != state["branch"]:
231
+ report.update(branch=branch, head_sha=None, is_dirty=None)
232
+ reports.append(report)
233
+ return reports
234
+
235
+
236
+ def payload(path, repo_dir=None, folder=None):
237
+ """The session for core.ingest_external_session. `repo_dir` pins its repository. Claude Code
238
+ records each prompt's folder itself, so `folder` isn't needed."""
239
+ entries = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
240
+ entries = [e for e in entries if e.get("type") in ("user", "assistant") and e.get("timestamp")]
241
+ session_id = next(e["sessionId"] for e in entries if e.get("sessionId"))
242
+ calls = model_calls(entries)
243
+ models = [c["model"] for c in calls if c["model"]]
244
+ repo_dir = repo_dir or repo_of_paths(
245
+ (block.get("input") or {}).get(key)
246
+ for e in entries if e.get("type") == "assistant" and isinstance((e.get("message") or {}).get("content"), list)
247
+ for block in e["message"]["content"] if isinstance(block, dict) and block.get("type") == "tool_use"
248
+ for key in PATH_ARGS)
249
+ return {
250
+ "session_id": session_id,
251
+ "agent": "claude-code",
252
+ "agent_name": NAME,
253
+ "model": max(set(models), key=models.count) if models else None,
254
+ "started_at": _ts(entries[0]["timestamp"]),
255
+ "prompts": sum(1 for c in calls if c["step"] == 0 and c["prompt"]),
256
+ "git_reports": git_reports(entries, repo_dir),
257
+ "requests": to_requests(calls, session_id),
258
+ }
@@ -0,0 +1,42 @@
1
+ """Cortex Code. Its sessions reach the app through Snowflake's own telemetry; what it needs on the
2
+ developer machine is the git hook plugin the app publishes as a Cortex Extension."""
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+
7
+ ID, NAME = "cortex", "Cortex Code"
8
+ PLUGIN = "agentledger"
9
+
10
+
11
+ class SetupError(Exception):
12
+ pass
13
+
14
+
15
+ def _plugin(*args):
16
+ return subprocess.run(["cortex", "plugin", *args], capture_output=True, text=True, timeout=180)
17
+
18
+
19
+ def detected():
20
+ return shutil.which("cortex") is not None
21
+
22
+
23
+ def hooked():
24
+ return bool(re.search(rf"^\s*{PLUGIN} v\S+ \[enabled", _plugin("list").stdout, re.M))
25
+
26
+
27
+ def install(command, config):
28
+ if hooked():
29
+ return None
30
+ fqn = f"{config['app']}.PLUGINS.AGENTLEDGER"
31
+ out = _plugin("install", fqn, *(["-c", config["connection"]] if config["connection"] else []))
32
+ if out.returncode:
33
+ reason = (out.stderr or out.stdout).strip().splitlines()[-1:] or ["no output"]
34
+ raise SetupError(f"couldn't install the git plugin {fqn} ({reason[0]}). An app admin publishes it "
35
+ "from the dashboard's Setup page, under Git integration.")
36
+ return f"git plugin {fqn} installed"
37
+
38
+
39
+ def uninstall(config):
40
+ if not re.search(rf"^\s*{PLUGIN} v", _plugin("list").stdout, re.M):
41
+ return False
42
+ return _plugin("uninstall", PLUGIN).returncode == 0