agentledger-cli 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.
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ """AgentLedger hook: tell the Snowflake app which repo and branch a coding-agent
3
+ session is working on, so its cost can be attributed to branches and pull requests.
4
+
5
+ Runs on SessionStart, UserPromptSubmit and SubagentStart (Cortex Code and Claude Code hook
6
+ format). Every prompt is reported with a running count, so the app can count turns exactly;
7
+ SessionStart is reported only when the git state changed. For SubagentStart it reports the
8
+ sub-agent's session with its parent, because Cortex Code's telemetry records sub-agents as
9
+ separate sessions without linking them.
10
+ It reads the hook payload from stdin, collects git state for the session's working
11
+ directory, and, when the state changed, calls <app>.core.register_git_context in the
12
+ background through the Snowflake CLI. The hook itself returns immediately and never fails
13
+ the agent: any problem is logged to ~/.cache/agentledger/hook.log.
14
+
15
+ No configuration is needed when the app publishes this plugin (it writes the app name to
16
+ config.json in the plugin root) and the agent is Cortex Code (the session's Snowflake
17
+ connection is read from its metadata file). Otherwise, or to override, use
18
+ ~/.snowflake/agentledger.json:
19
+ {"app": "AGENTLEDGER", "connection": "my_conn", "role": "DEVELOPER"}
20
+ Environment variables AGENTLEDGER_APP / AGENTLEDGER_CONNECTION / AGENTLEDGER_ROLE override both.
21
+ """
22
+ import json
23
+ import os
24
+ import re
25
+ import subprocess
26
+ import sys
27
+ from datetime import datetime, timezone
28
+
29
+ STATE_DIR = os.path.expanduser("~/.cache/agentledger")
30
+ CONFIG_PATH = os.path.expanduser("~/.snowflake/agentledger.json")
31
+ PLUGIN_CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
32
+ LOG_PATH = os.path.join(STATE_DIR, "hook.log")
33
+ MAX_LOG_BYTES = 256 * 1024
34
+ IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$")
35
+
36
+
37
+ def log(message):
38
+ try:
39
+ os.makedirs(STATE_DIR, exist_ok=True)
40
+ if os.path.exists(LOG_PATH) and os.path.getsize(LOG_PATH) > MAX_LOG_BYTES:
41
+ os.replace(LOG_PATH, LOG_PATH + ".1")
42
+ with open(LOG_PATH, "a") as f:
43
+ f.write(f"{datetime.now(timezone.utc).isoformat()} {message}\n")
44
+ except OSError:
45
+ pass
46
+
47
+
48
+ def read_json(path):
49
+ try:
50
+ with open(path) as f:
51
+ data = json.load(f)
52
+ return data if isinstance(data, dict) else {}
53
+ except (OSError, ValueError):
54
+ return {}
55
+
56
+
57
+ def load_config(session_meta=None):
58
+ """Defaults < plugin config.json < session metadata < user config file < environment."""
59
+ config = {"app": "AGENTLEDGER", "connection": None, "role": None}
60
+ sources = [
61
+ read_json(PLUGIN_CONFIG_PATH),
62
+ {"connection": (session_meta or {}).get("connection_name")},
63
+ read_json(CONFIG_PATH),
64
+ ]
65
+ for source in sources:
66
+ config.update({k: v for k, v in source.items() if k in config and v})
67
+ for key in config:
68
+ config[key] = os.environ.get(f"AGENTLEDGER_{key.upper()}", config[key])
69
+ return config
70
+
71
+
72
+ def session_metadata(transcript_path):
73
+ """Cortex Code's per-session metadata (connection_name, git_branch, ...), if that's what
74
+ the hook's transcript_path points at. Claude Code transcripts are JSONL and yield {}."""
75
+ if not transcript_path or not transcript_path.endswith(".json"):
76
+ return {}
77
+ return read_json(transcript_path)
78
+
79
+
80
+ def parse_remote(url):
81
+ """Return (host, slug) for a git remote URL, dropping any credentials and the port.
82
+
83
+ Handles scp-style (git@host:group/repo.git), ssh://, https:// and git:// remotes, and
84
+ nested GitLab groups (host/group/subgroup/repo).
85
+ """
86
+ if not url:
87
+ return None, None
88
+ url = url.strip()
89
+ scp = re.match(r"^(?:[^@/]+@)?([^:/]+):(?!//)(.+)$", url)
90
+ if scp and "://" not in url:
91
+ host, path = scp.group(1), scp.group(2)
92
+ else:
93
+ m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://(?:[^@/]*@)?([^/:]+)(?::\d+)?/(.+)$", url)
94
+ if not m:
95
+ return None, None
96
+ host, path = m.group(1), m.group(2)
97
+ path = re.sub(r"\.git/?$", "", path.strip("/"))
98
+ if not path or "/" not in path:
99
+ return host.lower(), None
100
+ return host.lower(), path.lower()
101
+
102
+
103
+ def git(cwd, *args):
104
+ out = subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True, timeout=5)
105
+ return out.stdout.strip() if out.returncode == 0 else None
106
+
107
+
108
+ def git_state(cwd):
109
+ if not git(cwd, "rev-parse", "--is-inside-work-tree"):
110
+ return None
111
+ host, slug = parse_remote(git(cwd, "config", "--get", "remote.origin.url"))
112
+ branch = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
113
+ return {
114
+ "repo_host": host,
115
+ "repo_slug": slug,
116
+ "branch": None if branch == "HEAD" else branch, # detached HEAD
117
+ "head_sha": git(cwd, "rev-parse", "HEAD"),
118
+ "is_dirty": bool(git(cwd, "status", "--porcelain", "--untracked-files=no")),
119
+ }
120
+
121
+
122
+ def sql_string(value):
123
+ """Quote a value as a Snowflake single-quoted string literal."""
124
+ return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
125
+
126
+
127
+ def build_call(app, payload):
128
+ if not IDENTIFIER.match(app):
129
+ raise ValueError(f"invalid app name: {app!r}")
130
+ return f"CALL {app}.core.register_git_context(PARSE_JSON({sql_string(json.dumps(payload))}))"
131
+
132
+
133
+ def state_path(session_id):
134
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session_id)[:128]
135
+ return os.path.join(STATE_DIR, f"session-{safe}.json")
136
+
137
+
138
+ def next_prompt(session_id):
139
+ """Count this session's prompts (1, 2, ...), so the app can count turns exactly."""
140
+ path = state_path(session_id)[:-len(".json")] + ".prompts"
141
+ try:
142
+ with open(path) as f:
143
+ n = int(f.read().strip() or 0) + 1
144
+ except (OSError, ValueError):
145
+ n = 1
146
+ os.makedirs(STATE_DIR, exist_ok=True)
147
+ with open(path, "w") as f:
148
+ f.write(str(n))
149
+ return n
150
+
151
+
152
+ def changed_since_last_report(session_id, state):
153
+ """True if this session hasn't reported this repo/branch/commit yet."""
154
+ key = {k: state[k] for k in ("repo_host", "repo_slug", "branch", "head_sha")}
155
+ path = state_path(session_id)
156
+ try:
157
+ with open(path) as f:
158
+ if json.load(f) == key:
159
+ return False
160
+ except (OSError, ValueError):
161
+ pass
162
+ os.makedirs(STATE_DIR, exist_ok=True)
163
+ with open(path, "w") as f:
164
+ json.dump(key, f)
165
+ return True
166
+
167
+
168
+ def detect_agent(transcript_path):
169
+ if transcript_path and "/.snowflake/cortex/" in transcript_path:
170
+ return "cortex-code"
171
+ if transcript_path and "/.claude/" in transcript_path:
172
+ return "claude-code"
173
+ return "claude-code" if "CLAUDE_PROJECT_DIR" in os.environ else "cortex-code"
174
+
175
+
176
+ def send(config, sql):
177
+ cmd = ["snow", "sql", "--silent", "--enable-templating", "NONE", "-q", sql]
178
+ if config["connection"]:
179
+ cmd += ["-c", config["connection"]]
180
+ if config["role"]:
181
+ cmd += ["--role", config["role"]]
182
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
183
+ if out.returncode != 0 or "ok" not in out.stdout:
184
+ log(f"register_git_context failed ({out.returncode}): {(out.stderr or out.stdout).strip()[:500]}")
185
+
186
+
187
+ def main():
188
+ if len(sys.argv) == 3 and sys.argv[1] == "--send":
189
+ send(load_config(), sys.argv[2])
190
+ return
191
+
192
+ try:
193
+ hook = json.load(sys.stdin)
194
+ except ValueError:
195
+ return
196
+ session_id = hook.get("session_id")
197
+ event = hook.get("hook_event_name")
198
+ parent = None
199
+ if event == "SubagentStart":
200
+ parent = hook.get("parent_session_id") or session_id
201
+ session_id = hook.get("agent_id")
202
+ cwd = hook.get("cwd") or os.environ.get("CORTEX_PROJECT_DIR") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
203
+ if not session_id:
204
+ return
205
+
206
+ state = git_state(cwd)
207
+ prompt_seq = next_prompt(session_id) if event == "UserPromptSubmit" else None
208
+ changed = state is not None and changed_since_last_report(session_id, state)
209
+ if not (parent or prompt_seq or changed):
210
+ return
211
+ # Sub-agents and prompts are reported even outside a repo (the app counts turns from prompts).
212
+ state = state or {k: None for k in ("repo_host", "repo_slug", "branch", "head_sha", "is_dirty")}
213
+
214
+ transcript_path = hook.get("transcript_path")
215
+ config = load_config(session_metadata(transcript_path))
216
+ payload = {
217
+ "session_id": session_id,
218
+ "event": event,
219
+ "agent": detect_agent(transcript_path),
220
+ "observed_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f"),
221
+ **state,
222
+ }
223
+ if parent:
224
+ payload.update(parent_session_id=parent, agent_type=hook.get("agent_type"))
225
+ if prompt_seq:
226
+ payload["prompt_seq"] = prompt_seq
227
+ # Detach so the agent never waits on the Snowflake round trip. The resolved connection
228
+ # and role go through the environment, since the child has no hook payload.
229
+ env = dict(os.environ)
230
+ env.update({f"AGENTLEDGER_{k.upper()}": v for k, v in config.items() if v})
231
+ subprocess.Popen(
232
+ [sys.executable, os.path.abspath(__file__), "--send", build_call(config["app"], payload)],
233
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
234
+ start_new_session=True, env=env,
235
+ )
236
+
237
+
238
+ if __name__ == "__main__":
239
+ try:
240
+ main()
241
+ except Exception as e: # a broken hook must never break the agent
242
+ log(f"hook error: {e!r}")
agentledger/hooks.py ADDED
@@ -0,0 +1,94 @@
1
+ """Adding and removing the agentledger hook in an agent's JSON hook file.
2
+
3
+ Cursor's ~/.cursor/hooks.json lists commands per event:
4
+ {"version": 1, "hooks": {"stop": [{"command": "..."}]}}
5
+ Claude Code's settings.json nests them in matcher groups:
6
+ {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "..."}]}]}}
7
+ Only entries that run agentledger are touched; everything else in the file is kept as it was.
8
+ """
9
+ import shlex
10
+ from pathlib import Path
11
+
12
+ from .config import read_json, write_json
13
+
14
+ # The repo's own hook script, before the agentledger CLI. Replaced on install.
15
+ LEGACY = "session_hook.py"
16
+
17
+
18
+ def is_ours(command, legacy=True):
19
+ try:
20
+ words = shlex.split(command) if isinstance(command, str) else []
21
+ except ValueError:
22
+ return False
23
+ names = ("agentledger", LEGACY) if legacy else ("agentledger",)
24
+ return any(Path(w).name in names for w in words[:2])
25
+
26
+
27
+ def _commands(entry, nested):
28
+ if not isinstance(entry, dict):
29
+ return []
30
+ if not nested:
31
+ return [entry.get("command")]
32
+ return [h.get("command") for h in entry.get("hooks") or [] if isinstance(h, dict)]
33
+
34
+
35
+ def _without_ours(entry, nested):
36
+ """The entry with our commands taken out, or None when nothing else is left in it."""
37
+ if not any(is_ours(c) for c in _commands(entry, nested)):
38
+ return entry
39
+ if not nested:
40
+ return None
41
+ rest = [h for h in entry["hooks"] if not (isinstance(h, dict) and is_ours(h.get("command")))]
42
+ return {**entry, "hooks": rest} if rest else None
43
+
44
+
45
+ def _strip(hooks, nested):
46
+ for event in list(hooks):
47
+ if not isinstance(hooks[event], list):
48
+ continue
49
+ kept = [e for e in (_without_ours(e, nested) for e in hooks[event]) if e is not None]
50
+ if kept:
51
+ hooks[event] = kept
52
+ elif hooks[event]:
53
+ del hooks[event]
54
+
55
+
56
+ def present(path, nested):
57
+ """True when the file runs the current agentledger hook."""
58
+ hooks = read_json(path).get("hooks") or {}
59
+ return any(is_ours(c, legacy=False) for entries in hooks.values() if isinstance(entries, list)
60
+ for entry in entries for c in _commands(entry, nested))
61
+
62
+
63
+ def install(path, events, command, nested):
64
+ """Points `events` at `command`, replacing earlier agentledger entries. Returns whether the
65
+ file changed."""
66
+ data = read_json(path)
67
+ before = repr(data)
68
+ hooks = data.setdefault("hooks", {})
69
+ if not nested:
70
+ data.setdefault("version", 1)
71
+ _strip(hooks, nested)
72
+ for event in events:
73
+ hooks.setdefault(event, []).append(
74
+ {"hooks": [{"type": "command", "command": command}]} if nested else {"command": command})
75
+ if repr(data) == before:
76
+ return False
77
+ write_json(path, data)
78
+ return True
79
+
80
+
81
+ def remove(path, nested):
82
+ """Removes every agentledger entry. Returns whether the file changed."""
83
+ data = read_json(path)
84
+ hooks = data.get("hooks")
85
+ if not isinstance(hooks, dict) or not hooks:
86
+ return False
87
+ before = repr(data)
88
+ _strip(hooks, nested)
89
+ if not hooks:
90
+ del data["hooks"]
91
+ if repr(data) == before:
92
+ return False
93
+ write_json(path, data)
94
+ return True
agentledger/repo.py ADDED
@@ -0,0 +1,96 @@
1
+ """Which git repository a session worked in, and on which branches."""
2
+ import re
3
+ from collections import Counter
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from .git_context import git, git_state
8
+
9
+ # Reflog entries that leave HEAD on another branch (a rebase's own checkouts are transient).
10
+ MOVES = re.compile(r"^(?:checkout: moving from (?P<left>\S+) to |rebase \(finish\): returning to (?:refs/heads/)?)"
11
+ r"(?P<to>\S+)$")
12
+ SHA = re.compile(r"[0-9a-f]{7,40}")
13
+
14
+ # Tool arguments that name a file or folder, across agents.
15
+ PATH_ARGS = ("path", "file_path", "working_directory", "target_directory", "dir_path")
16
+ # A repository at the home folder holds dotfiles, never the project.
17
+ HOME = str(Path.home())
18
+
19
+
20
+ def toplevel(folder):
21
+ top = git(str(folder), "rev-parse", "--show-toplevel") if Path(folder).is_dir() else None
22
+ return None if top == HOME else top
23
+
24
+
25
+ def repo_of_paths(paths):
26
+ """The git repository most of these absolute paths are in, or None."""
27
+ tops, counts = {}, Counter()
28
+ for raw in paths:
29
+ if not isinstance(raw, str) or not raw.startswith("/"):
30
+ continue
31
+ folder = Path(raw) if Path(raw).is_dir() else Path(raw).parent
32
+ if folder not in tops:
33
+ tops[folder] = toplevel(folder)
34
+ if tops[folder]:
35
+ counts[tops[folder]] += 1
36
+ return counts.most_common(1)[0][0] if counts else None
37
+
38
+
39
+ def repo_state(folder):
40
+ """The git state the git hook would report for a folder, or None outside a repository. A
41
+ repository without a remote is named after its folder, on host "local"."""
42
+ top = toplevel(folder) if folder else None
43
+ state = git_state(top) if top else None
44
+ if state and not state["repo_slug"]:
45
+ state["repo_host"], state["repo_slug"] = "local", Path(top).name
46
+ return state
47
+
48
+
49
+ def checkouts(top):
50
+ """When HEAD moved to another branch, oldest first, from the reflog: (naive UTC datetime,
51
+ branch left, branch moved to). A detached HEAD (a commit or a remote branch) is None; so is
52
+ the branch a rebase left, which the reflog doesn't name."""
53
+ log = git(top, "reflog", "--date=unix", "--format=%gd%x09%gs") or ""
54
+ remotes = set((git(top, "for-each-ref", "--format=%(refname:short)", "refs/remotes") or "").split())
55
+
56
+ def branch(name):
57
+ return None if not name or name == "HEAD" or SHA.fullmatch(name) or name in remotes else name
58
+
59
+ moves = []
60
+ for line in log.splitlines():
61
+ stamp, _, subject = line.partition("\t")
62
+ when, move = re.search(r"\{(\d+)\}", stamp), MOVES.match(subject)
63
+ if when and move:
64
+ moves.append((datetime.fromtimestamp(int(when[1]), timezone.utc).replace(tzinfo=None),
65
+ branch(move["left"]), branch(move["to"])))
66
+ return moves[::-1]
67
+
68
+
69
+ def git_reports(folder, started, prompts):
70
+ """Git reports for a session in `folder` whose files don't record branches: the state when it
71
+ started, at each branch checkout since (from the reflog) and at its latest prompt, each with
72
+ the number of prompts sent by then. Times are naive UTC datetimes; the commit and dirty flag
73
+ are known only for the branch checked out now. [] outside a repository."""
74
+ state = repo_state(folder)
75
+ if not state:
76
+ return []
77
+ moves = checkouts(toplevel(folder))
78
+ before, after = [m for m in moves if m[0] <= started], [m for m in moves if m[0] > started]
79
+ if before:
80
+ branch = before[-1][2]
81
+ elif after:
82
+ branch = after[0][1] if after[0][1] else after[0][2]
83
+ else:
84
+ branch = state["branch"]
85
+ points = [(started, branch)] + [(t, to) for t, _, to in after]
86
+ latest = max(prompts[-1] if prompts else started, started)
87
+ if points[-1][0] < latest:
88
+ points.append((latest, points[-1][1]))
89
+ reports = []
90
+ for when, branch in points:
91
+ report = {**state, "observed_at": f"{when:%Y-%m-%d %H:%M:%S}", "prompt_seq": sum(p <= when for p in prompts)}
92
+ if branch != state["branch"]:
93
+ report.update(branch=branch, head_sha=None, is_dirty=None)
94
+ reports.append(report)
95
+ reports[-1]["prompt_seq"] = len(prompts)
96
+ return reports
agentledger/runner.py ADDED
@@ -0,0 +1,92 @@
1
+ """Reporting sessions in the background, so an agent never waits on Snowflake.
2
+
3
+ A hook call queues its session under ~/.cache/agentledger/pending/ and returns. One detached
4
+ process per agent drains the queue, reading each session's file as it is at that moment, so turns
5
+ that end while it works are picked up by its next pass. Passes start at least MIN_INTERVAL
6
+ apart: a long session is reported every couple of minutes rather than after every turn.
7
+ """
8
+ import fcntl
9
+ import json
10
+ import os
11
+ import time
12
+ from datetime import datetime, timezone
13
+
14
+ from .config import STATE
15
+
16
+ PENDING = STATE / "pending"
17
+ MIN_INTERVAL = 120
18
+ MAX_LOG_BYTES = 256 * 1024
19
+
20
+
21
+ def log_path(agent):
22
+ return STATE / f"{agent}-hook.log"
23
+
24
+
25
+ def log(agent, message):
26
+ path = log_path(agent)
27
+ STATE.mkdir(parents=True, exist_ok=True)
28
+ if path.exists() and path.stat().st_size > MAX_LOG_BYTES:
29
+ os.replace(path, path.with_name(path.name + ".1"))
30
+ with path.open("a") as f:
31
+ f.write(f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M:%S} {message}\n")
32
+
33
+
34
+ def enqueue(agent, job):
35
+ PENDING.mkdir(parents=True, exist_ok=True)
36
+ path = PENDING / f"{agent}-{job['label']}.json"
37
+ tmp = path.with_name(f".{path.name}")
38
+ tmp.write_text(json.dumps(job))
39
+ os.replace(tmp, path)
40
+
41
+
42
+ def pending(agent):
43
+ return sorted(PENDING.glob(f"{agent}-*.json"))
44
+
45
+
46
+ def detach(agent):
47
+ """Forks a process outside the agent's session. Returns True in that process, with its output
48
+ going to the agent's log, and False in the caller."""
49
+ if os.fork():
50
+ return False
51
+ os.setsid()
52
+ if os.fork():
53
+ os._exit(0)
54
+ STATE.mkdir(parents=True, exist_ok=True)
55
+ out = os.open(log_path(agent), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
56
+ null = os.open(os.devnull, os.O_RDONLY)
57
+ os.dup2(null, 0)
58
+ os.dup2(out, 1)
59
+ os.dup2(out, 2)
60
+ return True
61
+
62
+
63
+ def _take(agent):
64
+ jobs = []
65
+ for path in pending(agent):
66
+ try:
67
+ jobs.append(json.loads(path.read_text()))
68
+ except (OSError, ValueError):
69
+ pass
70
+ path.unlink(missing_ok=True)
71
+ return jobs
72
+
73
+
74
+ def drain(agent, report):
75
+ """Calls report(jobs) until the queue is empty. Returns at once when another process is
76
+ already draining it; that one picks up what this one queued."""
77
+ STATE.mkdir(parents=True, exist_ok=True)
78
+ last = STATE / f"{agent}-last-pass"
79
+ while pending(agent):
80
+ with open(STATE / f"{agent}.lock", "a+") as lock:
81
+ try:
82
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
83
+ except BlockingIOError:
84
+ return
85
+ while pending(agent):
86
+ since = time.time() - (last.stat().st_mtime if last.exists() else 0)
87
+ if since < MIN_INTERVAL:
88
+ time.sleep(MIN_INTERVAL - since)
89
+ last.touch()
90
+ report(_take(agent))
91
+ # Anything queued after the last check, while the lock was still held, is drained by the
92
+ # next turn of the outer loop.
agentledger/trace.py ADDED
@@ -0,0 +1,43 @@
1
+ """The shape every loader reduces a session to: the model-call rows core.ingest_external_session
2
+ loads, with tool calls packed the way Cortex Code's telemetry packs them."""
3
+ import json
4
+
5
+ RESULT_CHARS = 12000
6
+ ARG_CHARS = 20000
7
+
8
+
9
+ def clip(text, limit):
10
+ text = "" if text is None else str(text)
11
+ return text if len(text) <= limit else f"{text[:limit]}\n… ({len(text) - limit:,} more characters)"
12
+
13
+
14
+ def clip_args(raw, renames=None):
15
+ renames = renames or {}
16
+ return {renames.get(k, k): clip(v, ARG_CHARS) if isinstance(v, str) else v for k, v in (raw or {}).items()}
17
+
18
+
19
+ def request(request_id, step, start, end, query, response, tools, **usage):
20
+ """One model call. `tools` are {"id", "name", "args", "result"}; `usage` is any of model, status
21
+ and the token counts."""
22
+ ids = json.dumps([t["id"] for t in tools])
23
+ names = [t["name"] for t in tools]
24
+ arg_names = json.dumps([",".join(t["args"]) for t in tools])
25
+ arg_values = json.dumps([",".join(json.dumps(v) for v in t["args"].values()) for t in tools])
26
+ return {
27
+ "request_id": request_id,
28
+ "step_number": step,
29
+ "start_ts": start,
30
+ "end_ts": end,
31
+ **usage,
32
+ "query": query,
33
+ "response": response,
34
+ "tool_names": names,
35
+ "selected_ids": ids,
36
+ "selected_names": json.dumps(names),
37
+ "selected_arg_names": arg_names,
38
+ "selected_arg_values": arg_values,
39
+ "executed_ids": ids,
40
+ "executed_arg_names": arg_names,
41
+ "executed_arg_values": arg_values,
42
+ "executed_results": json.dumps([t["result"] for t in tools]),
43
+ }
@@ -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,19 @@
1
+ agentledger/__init__.py,sha256=V7ljJUrzkRr6xRlk9ZDqNFkntCZd5ROK9-BIw4S91Jw,98
2
+ agentledger/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ agentledger/app.py,sha256=KlzLPcKGtzWf_IL1oNFVRaRFZ1wMGgdV8ye4H3dXsfg,1750
4
+ agentledger/cli.py,sha256=cQtIgzVjmJB4c60RjrjYOuF22Y-OMxWp21brNBzdcO0,10200
5
+ agentledger/config.py,sha256=u5MoyOl0Nwt6NJBIQw_8x8DVSLQSEjyy0k2RPA9A0-Y,2421
6
+ agentledger/git_context.py,sha256=CzDQ7rl8XTDKbUJKiAKUM4aHngllsjTz7m0SZqqJN-M,9391
7
+ agentledger/hooks.py,sha256=TiWzxiT4sAjXS9CQyoyaQv_Oc_0ld1QVUH43tsrBhh8,3188
8
+ agentledger/repo.py,sha256=eLqrbmLH2WlMaHiSYOBI9hLGBmOu0LKdavpTt1p1qq0,4164
9
+ agentledger/runner.py,sha256=2YJ2ACkJFQhr6pzeN-fqw_BdgYgRGWb58bi_qDjz-PA,3009
10
+ agentledger/trace.py,sha256=AkFqhNOgZ7c6UEpNru22VUpZ7pUx6ErC7lUBWVW5Hs4,1608
11
+ agentledger/agents/__init__.py,sha256=BnuXvKsIQah3zkAwh7hpgAT-n-CQmA6FFcuIaoUPvIg,579
12
+ agentledger/agents/claude.py,sha256=5d6C1Df7dLvwQlqvwKEc0L4zmJTR3wUverR0u3ZMY1Y,12908
13
+ agentledger/agents/cortex.py,sha256=VDwT-doMUDzxFke1jpEded0axrUCOeVLqxlh7e9A_dw,1351
14
+ agentledger/agents/cursor.py,sha256=kB9L9cO85vK57MP3JJFJJ80xN5jSBXt8VFKANMN4QvA,10058
15
+ agentledger/agents/gemini.py,sha256=2yFI527xAO8cQQrtVrPnSfe1oBS--4GsJKzC-MDEtVY,12391
16
+ agentledger_cli-0.1.0.dist-info/METADATA,sha256=jHxoSXiDXDJHX9YUe_NGMibOO2xtKgNI2OWYpgtzSPI,2455
17
+ agentledger_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
+ agentledger_cli-0.1.0.dist-info/entry_points.txt,sha256=BVWEN762nxZkUGqr-uD0F3iRPK4tWjrngVf9CEJI2Qc,53
19
+ agentledger_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentledger = agentledger.cli:main