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,251 @@
1
+ """Gemini CLI sessions, hooked from ~/.gemini/settings.json (AfterAgent and SessionEnd).
2
+
3
+ The session is ~/.gemini/tmp/<project>/chats/session-<start>-<id>.jsonl (older releases wrote one
4
+ .json file); $GEMINI_CLI_HOME moves ~/.gemini. Gemini CLI appends records to it: the session's
5
+ metadata, each message (written again under the same id when it changes), "$set" updates and
6
+ "$rewindTo". A "$set" with "messages" replaces the conversation after compression and "$rewindTo"
7
+ drops a rewound tail, but those model calls still ran, so the loader keeps every message it has
8
+ seen, each at its latest version; messages that first appear in such a checkpoint (summaries,
9
+ Gemini CLI's session context) are context rather than activity and are left out.
10
+
11
+ Each gemini message is one of the app's model calls, with its model and token counts; it's
12
+ written when the response finishes, so a call runs from the message before it to its own
13
+ timestamp. A prompt the developer typed starts a turn (step 0); tool results come back as user
14
+ messages, and Gemini CLI's own context and slash commands aren't prompts. Subagents keep their
15
+ conversations in chats/<session id>/ and aren't loaded. Tool names are mapped to the ones the
16
+ trace's file cards understand, read_file results are numbered in Cortex Code's "12... text" form,
17
+ and replace results get Cortex Code's "Edit location" line. Thoughts are not loaded. The session is
18
+ attributed to the git repository its tool calls touched most, else to the folder it ran in, and to
19
+ the branches the repository's reflog shows checked out while it ran.
20
+ """
21
+ import json
22
+ import os
23
+ import re
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ from .. import hooks
28
+ from ..repo import PATH_ARGS, git_reports, repo_of_paths
29
+ from ..trace import RESULT_CHARS, clip, clip_args, request
30
+
31
+ ID, NAME = "gemini", "Gemini CLI"
32
+ HOME = Path(os.environ.get("GEMINI_CLI_HOME") or Path.home()).expanduser() / ".gemini"
33
+ SETTINGS = HOME / "settings.json"
34
+ TMP = HOME / "tmp"
35
+ EVENTS = ("AfterAgent", "SessionEnd")
36
+
37
+ TOOL_NAMES = {"read_file": "read", "write_file": "write", "replace": "edit", "run_shell_command": "bash",
38
+ "grep_search": "grep", "search_file_content": "grep", "glob": "glob", "web_fetch": "web_fetch",
39
+ "google_web_search": "web_search", "write_todos": "todo_write", "invoke_agent": "task",
40
+ "activate_skill": "skill", "ask_user": "ask_user_question"}
41
+ # Older releases named read_file's path absolute_path.
42
+ ARG_RENAMES = {"absolute_path": "file_path"}
43
+ READ_LINES = 400
44
+ # Gemini CLI's isIgnoredUserContent: slash commands, help, and the context it adds itself.
45
+ NOT_PROMPTS = ("/", "?", "<session_context>", "<hook_context>")
46
+ # read_file's header when it returns part of a file.
47
+ TRUNCATED = re.compile(r"Showing lines (\d+)-\d+ of \d+ total lines\..*?--- FILE CONTENT \(truncated\) ---\n", re.S)
48
+ # The tags Gemini CLI wraps shell output in.
49
+ UNTRUSTED = re.compile(r"</?untrusted_context>")
50
+
51
+
52
+ def detected():
53
+ return HOME.is_dir()
54
+
55
+
56
+ def hooked():
57
+ return hooks.present(SETTINGS, nested=True)
58
+
59
+
60
+ def install(command, config):
61
+ if hooks.install(SETTINGS, EVENTS, command, nested=True):
62
+ return f"hook added to {SETTINGS}"
63
+ return None
64
+
65
+
66
+ def uninstall(config):
67
+ return hooks.remove(SETTINGS, nested=True)
68
+
69
+
70
+ def job(hook):
71
+ """The session an AfterAgent / SessionEnd hook is about, or None. Only main sessions, in
72
+ ~/.gemini/tmp/<project>/chats (a subagent's sit a folder deeper)."""
73
+ transcript = Path(hook.get("transcript_path") or "").expanduser().resolve()
74
+ chats = transcript.parent
75
+ if (transcript.suffix not in (".jsonl", ".json") or chats.name != "chats"
76
+ or chats.parent.parent != TMP.resolve() or not transcript.is_file()):
77
+ return None
78
+ cwd = hook.get("cwd") if isinstance(hook.get("cwd"), str) else None
79
+ return {"path": str(transcript), "label": transcript.stem, "folders": [cwd] if cwd else [], "folder": cwd}
80
+
81
+
82
+ def newest():
83
+ sessions = [p for pattern in ("*/chats/*.jsonl", "*/chats/*.json") for p in TMP.glob(pattern)]
84
+ return max(sessions, key=lambda p: p.stat().st_mtime, default=None)
85
+
86
+
87
+ def _utc(value):
88
+ """A session timestamp (2026-10-12T00:27:01.735Z) as a naive UTC datetime."""
89
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc).replace(tzinfo=None)
90
+
91
+
92
+ def _ts(value):
93
+ """Session timestamps as the naive UTC text Snowflake parses."""
94
+ return _utc(value).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
95
+
96
+
97
+ def load(path):
98
+ """The session's metadata and its messages in order, each at its latest version, with
99
+ `_checkpoint` set on those first seen in a "$set" messages checkpoint."""
100
+ text = Path(path).read_text()
101
+ if Path(path).suffix == ".json":
102
+ record = json.loads(text)
103
+ return record, [m for m in record.get("messages") or [] if isinstance(m, dict) and m.get("id")]
104
+ meta, messages = {}, {}
105
+ for line in text.splitlines():
106
+ try:
107
+ record = json.loads(line)
108
+ except ValueError:
109
+ continue # a line cut short by a crash
110
+ if not isinstance(record, dict) or "$rewindTo" in record:
111
+ continue
112
+ if isinstance(record.get("id"), str):
113
+ messages[record["id"]] = {**record, "_checkpoint": messages.get(record["id"], {}).get("_checkpoint")}
114
+ elif isinstance(record.get("$set"), dict):
115
+ update = record["$set"]
116
+ checkpoint = update.get("messages")
117
+ for m in checkpoint if isinstance(checkpoint, list) else []:
118
+ if isinstance(m, dict) and isinstance(m.get("id"), str):
119
+ seen = messages.get(m["id"])
120
+ messages[m["id"]] = {**m, "_checkpoint": True if seen is None else seen.get("_checkpoint")}
121
+ meta.update({k: v for k, v in update.items() if k != "messages"})
122
+ elif "sessionId" in record:
123
+ meta.update(record)
124
+ return meta, list(messages.values())
125
+
126
+
127
+ def _parts(content):
128
+ if isinstance(content, str):
129
+ return [{"text": content}]
130
+ return [p for p in content if isinstance(p, dict)] if isinstance(content, list) else []
131
+
132
+
133
+ def _text(content):
134
+ return "".join(p.get("text", "") for p in _parts(content) if isinstance(p.get("text"), str))
135
+
136
+
137
+ def prompt_of(message):
138
+ """The prompt a user message carries (as the developer typed it), or None when it isn't one."""
139
+ if message.get("type") != "user" or message.get("_checkpoint"):
140
+ return None
141
+ if any("functionResponse" in p for p in _parts(message.get("content"))):
142
+ return None
143
+ text = _text(message.get("displayContent") or message.get("content")).strip()
144
+ return None if not text or text.startswith(NOT_PROMPTS) else text
145
+
146
+
147
+ def edit_location(display, new_string):
148
+ """Where a replace's new text landed, in the words Cortex Code's edit results use ("Edit
149
+ location: lines 12-16"), from the file after the edit that Gemini CLI keeps beside the result."""
150
+ after = display.get("newContent") if isinstance(display, dict) else None
151
+ if not isinstance(after, str) or not new_string or (at := after.find(new_string)) < 0:
152
+ return None
153
+ start = after.count("\n", 0, at) + 1
154
+ return f"Edit location: lines {start}-{start + new_string.count(chr(10))}"
155
+
156
+
157
+ def _result(name, call, args):
158
+ responses = [p["functionResponse"].get("response") for p in _parts(call.get("result"))
159
+ if isinstance(p.get("functionResponse"), dict)]
160
+ texts = []
161
+ for r in responses:
162
+ value = r.get("output", r.get("error")) if isinstance(r, dict) else r
163
+ texts.append(value if isinstance(value, str) else json.dumps(value))
164
+ text = UNTRUSTED.sub("", "\n".join(texts)).strip("\n")
165
+ if name == "read" and call.get("status") == "success":
166
+ start = int(args.get("start_line") or args.get("offset") or 0) + (0 if args.get("start_line") else 1)
167
+ if m := TRUNCATED.search(text):
168
+ start, text = int(m[1]), text[m.end():]
169
+ text = "\n".join(f"{start + i}... {line}" for i, line in enumerate(text.splitlines()[:READ_LINES]))
170
+ elif name == "edit" and (where := edit_location(call.get("resultDisplay"), args.get("new_string"))):
171
+ text = f"{text}\n{where}"
172
+ # The trace marks a tool call failed when its result reads as an error.
173
+ if call.get("status") in ("error", "cancelled") and not text.lstrip().lower().startswith("error"):
174
+ text = f"Error: {text or call['status']}"
175
+ return clip(text, RESULT_CHARS)
176
+
177
+
178
+ def model_calls(messages):
179
+ """One dict per gemini message, in order: prompt (for turn starts), step, model, tokens,
180
+ response text, tool calls, start (when its input was sent) and end."""
181
+ calls, last_input, step, pending_prompt = [], None, 0, None
182
+ for m in messages:
183
+ if not m.get("timestamp") or m.get("_checkpoint"):
184
+ continue
185
+ if m.get("type") == "user":
186
+ last_input = m["timestamp"]
187
+ if (prompt := prompt_of(m)) is not None:
188
+ # Prompts sent before the agent answered open one turn together.
189
+ pending_prompt = prompt if pending_prompt is None else f"{pending_prompt}\n\n{prompt}"
190
+ step = 0
191
+ elif m.get("type") == "gemini":
192
+ tools = []
193
+ for call in m.get("toolCalls") or []:
194
+ name = TOOL_NAMES.get(call.get("name"), call.get("name") or "tool")
195
+ args = clip_args(call.get("args") if isinstance(call.get("args"), dict) else {}, ARG_RENAMES)
196
+ tools.append({"id": call.get("id"), "name": name, "args": args, "result": _result(name, call, args)})
197
+ calls.append({"prompt": pending_prompt, "step": step, "model": m.get("model"),
198
+ "tokens": m.get("tokens") or {}, "text": _text(m.get("content")).strip() or None,
199
+ "tools": tools, "start": last_input or m["timestamp"], "end": m["timestamp"]})
200
+ pending_prompt, step, last_input = None, step + 1, m["timestamp"]
201
+ return calls
202
+
203
+
204
+ def to_requests(calls, session_id):
205
+ requests = []
206
+ for i, call in enumerate(calls):
207
+ t = call["tokens"]
208
+ requests.append(request(
209
+ f"gemini-{session_id[:8]}-{i}", call["step"], _ts(call["start"]), _ts(max(call["end"], call["start"])),
210
+ call["prompt"] if call["step"] == 0 else None, call["text"], call["tools"],
211
+ model=call["model"],
212
+ # Gemini's input count already includes cached input, as Snowflake's telemetry does;
213
+ # thinking is billed as output.
214
+ input_tokens=(t.get("input") or 0) + (t.get("tool") or 0),
215
+ output_tokens=(t.get("output") or 0) + (t.get("thoughts") or 0),
216
+ cache_read_tokens=t.get("cached") or 0,
217
+ cache_write_tokens=0))
218
+ return requests
219
+
220
+
221
+ def project_root(path):
222
+ """The folder a session ran in, from the .project_root Gemini CLI keeps beside its chats."""
223
+ try:
224
+ return (Path(path).parent.parent / ".project_root").read_text().strip() or None
225
+ except OSError:
226
+ return None
227
+
228
+
229
+ def payload(path, repo_dir=None, folder=None):
230
+ """The session for core.ingest_external_session. `repo_dir` pins its repository; `folder`
231
+ (where it ran) is used when its tool calls name none, else the project Gemini CLI recorded."""
232
+ meta, messages = load(path)
233
+ calls = model_calls(messages)
234
+ models = [c["model"] for c in calls if c["model"]]
235
+ folder = repo_dir or repo_of_paths(
236
+ tool["args"].get(key) for c in calls for tool in c["tools"] for key in PATH_ARGS) or folder or project_root(path)
237
+ session_id = meta.get("sessionId") or Path(path).stem
238
+ stamps = [m["timestamp"] for m in messages if m.get("timestamp") and not m.get("_checkpoint")]
239
+ started = min(stamps) if stamps else meta.get("startTime")
240
+ prompts = sorted(_utc(m["timestamp"]) for m in messages if m.get("timestamp") and prompt_of(m) is not None)
241
+ reports = git_reports(folder, _utc(started), prompts) if folder else []
242
+ return {
243
+ "session_id": session_id,
244
+ "agent": "gemini-cli",
245
+ "agent_name": NAME,
246
+ "model": max(set(models), key=models.count) if models else None,
247
+ "started_at": _ts(started),
248
+ **({"git_reports": reports} if reports else {}),
249
+ "prompts": sum(1 for c in calls if c["step"] == 0 and c["prompt"]),
250
+ "requests": to_requests(calls, session_id),
251
+ }
agentledger/app.py ADDED
@@ -0,0 +1,45 @@
1
+ """Calls into the installed AgentLedger app over the developer's own Snowflake connection, so
2
+ every session is stored as the user who ran it."""
3
+ import json
4
+
5
+ from .git_context import IDENTIFIER
6
+
7
+
8
+ def connection_name(config):
9
+ from snowflake.connector.config_manager import CONFIG_MANAGER
10
+
11
+ return config["connection"] or CONFIG_MANAGER["default_connection_name"]
12
+
13
+
14
+ def connect(config):
15
+ import snowflake.connector
16
+
17
+ snowflake.connector.paramstyle = "qmark" # bind payloads server-side; they're too big for the statement text
18
+ options = {"connection_name": connection_name(config), "login_timeout": 60, "network_timeout": 600}
19
+ if config["role"]:
20
+ options["role"] = config["role"]
21
+ return snowflake.connector.connect(**options)
22
+
23
+
24
+ def _ingest(config):
25
+ if not IDENTIFIER.match(config["app"]):
26
+ raise ValueError(f"invalid app name: {config['app']!r}")
27
+ return f"CALL {config['app']}.core.ingest_external_session(PARSE_JSON(?))"
28
+
29
+
30
+ def check(conn, config):
31
+ """(user, role) when this connection may report sessions to the app; raises when it may not."""
32
+ cur = conn.cursor()
33
+ user, role = cur.execute("SELECT CURRENT_USER(), CURRENT_ROLE()").fetchone()
34
+ cur.execute(_ingest(config), ["{}"])
35
+ return user, role
36
+
37
+
38
+ def ingest(conn, config, body):
39
+ """Loads one session; the app's answer ({"status": "ok", "requests": n, ...})."""
40
+ cur = conn.cursor()
41
+ # Agents outside Snowflake have no role of their own; a session shows the role it was reported
42
+ # with, as Cortex Code sessions show the role they ran under.
43
+ role = cur.execute("SELECT CURRENT_ROLE()").fetchone()[0]
44
+ cur.execute(_ingest(config), [json.dumps({**body, "role_name": role})])
45
+ return json.loads(cur.fetchone()[0])
agentledger/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ """agentledger: report Claude Code, Cursor, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake app.
2
+
3
+ agentledger install set up every agent on this machine (once)
4
+ agentledger status what's set up, and each agent's latest report
5
+ agentledger upload report sessions now, such as ones from before the install
6
+ agentledger uninstall remove the hooks and the Cortex Code plugin
7
+ """
8
+ import argparse
9
+ import json
10
+ import os
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from . import __version__, app, runner
18
+ from . import config as settings
19
+ from .agents import AGENTS, REPORTERS
20
+
21
+ INSTALL_FIRST = "Install agentledger first: uv tool install agentledger-cli (or pipx install agentledger-cli)."
22
+
23
+
24
+ def show(mark, label, text):
25
+ print(f" {mark} {label:<12} {text}")
26
+
27
+
28
+ def first_line(error):
29
+ return (str(error).strip().splitlines() or [repr(error)])[0]
30
+
31
+
32
+ def executable():
33
+ """The agentledger command the hooks run, from a lasting installation (uv tool, pipx, a venv).
34
+ Run through uvx, whose environment is temporary, it installs itself with uv first."""
35
+ prefix = Path(sys.prefix)
36
+ if any(part.startswith(("archive-v", "builds-v")) for part in prefix.parts):
37
+ if not (uv := shutil.which("uv")):
38
+ raise SystemExit(INSTALL_FIRST)
39
+ print(f"Installing agentledger {__version__} with uv, so the hooks outlive this command…")
40
+ subprocess.run([uv, "tool", "install", f"agentledger-cli=={__version__}"], check=True)
41
+ bin_dir = subprocess.run([uv, "tool", "dir", "--bin"], capture_output=True, text=True, check=True).stdout
42
+ return str(Path(bin_dir.strip()) / "agentledger")
43
+ if (exe := prefix / "bin" / "agentledger").exists():
44
+ return str(exe)
45
+ if exe := shutil.which("agentledger"):
46
+ return exe
47
+ raise SystemExit(INSTALL_FIRST)
48
+
49
+
50
+ def scope(config):
51
+ return "sessions in " + ", ".join(config["paths"]) if config["paths"] else "sessions in every folder"
52
+
53
+
54
+ def check(config):
55
+ """Prints whether this machine can report to the app; True when it can."""
56
+ try:
57
+ with app.connect(config) as conn:
58
+ user, role = app.check(conn, config)
59
+ except Exception as e: # noqa: BLE001 - any failure here means the same fix
60
+ show("✗", "Snowflake", first_line(e))
61
+ print(f"\n The role needs the app's reporter role. An admin grants it with:\n"
62
+ f" GRANT APPLICATION ROLE {config['app']}.APP_REPORTER TO ROLE <role>;\n"
63
+ f" Pick the connection and role with --connection and --role.")
64
+ return False
65
+ show("✓", "Snowflake", f"{user} as {role} can report to {config['app']}")
66
+ return True
67
+
68
+
69
+ def cmd_install(args):
70
+ config = settings.load()
71
+ config.update({k: getattr(args, k) for k in ("app", "connection", "role") if getattr(args, k)})
72
+ if args.only:
73
+ config["paths"] = [str(Path(p).expanduser().resolve()) for p in args.only]
74
+ elif args.all_folders:
75
+ config["paths"] = []
76
+ agents = [AGENTS[a] for a in args.agent] if args.agent else [a for a in AGENTS.values() if a.detected()]
77
+ if not agents:
78
+ print(f"No supported agent on this machine ({', '.join(a.NAME for a in AGENTS.values())}).")
79
+ return 1
80
+ exe = executable()
81
+ print(f"Setting up agentledger {__version__} for {config['app']} over the "
82
+ f"{app.connection_name(config)} connection")
83
+ if not check(config):
84
+ return 1
85
+ settings.save(config)
86
+ show("✓", "Settings", f"{settings.PATH} · {scope(config)}")
87
+ failed = False
88
+ for agent in agents:
89
+ try:
90
+ done = agent.install(f"{shlex.quote(exe)} hook {agent.ID}", config)
91
+ show("✓", agent.NAME, done or "already set up")
92
+ except Exception as e: # noqa: BLE001 - one agent failing shouldn't stop the others
93
+ failed = True
94
+ show("!", agent.NAME, first_line(e))
95
+ print("\nSessions are reported in the background after each turn; `agentledger status` shows the latest.")
96
+ return 1 if failed else 0
97
+
98
+
99
+ def last_report(agent):
100
+ try:
101
+ lines = runner.log_path(agent).read_text().splitlines()
102
+ except OSError:
103
+ return None
104
+ return next((line for line in reversed(lines) if line[20:].startswith(("ok ", "ignored ", "failed "))), None)
105
+
106
+
107
+ def cmd_status(args):
108
+ config = settings.load()
109
+ print(f"agentledger {__version__} · {config['app']} · {app.connection_name(config)} connection · "
110
+ f"role {config['role'] or 'from the connection'} · {scope(config)}")
111
+ ready = check(config)
112
+ for agent in AGENTS.values():
113
+ if not agent.detected():
114
+ show("·", agent.NAME, "not on this machine")
115
+ elif not agent.hooked():
116
+ ready = False
117
+ show("✗", agent.NAME, "not set up; run agentledger install")
118
+ else:
119
+ last = last_report(agent.ID) if agent.ID in REPORTERS else None
120
+ show("✓", agent.NAME, f"set up · last report {last}" if last else "set up")
121
+ return 0 if ready else 1
122
+
123
+
124
+ def cmd_uninstall(args):
125
+ config = settings.load()
126
+ for agent in AGENTS.values():
127
+ if agent.detected():
128
+ show("✓", agent.NAME, "removed" if agent.uninstall(config) else "nothing to remove")
129
+ print(f"\nSettings stay in {settings.PATH}.")
130
+ return 0
131
+
132
+
133
+ def cmd_upload(args):
134
+ agent = REPORTERS[args.agent]
135
+ paths = args.sessions or [agent.newest()]
136
+ if paths == [None]:
137
+ print(f"No {agent.NAME} sessions on this machine.")
138
+ return 1
139
+ config = settings.load()
140
+ conn = None if args.dry_run else app.connect(config)
141
+ failed = False
142
+ for path in paths:
143
+ try:
144
+ body = agent.payload(path, repo_dir=args.repo_dir)
145
+ summary = (f"{body['session_id']}: {body['prompts']} prompts, {len(body['requests'])} model calls, "
146
+ f"{len(json.dumps(body)) / 1e6:.1f} MB")
147
+ if args.dry_run:
148
+ out = settings.STATE / f"{agent.ID}-{body['session_id']}.json"
149
+ settings.write_json(out, body)
150
+ print(f"{summary} → {out}")
151
+ continue
152
+ result = app.ingest(conn, config, body)
153
+ print(f"{summary} → {result['status']}" + (f" ({result['reason']})" if result.get("reason") else ""))
154
+ failed |= result["status"] != "ok"
155
+ except Exception as e: # noqa: BLE001 - report the rest
156
+ failed = True
157
+ print(f"{path}: {first_line(e)}")
158
+ if conn:
159
+ conn.close()
160
+ return 1 if failed else 0
161
+
162
+
163
+ def report(agent, config, jobs):
164
+ with app.connect(config) as conn:
165
+ for job in jobs:
166
+ try:
167
+ result = app.ingest(conn, config, agent.payload(job["path"], folder=job.get("folder")))
168
+ detail = f"{result['requests']} model calls" if result["status"] == "ok" else result.get("reason")
169
+ runner.log(agent.ID, f"{result['status']} {job['label']}: {detail}")
170
+ except Exception as e: # noqa: BLE001 - log it and go on with the next session
171
+ runner.log(agent.ID, f"failed {job['label']}: {first_line(e)}")
172
+
173
+
174
+ def cmd_hook(args):
175
+ """Run by the agent after each turn. Returns at once and never fails the agent."""
176
+ agent = REPORTERS[args.agent]
177
+ try:
178
+ try:
179
+ hook = json.loads(sys.stdin.read() or "{}")
180
+ except ValueError:
181
+ hook = {}
182
+ sys.stdout.write("{}\n")
183
+ sys.stdout.flush()
184
+ config = settings.load()
185
+ job = agent.job(hook) if isinstance(hook, dict) else None
186
+ if job is None or not settings.in_scope(config, job["folders"]):
187
+ return 0
188
+ runner.enqueue(agent.ID, job)
189
+ if not runner.detach(agent.ID):
190
+ return 0
191
+ except Exception as e: # noqa: BLE001
192
+ runner.log(agent.ID, f"failed hook: {e!r}")
193
+ return 0
194
+ try:
195
+ runner.drain(agent.ID, lambda jobs: report(agent, config, jobs))
196
+ except Exception as e: # noqa: BLE001
197
+ runner.log(agent.ID, f"failed: {first_line(e)}")
198
+ finally:
199
+ os._exit(0)
200
+
201
+
202
+ def main(argv=None):
203
+ parser = argparse.ArgumentParser(prog="agentledger", description=__doc__.splitlines()[0].split(": ", 1)[1])
204
+ parser.add_argument("--version", action="version", version=f"agentledger {__version__}")
205
+ sub = parser.add_subparsers(dest="command", required=True, metavar="command")
206
+
207
+ p = sub.add_parser("install", help="set up every agent on this machine")
208
+ p.add_argument("--app", help="the installed app's name (default AGENTLEDGER)")
209
+ p.add_argument("-c", "--connection", help="Snowflake connection (default: your default connection)")
210
+ p.add_argument("--role", help="role holding the app's APP_REPORTER role (default: the connection's)")
211
+ p.add_argument("--only", nargs="+", metavar="FOLDER", help="report only sessions that ran in these folders")
212
+ p.add_argument("--all-folders", action="store_true", help="report sessions from every folder again")
213
+ p.add_argument("--agent", action="append", choices=AGENTS, help="set up only this agent (repeatable)")
214
+ p.set_defaults(run=cmd_install)
215
+
216
+ sub.add_parser("status", help="what's set up, and each agent's latest report").set_defaults(run=cmd_status)
217
+ sub.add_parser("uninstall", help="remove the hooks and the Cortex Code plugin").set_defaults(run=cmd_uninstall)
218
+
219
+ p = sub.add_parser("upload", help="report sessions now (default: the agent's newest)")
220
+ p.add_argument("agent", choices=REPORTERS)
221
+ p.add_argument("sessions", nargs="*", help="session files: Claude Code .jsonl transcripts, Cursor store.db "
222
+ "files, Gemini CLI chats/session-*.jsonl files")
223
+ p.add_argument("--repo-dir", help="attribute the sessions to this repository")
224
+ p.add_argument("--dry-run", action="store_true", help=f"write the payloads under {settings.STATE} instead")
225
+ p.set_defaults(run=cmd_upload)
226
+
227
+ p = sub.add_parser("hook") # run by the agents; without help, it stays out of the list
228
+ p.add_argument("agent", choices=REPORTERS)
229
+ p.set_defaults(run=cmd_hook)
230
+
231
+ args = parser.parse_args(argv)
232
+ sys.exit(args.run(args))
agentledger/config.py ADDED
@@ -0,0 +1,66 @@
1
+ """The developer's settings, in the file the Cortex Code plugin's git hook also reads:
2
+
3
+ ~/.snowflake/agentledger.json {"app": "AGENTLEDGER", "connection": "my_conn", "role": "DEVELOPER",
4
+ "paths": ["~/work"]}
5
+
6
+ `paths` limits which sessions are reported to the ones that ran in those folders (empty: all).
7
+ AGENTLEDGER_APP / AGENTLEDGER_CONNECTION / AGENTLEDGER_ROLE override the file.
8
+ """
9
+ import json
10
+ import os
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ PATH = Path.home() / ".snowflake" / "agentledger.json"
15
+ STATE = Path.home() / ".cache" / "agentledger"
16
+ DEFAULTS = {"app": "AGENTLEDGER", "connection": None, "role": None, "paths": []}
17
+
18
+
19
+ def read_json(path):
20
+ """A JSON object from a file; {} when it's missing. Anything else is an error, so a settings
21
+ file someone wrote by hand is never overwritten."""
22
+ try:
23
+ text = Path(path).read_text()
24
+ except FileNotFoundError:
25
+ return {}
26
+ data = json.loads(text) if text.strip() else {}
27
+ if not isinstance(data, dict):
28
+ raise ValueError(f"{path} doesn't hold a JSON object")
29
+ return data
30
+
31
+
32
+ def write_json(path, data):
33
+ path = Path(path)
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
36
+ with os.fdopen(fd, "w") as f:
37
+ json.dump(data, f, indent=2)
38
+ f.write("\n")
39
+ if path.exists():
40
+ os.chmod(tmp, path.stat().st_mode & 0o777)
41
+ os.replace(tmp, path)
42
+
43
+
44
+ def load():
45
+ saved = read_json(PATH)
46
+ config = {**DEFAULTS, **{k: v for k, v in saved.items() if k in DEFAULTS and v}}
47
+ for key in ("app", "connection", "role"):
48
+ config[key] = os.environ.get(f"AGENTLEDGER_{key.upper()}") or config[key]
49
+ return config
50
+
51
+
52
+ def save(config):
53
+ """Writes the settings, keeping any keys this version doesn't know."""
54
+ saved = read_json(PATH)
55
+ saved.update({k: config[k] for k in DEFAULTS})
56
+ write_json(PATH, {k: v for k, v in saved.items() if v})
57
+
58
+
59
+ def in_scope(config, folders):
60
+ """True when a session that ran in these folders should be reported. A session that names no
61
+ folder is reported."""
62
+ folders = [Path(f).expanduser().resolve() for f in folders if f]
63
+ if not config["paths"] or not folders:
64
+ return True
65
+ roots = [Path(p).expanduser().resolve() for p in config["paths"]]
66
+ return any(f == r or r in f.parents for f in folders for r in roots)