cli-consumption 0.0.7__tar.gz → 0.0.8__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.
Files changed (58) hide show
  1. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yolo/SKILL.md +7 -5
  2. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/PKG-INFO +19 -3
  3. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/README.md +18 -2
  4. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/docs/architecture.md +2 -2
  5. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/docs/provider-support.md +17 -0
  6. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/pyproject.toml +1 -1
  7. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/__init__.py +2 -0
  8. cli_consumption-0.0.8/src/cli_consumption/adapters/aider.py +347 -0
  9. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/cli.py +3 -0
  10. cli_consumption-0.0.8/tests/test_aider_adapter.py +189 -0
  11. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_cli.py +29 -0
  12. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/uv.lock +1 -1
  13. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
  14. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
  15. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
  16. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
  17. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
  18. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
  19. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yeet-github/SKILL.md +0 -0
  20. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
  21. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yolo/agents/openai.yaml +0 -0
  22. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.github/workflows/ci.yml +0 -0
  23. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.github/workflows/release.yaml +0 -0
  24. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.gitignore +0 -0
  25. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.pre-commit-config.yaml +0 -0
  26. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.python-version +0 -0
  27. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/AGENTS.md +0 -0
  28. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/CONTRIBUTING.md +0 -0
  29. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/LICENSE +0 -0
  30. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/NOTICE +0 -0
  31. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/docs/privacy.md +0 -0
  32. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/docs/roadmap.md +0 -0
  33. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/__init__.py +0 -0
  34. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/__main__.py +0 -0
  35. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/base.py +0 -0
  36. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/claude.py +0 -0
  37. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/codex.py +0 -0
  38. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/gemini.py +0 -0
  39. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/kilo.py +0 -0
  40. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/opencode.py +0 -0
  41. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/pi.py +0 -0
  42. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/api.py +0 -0
  43. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/dashboard.py +0 -0
  44. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/exporting.py +0 -0
  45. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/models.py +0 -0
  46. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/py.typed +0 -0
  47. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/storage.py +0 -0
  48. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/sync.py +0 -0
  49. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/conftest.py +0 -0
  50. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_api.py +0 -0
  51. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_claude_adapter.py +0 -0
  52. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_codex_adapter.py +0 -0
  53. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_gemini_adapter.py +0 -0
  54. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_kilo_adapter.py +0 -0
  55. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_opencode_adapter.py +0 -0
  56. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_pi_adapter.py +0 -0
  57. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_storage_and_exports.py +0 -0
  58. {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_sync.py +0 -0
@@ -10,9 +10,11 @@ Run each phase in order from the repository root. The implicit pull request is t
10
10
  ## 1. Publish
11
11
 
12
12
  1. Read all repository instructions and the product specification referenced by `AGENTS.md`.
13
- 2. Load and follow `$yeet-github` in full to inspect, validate, commit, push, and open a draft pull request, except do not request its approval before pushing or publishing: the explicit `$yolo` invocation already provides that approval.
14
- 3. Retain the exact source and target branches, pushed SHA, pull-request number, and URL.
15
- 4. Stop after any failure or ambiguous external result. Never merge or clean up after a partial failure.
13
+ 2. Read `.agents/local-environment.md` when present and follow its GitHub authentication instructions.
14
+ 3. Load and follow `$yeet-github` in full to inspect, validate, commit, push, and open a draft pull request, except do not request its approval before pushing or publishing: the explicit `$yolo` invocation already provides that approval.
15
+ 4. Use `gh` directly for GitHub API and pull-request operations. When Git transport is unavoidable and the remote is HTTPS, authenticate it explicitly through the current `gh` session, for example `git -c credential.helper='!gh auth git-credential' push -u origin <source-branch>`; never rely on ambient HTTPS Git credentials.
16
+ 5. Retain the exact source and target branches, pushed SHA, pull-request number, and URL.
17
+ 6. Stop after any failure or ambiguous external result. Never merge or clean up after a partial failure.
16
18
 
17
19
  ## 2. Merge
18
20
 
@@ -29,10 +31,10 @@ Run each phase in order from the repository root. The implicit pull request is t
29
31
 
30
32
  1. Reconfirm the merged state and capture the merge commit SHA.
31
33
  2. Refuse cleanup if new tracked, staged, or untracked work appeared after the source commit.
32
- 3. Run `git fetch --prune origin`.
34
+ 3. Fetch with the authentication method required by `.agents/local-environment.md`; for an HTTPS GitHub remote, use `git -c credential.helper='!gh auth git-credential' fetch --prune origin`.
33
35
  4. Switch to the target branch and update it only by fast-forward with `git switch <target-branch>` and `git pull --ff-only origin <target-branch>`.
34
36
  5. Delete the exact local source branch if it remains. `git branch -D <source-branch>` is allowed only after verified squash merge.
35
- 6. If the exact unprotected remote source branch remains, delete it with `git push origin --delete <source-branch>` without requesting additional approval.
37
+ 6. If the exact unprotected remote source branch remains, delete it without requesting additional approval. For an HTTPS GitHub remote, use `git -c credential.helper='!gh auth git-credential' push origin --delete <source-branch>`.
36
38
  7. Run `git status --short --branch` and report the final state.
37
39
 
38
40
  ## Guardrails
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.0.7
3
+ Version: 0.0.8
4
4
  Summary: Analyze and consolidate AI coding CLI consumption across machines.
5
5
  Project-URL: Homepage, https://github.com/Guillaume-Lombardo/cli-consumption
6
6
  Project-URL: Documentation, https://github.com/Guillaume-Lombardo/cli-consumption#readme
@@ -33,8 +33,8 @@ models, tokens, tools, conversations, and turns. It can analyze one workstation,
33
33
  consolidate copied data from several machines, or send metadata-only snapshots to a
34
34
  central collector.
35
35
 
36
- Codex, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript
37
- format are supported.
36
+ Codex, Aider, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code
37
+ local transcript format are supported.
38
38
 
39
39
  The collector deliberately excludes prompts, responses, tool arguments, and
40
40
  credentials. See [Privacy](docs/privacy.md) before sharing a database or export.
@@ -126,6 +126,14 @@ Select Gemini CLI to read `~/.gemini/tmp/*/chats/` instead:
126
126
  uv run cli-consumption collect --provider gemini --database usage.sqlite
127
127
  ```
128
128
 
129
+ Select Aider to read a local analytics log configured at
130
+ `~/.aider/analytics.jsonl` instead:
131
+
132
+ ```bash
133
+ export AIDER_ANALYTICS_LOG="$HOME/.aider/analytics.jsonl"
134
+ uv run cli-consumption collect --provider aider --database usage.sqlite
135
+ ```
136
+
129
137
  Use `all` to detect and collect every supported provider present on the machine:
130
138
 
131
139
  ```bash
@@ -198,6 +206,14 @@ uv run cli-consumption collect --provider gemini \
198
206
  --source laptop=/data/gemini/laptop
199
207
  ```
200
208
 
209
+ Copied Aider sources point to a directory containing `analytics.jsonl`:
210
+
211
+ ```bash
212
+ uv run cli-consumption collect --provider aider \
213
+ --source desktop=/data/aider/desktop \
214
+ --source laptop=/data/aider/laptop
215
+ ```
216
+
201
217
  ## SQLite and PostgreSQL
202
218
 
203
219
  A file path selects SQLite. A SQLAlchemy URL selects PostgreSQL:
@@ -5,8 +5,8 @@ models, tokens, tools, conversations, and turns. It can analyze one workstation,
5
5
  consolidate copied data from several machines, or send metadata-only snapshots to a
6
6
  central collector.
7
7
 
8
- Codex, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript
9
- format are supported.
8
+ Codex, Aider, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code
9
+ local transcript format are supported.
10
10
 
11
11
  The collector deliberately excludes prompts, responses, tool arguments, and
12
12
  credentials. See [Privacy](docs/privacy.md) before sharing a database or export.
@@ -98,6 +98,14 @@ Select Gemini CLI to read `~/.gemini/tmp/*/chats/` instead:
98
98
  uv run cli-consumption collect --provider gemini --database usage.sqlite
99
99
  ```
100
100
 
101
+ Select Aider to read a local analytics log configured at
102
+ `~/.aider/analytics.jsonl` instead:
103
+
104
+ ```bash
105
+ export AIDER_ANALYTICS_LOG="$HOME/.aider/analytics.jsonl"
106
+ uv run cli-consumption collect --provider aider --database usage.sqlite
107
+ ```
108
+
101
109
  Use `all` to detect and collect every supported provider present on the machine:
102
110
 
103
111
  ```bash
@@ -170,6 +178,14 @@ uv run cli-consumption collect --provider gemini \
170
178
  --source laptop=/data/gemini/laptop
171
179
  ```
172
180
 
181
+ Copied Aider sources point to a directory containing `analytics.jsonl`:
182
+
183
+ ```bash
184
+ uv run cli-consumption collect --provider aider \
185
+ --source desktop=/data/aider/desktop \
186
+ --source laptop=/data/aider/laptop
187
+ ```
188
+
173
189
  ## SQLite and PostgreSQL
174
190
 
175
191
  A file path selects SQLite. A SQLAlchemy URL selects PostgreSQL:
@@ -16,8 +16,8 @@ provider files -> adapter -> metadata-only snapshot -> SQL storage -> dashboard/
16
16
 
17
17
  - `adapters`: parse a CLI's local data into conversations, turns, model calls, tool
18
18
  calls, context-pressure samples, bounded turn settings, compactions, and content-free
19
- work-item intervals. Codex exposes the complete analytics contract; Claude Code,
20
- Gemini CLI, Kilo Code, OpenCode, and Pi expose the core dimensions available in
19
+ work-item intervals. Codex exposes the complete analytics contract; Aider, Claude
20
+ Code, Gemini CLI, Kilo Code, OpenCode, and Pi expose the core dimensions available in
21
21
  their local stores.
22
22
  - `models`: define the transport boundary shared by offline and API ingestion.
23
23
  - `storage`: owns the normalized schema, idempotent replacement rules, SQLite, and
@@ -2,6 +2,7 @@
2
2
 
3
3
  | Provider | Status | Initial source |
4
4
  | --- | --- | --- |
5
+ | Aider | Supported (core) | Opt-in local analytics JSONL |
5
6
  | Codex | Supported | Local rollout JSONL and optional metadata-only subagent state |
6
7
  | Claude Code | Supported (core) | Local project transcript JSONL |
7
8
  | Gemini CLI | Supported (core) | Local automatic chat history JSON and JSONL |
@@ -98,6 +99,22 @@ session directories automatically, context-window sizes, costs, branch relations
98
99
  or provider-reported durations. Pi's JSONL schema can change without notice, and its
99
100
  local token events are not billing data.
100
101
 
102
+ Aider reads an explicitly configured local analytics log named `analytics.jsonl` (for
103
+ example, `AIDER_ANALYTICS_LOG=~/.aider/analytics.jsonl`). It groups events from
104
+ `launched` through `exit`, extracts message-send attempts, model identifiers, and
105
+ prompt/completion/total token counters, and discards the analytics user UUID, costs,
106
+ exception text, command events, edit formats, system properties, and arbitrary event
107
+ properties. Conversation identifiers are stable hashes of the transient UUID and
108
+ launch time; the UUID itself is never emitted.
109
+
110
+ Aider analytics do not expose prompts or responses, but logging is not enabled at a
111
+ fixed location by default. The adapter cannot attribute projects, tool calls, cached
112
+ or reasoning tokens, compactions, context windows, or provider-reported durations.
113
+ Turns represent Aider message-send attempts because the log has no durable user-turn
114
+ identifier. Unknown custom model names may already be represented as
115
+ `provider/REDACTED` by Aider. Its analytics event schema can change without notice,
116
+ and local token events are not billing data.
117
+
101
118
  Provider formats can change without notice. Unknown fields are ignored; malformed
102
119
  provider records are counted and skipped. Compatibility fixes should add a fixture for
103
120
  both the old and new format whenever possible.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.0.7"
7
+ version = "0.0.8"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -1,3 +1,4 @@
1
+ from cli_consumption.adapters.aider import AiderAdapter
1
2
  from cli_consumption.adapters.claude import ClaudeAdapter
2
3
  from cli_consumption.adapters.codex import CodexAdapter
3
4
  from cli_consumption.adapters.gemini import GeminiAdapter
@@ -6,6 +7,7 @@ from cli_consumption.adapters.opencode import OpenCodeAdapter
6
7
  from cli_consumption.adapters.pi import PiAdapter
7
8
 
8
9
  __all__ = [
10
+ "AiderAdapter",
9
11
  "ClaudeAdapter",
10
12
  "CodexAdapter",
11
13
  "GeminiAdapter",
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cli_consumption.models import Snapshot, empty_tokens
13
+
14
+ MAX_BIGINT = 9_223_372_036_854_775_807
15
+ SAFE_LABEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+-]*")
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class _Candidate:
20
+ machine: str
21
+ external_id: str
22
+ events: list[dict[str, Any]]
23
+ digest: str
24
+
25
+
26
+ class AiderAdapter:
27
+ """Read Aider analytics logs while discarding arbitrary event properties."""
28
+
29
+ name = "aider"
30
+
31
+ def collect(
32
+ self,
33
+ sources: list[tuple[str, Path]],
34
+ project_mappings: list[tuple[str, str]] | None = None,
35
+ ) -> Snapshot:
36
+ del project_mappings # Aider analytics do not record a project path.
37
+ selected: dict[str, _Candidate] = {}
38
+ duplicates = malformed = 0
39
+ for machine, home in sources:
40
+ path = home / "analytics.jsonl"
41
+ if not path.is_file():
42
+ raise ValueError(f"Missing Aider analytics log: {path}")
43
+ sessions, invalid = _read_sessions(path)
44
+ malformed += invalid
45
+ for external_id, events, digest in sessions:
46
+ candidate = _Candidate(machine, external_id, events, digest)
47
+ previous = selected.get(external_id)
48
+ if previous is None:
49
+ selected[external_id] = candidate
50
+ continue
51
+ duplicates += 1
52
+ if _rank(candidate) > _rank(previous):
53
+ selected[external_id] = candidate
54
+
55
+ snapshot = Snapshot(
56
+ provider=self.name,
57
+ duplicate_conversations=duplicates,
58
+ malformed_records=malformed,
59
+ )
60
+ for candidate in sorted(selected.values(), key=lambda item: item.external_id):
61
+ self._normalize(snapshot, candidate)
62
+ return snapshot
63
+
64
+ def _normalize(self, snapshot: Snapshot, source: _Candidate) -> None:
65
+ conversation_id = f"aider:{source.external_id}"
66
+ timestamps = [
67
+ timestamp
68
+ for event in source.events
69
+ if (timestamp := _timestamp(event.get("time"))) is not None
70
+ ]
71
+ turns: list[dict[str, Any]] = []
72
+ turn_models: list[set[str]] = []
73
+ active: int | None = None
74
+ calls: list[tuple[int | None, datetime | None, str, dict[str, int]]] = []
75
+
76
+ for event in source.events:
77
+ name = event.get("event")
78
+ timestamp = _timestamp(event.get("time"))
79
+ if name == "message_send_starting":
80
+ if active is not None:
81
+ _finish_turn(turns[active], timestamp, "aborted")
82
+ active = len(turns)
83
+ turns.append(_new_turn(conversation_id, active + 1, timestamp))
84
+ turn_models.append(set())
85
+ continue
86
+ if name == "message_send_exception":
87
+ if active is not None:
88
+ _finish_turn(turns[active], timestamp, "aborted")
89
+ active = None
90
+ continue
91
+ if name == "exit":
92
+ if active is not None:
93
+ _finish_turn(turns[active], timestamp, "aborted")
94
+ active = None
95
+ continue
96
+ if name != "message_send":
97
+ continue
98
+
99
+ properties = event.get("properties")
100
+ properties = properties if isinstance(properties, dict) else {}
101
+ model = _label(properties.get("main_model"), 255) or "unknown"
102
+ tokens = _usage(properties)
103
+ calls.append((active, timestamp, model, tokens))
104
+ if active is not None:
105
+ _finish_turn(turns[active], timestamp, "completed")
106
+ turn_models[active].add(model)
107
+ active = None
108
+
109
+ ended_at = max(timestamps, default=None)
110
+ if active is not None:
111
+ _finish_turn(turns[active], ended_at, "in-progress")
112
+
113
+ totals = empty_tokens()
114
+ models: set[str] = set()
115
+ for sequence, (turn_index, timestamp, model, tokens) in enumerate(calls, 1):
116
+ models.add(model)
117
+ _add_tokens(totals, tokens)
118
+ turn = turns[turn_index] if turn_index is not None else None
119
+ if turn is not None:
120
+ turn["model_calls"] += 1
121
+ _add_tokens(turn, tokens)
122
+ snapshot.model_calls.append(
123
+ {
124
+ "id": f"{conversation_id}:model:{sequence}",
125
+ "conversation_id": conversation_id,
126
+ "turn_id": turn["id"] if turn else None,
127
+ "sequence": sequence,
128
+ "timestamp": _iso(timestamp),
129
+ "model": model,
130
+ **tokens,
131
+ }
132
+ )
133
+
134
+ for index, turn in enumerate(turns):
135
+ snapshot.turns.append(turn)
136
+ observed_models = turn_models[index]
137
+ snapshot.turn_settings.append(
138
+ {
139
+ "id": f"{conversation_id}:settings:{index + 1}",
140
+ "conversation_id": conversation_id,
141
+ "turn_id": turn["id"],
142
+ "model": next(iter(observed_models))
143
+ if len(observed_models) == 1
144
+ else None,
145
+ "effort": None,
146
+ "collaboration_mode": None,
147
+ "service_tier": None,
148
+ "context_window_tokens": None,
149
+ }
150
+ )
151
+
152
+ started_at = min(timestamps, default=None)
153
+ snapshot.conversations.append(
154
+ {
155
+ "id": conversation_id,
156
+ "provider": self.name,
157
+ "external_id": source.external_id,
158
+ "source_machine": source.machine,
159
+ "project": "outside-project",
160
+ "project_source": "none",
161
+ "started_at": _iso(started_at),
162
+ "ended_at": _iso(ended_at),
163
+ "duration_seconds": (
164
+ (ended_at - started_at).total_seconds()
165
+ if started_at and ended_at
166
+ else None
167
+ ),
168
+ "source": "local-analytics-jsonl",
169
+ "models": sorted(models),
170
+ "iterations": len(turns),
171
+ "model_calls": len(calls),
172
+ "tool_calls": 0,
173
+ "compactions": 0,
174
+ "event_count": len(source.events),
175
+ "content_hash": source.digest,
176
+ **totals,
177
+ }
178
+ )
179
+
180
+
181
+ def _read_sessions(
182
+ path: Path,
183
+ ) -> tuple[list[tuple[str, list[dict[str, Any]], str]], int]:
184
+ sessions: list[tuple[str, list[dict[str, Any]], str]] = []
185
+ active: list[dict[str, Any]] | None = None
186
+ malformed = 0
187
+ ordinals: dict[tuple[str, int], int] = {}
188
+ with path.open("rb") as handle:
189
+ for line in handle:
190
+ if not line.strip():
191
+ continue
192
+ try:
193
+ event = json.loads(line)
194
+ except (json.JSONDecodeError, UnicodeDecodeError):
195
+ malformed += 1
196
+ continue
197
+ if not isinstance(event, dict) or not isinstance(event.get("event"), str):
198
+ malformed += 1
199
+ continue
200
+ if _epoch(event.get("time")) is None:
201
+ malformed += 1
202
+ continue
203
+ if event["event"] == "launched":
204
+ if active:
205
+ sessions.append(_session(active, ordinals))
206
+ active = [event]
207
+ continue
208
+ if active is None:
209
+ continue
210
+ active.append(event)
211
+ if event["event"] == "exit":
212
+ sessions.append(_session(active, ordinals))
213
+ active = None
214
+ if active:
215
+ sessions.append(_session(active, ordinals))
216
+ return sessions, malformed
217
+
218
+
219
+ def _session(
220
+ events: list[dict[str, Any]], ordinals: dict[tuple[str, int], int]
221
+ ) -> tuple[str, list[dict[str, Any]], str]:
222
+ launched = events[0]
223
+ user_id = launched.get("user_id")
224
+ user_id = user_id if isinstance(user_id, str) else "anonymous"
225
+ started = _epoch(launched.get("time")) or 0
226
+ key = (user_id, started)
227
+ ordinal = ordinals.get(key, 0) + 1
228
+ ordinals[key] = ordinal
229
+ stable = hashlib.sha256(f"{user_id}\0{started}\0{ordinal}".encode()).hexdigest()[
230
+ :32
231
+ ]
232
+ approved = [
233
+ {
234
+ "event": event.get("event"),
235
+ "time": _epoch(event.get("time")),
236
+ "properties": _approved_properties(event),
237
+ }
238
+ for event in events
239
+ ]
240
+ digest = hashlib.sha256(
241
+ json.dumps(approved, sort_keys=True, separators=(",", ":")).encode()
242
+ ).hexdigest()
243
+ return stable, events, digest
244
+
245
+
246
+ def _approved_properties(event: dict[str, Any]) -> dict[str, Any]:
247
+ if event.get("event") != "message_send":
248
+ return {}
249
+ properties = event.get("properties")
250
+ if not isinstance(properties, dict):
251
+ return {}
252
+ return {
253
+ key: properties.get(key)
254
+ for key in ("main_model", "prompt_tokens", "completion_tokens", "total_tokens")
255
+ }
256
+
257
+
258
+ def _new_turn(
259
+ conversation_id: str, sequence: int, timestamp: datetime | None
260
+ ) -> dict[str, Any]:
261
+ return {
262
+ "id": f"{conversation_id}:turn:{sequence}",
263
+ "conversation_id": conversation_id,
264
+ "external_id": f"turn-{sequence}",
265
+ "started_at": _iso(timestamp),
266
+ "ended_at": None,
267
+ "status": "in-progress",
268
+ "duration_ms": None,
269
+ "time_to_first_token_ms": None,
270
+ "model_calls": 0,
271
+ "tool_calls": 0,
272
+ **empty_tokens(),
273
+ }
274
+
275
+
276
+ def _finish_turn(turn: dict[str, Any], timestamp: datetime | None, status: str) -> None:
277
+ turn["ended_at"] = _iso(timestamp)
278
+ turn["status"] = status
279
+
280
+
281
+ def _usage(properties: dict[str, Any]) -> dict[str, int]:
282
+ input_tokens = _counter(properties.get("prompt_tokens"))
283
+ output_tokens = _counter(properties.get("completion_tokens"))
284
+ attributed = _sum(input_tokens, output_tokens)
285
+ total = max(attributed, _counter(properties.get("total_tokens")))
286
+ return {
287
+ "input_tokens": input_tokens,
288
+ "uncached_input_tokens": input_tokens,
289
+ "cached_input_tokens": 0,
290
+ "cache_write_input_tokens": 0,
291
+ "output_tokens": output_tokens,
292
+ "reasoning_output_tokens": 0,
293
+ "visible_output_tokens": output_tokens,
294
+ "total_tokens": total,
295
+ "unattributed_tokens": total - attributed,
296
+ }
297
+
298
+
299
+ def _counter(value: object) -> int:
300
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
301
+ return 0
302
+ if not math.isfinite(value) or value < 0 or value > MAX_BIGINT:
303
+ return 0
304
+ return int(value)
305
+
306
+
307
+ def _sum(*values: int) -> int:
308
+ return min(MAX_BIGINT, sum(values))
309
+
310
+
311
+ def _add_tokens(target: dict[str, Any], values: dict[str, int]) -> None:
312
+ for key, value in values.items():
313
+ target[key] = _sum(int(target[key]), value)
314
+
315
+
316
+ def _epoch(value: object) -> int | None:
317
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
318
+ return None
319
+ if not math.isfinite(value) or value < 0 or value > 253_402_300_799:
320
+ return None
321
+ return int(value)
322
+
323
+
324
+ def _timestamp(value: object) -> datetime | None:
325
+ epoch = _epoch(value)
326
+ return datetime.fromtimestamp(epoch, tz=UTC) if epoch is not None else None
327
+
328
+
329
+ def _iso(value: datetime | None) -> str | None:
330
+ return value.isoformat().replace("+00:00", "Z") if value else None
331
+
332
+
333
+ def _label(value: object, limit: int) -> str | None:
334
+ if (
335
+ not isinstance(value, str)
336
+ or len(value) > limit
337
+ or not SAFE_LABEL.fullmatch(value)
338
+ ):
339
+ return None
340
+ return value
341
+
342
+
343
+ def _rank(candidate: _Candidate) -> tuple[int, int, str, str]:
344
+ ended = max(
345
+ (_epoch(event.get("time")) or 0 for event in candidate.events), default=0
346
+ )
347
+ return len(candidate.events), ended, candidate.digest, candidate.machine
@@ -9,6 +9,7 @@ import typer
9
9
 
10
10
  from cli_consumption import __version__
11
11
  from cli_consumption.adapters import (
12
+ AiderAdapter,
12
13
  ClaudeAdapter,
13
14
  CodexAdapter,
14
15
  GeminiAdapter,
@@ -54,6 +55,7 @@ def main(
54
55
  def providers() -> None:
55
56
  """Show implemented and planned CLI adapters."""
56
57
  typer.echo("all auto-detect supported providers")
58
+ typer.echo("aider supported")
57
59
  typer.echo("codex supported")
58
60
  typer.echo("gemini supported")
59
61
  typer.echo("claude supported")
@@ -231,6 +233,7 @@ def _collect_snapshots(
231
233
  ) -> list[Snapshot]:
232
234
  provider = "claude" if provider == "claude-code" else provider
233
235
  adapters = {
236
+ "aider": (AiderAdapter, ".aider", "analytics.jsonl"),
234
237
  "codex": (CodexAdapter, ".codex", "sessions"),
235
238
  "gemini": (GeminiAdapter, ".gemini", "tmp"),
236
239
  "claude": (ClaudeAdapter, ".claude", "projects"),
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from cli_consumption.adapters.aider import AiderAdapter
10
+ from cli_consumption.dashboard import generate_dashboard
11
+ from cli_consumption.exporting import export_csv
12
+ from cli_consumption.storage import (
13
+ TABLES,
14
+ create_database_engine,
15
+ ingest_snapshot,
16
+ read_table,
17
+ )
18
+ from cli_consumption.sync import send_snapshot
19
+
20
+ CANARY = "aider privacy canary secret"
21
+ USER_ID = "11111111-2222-4333-8444-555555555555"
22
+
23
+
24
+ def analytics(home: Path, *, complete: bool = True) -> Path:
25
+ events: list[dict[str, Any]] = [
26
+ {
27
+ "event": "launched",
28
+ "properties": {"system_prompt": CANARY},
29
+ "user_id": USER_ID,
30
+ "time": 1_777_300_000,
31
+ },
32
+ {
33
+ "event": "repo",
34
+ "properties": {"path": f"/private/{CANARY}", "num_files": 42},
35
+ "user_id": USER_ID,
36
+ "time": 1_777_300_001,
37
+ },
38
+ {
39
+ "event": "message_send_starting",
40
+ "properties": {"prompt": CANARY},
41
+ "user_id": USER_ID,
42
+ "time": 1_777_300_002,
43
+ },
44
+ {
45
+ "event": "message_send",
46
+ "properties": {
47
+ "main_model": "anthropic/claude-sonnet-4-6",
48
+ "weak_model": CANARY,
49
+ "editor_model": CANARY,
50
+ "edit_format": "diff",
51
+ "prompt_tokens": 100,
52
+ "completion_tokens": 20,
53
+ "total_tokens": 125,
54
+ "cost": 1.25,
55
+ "total_cost": 2.5,
56
+ "secret": CANARY,
57
+ },
58
+ "user_id": USER_ID,
59
+ "time": 1_777_300_004,
60
+ },
61
+ {
62
+ "event": "command_run",
63
+ "properties": {"command": CANARY, "output": CANARY},
64
+ "user_id": USER_ID,
65
+ "time": 1_777_300_005,
66
+ },
67
+ {
68
+ "event": "message_send_starting",
69
+ "properties": {},
70
+ "user_id": USER_ID,
71
+ "time": 1_777_300_006,
72
+ },
73
+ {
74
+ "event": "message_send_exception",
75
+ "properties": {"exception": CANARY, "traceback": CANARY},
76
+ "user_id": USER_ID,
77
+ "time": 1_777_300_007,
78
+ },
79
+ ]
80
+ if complete:
81
+ events.append(
82
+ {
83
+ "event": "exit",
84
+ "properties": {"reason": CANARY},
85
+ "user_id": USER_ID,
86
+ "time": 1_777_300_010,
87
+ }
88
+ )
89
+ path = home / "analytics.jsonl"
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ path.write_text(
92
+ "".join(json.dumps(event) + "\n" for event in events), encoding="utf-8"
93
+ )
94
+ return path
95
+
96
+
97
+ def test_collects_sessions_models_tokens_and_attempts(tmp_path: Path) -> None:
98
+ home = tmp_path / "aider"
99
+ analytics(home)
100
+
101
+ snapshot = AiderAdapter().collect(
102
+ [("laptop", home)], [("must-not-map", "/private")]
103
+ )
104
+
105
+ conversation = snapshot.conversations[0]
106
+ assert conversation["id"].startswith("aider:")
107
+ assert USER_ID not in conversation["id"]
108
+ assert conversation["project"] == "outside-project"
109
+ assert conversation["models"] == ["anthropic/claude-sonnet-4-6"]
110
+ assert conversation["model_calls"] == 1
111
+ assert conversation["tool_calls"] == 0
112
+ assert conversation["input_tokens"] == 100
113
+ assert conversation["uncached_input_tokens"] == 100
114
+ assert conversation["output_tokens"] == 20
115
+ assert conversation["visible_output_tokens"] == 20
116
+ assert conversation["total_tokens"] == 125
117
+ assert conversation["unattributed_tokens"] == 5
118
+ assert [turn["status"] for turn in snapshot.turns] == ["completed", "aborted"]
119
+ assert all(turn["duration_ms"] is None for turn in snapshot.turns)
120
+ assert snapshot.model_calls[0]["turn_id"] == snapshot.turns[0]["id"]
121
+ assert CANARY not in str(snapshot.to_dict())
122
+ assert USER_ID not in str(snapshot.to_dict())
123
+
124
+
125
+ def test_deduplicates_copies_and_handles_malformed_events(tmp_path: Path) -> None:
126
+ first, second = tmp_path / "first", tmp_path / "second"
127
+ analytics(first, complete=False)
128
+ path = analytics(second)
129
+ with path.open("a", encoding="utf-8") as handle:
130
+ handle.write("not-json\n[]\n")
131
+ handle.write('{"event":"bad","time":-1,"properties":{"secret":"')
132
+ handle.write(CANARY + '"}}\n')
133
+
134
+ snapshot = AiderAdapter().collect([("desktop", first), ("laptop", second)])
135
+
136
+ assert snapshot.duplicate_conversations == 1
137
+ assert snapshot.malformed_records == 3
138
+ assert snapshot.conversations[0]["source_machine"] == "laptop"
139
+ assert snapshot.conversations[0]["event_count"] == 8
140
+ assert CANARY not in str(snapshot.to_dict())
141
+ assert (
142
+ snapshot.to_dict()
143
+ == AiderAdapter().collect([("desktop", first), ("laptop", second)]).to_dict()
144
+ )
145
+
146
+ with pytest.raises(ValueError, match="Missing Aider analytics log"):
147
+ AiderAdapter().collect([("machine", tmp_path / "missing")])
148
+
149
+
150
+ def test_privacy_canary_is_absent_from_surfaces(
151
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
152
+ ) -> None:
153
+ home = tmp_path / "aider"
154
+ analytics(home)
155
+ snapshot = AiderAdapter().collect([("machine", home)])
156
+ observed: dict[str, Any] = {}
157
+
158
+ class Response:
159
+ def raise_for_status(self) -> None:
160
+ pass
161
+
162
+ def json(self) -> dict[str, int | str]:
163
+ return {"run_id": "run-1", "written": 1, "skipped": 0}
164
+
165
+ def fake_post(url: str, **kwargs: Any) -> Response:
166
+ observed.update(url=url, **kwargs)
167
+ return Response()
168
+
169
+ monkeypatch.setattr("cli_consumption.sync.httpx.post", fake_post)
170
+ send_snapshot(snapshot, "https://collector.test")
171
+ assert CANARY not in json.dumps(observed["json"])
172
+ assert USER_ID not in json.dumps(observed["json"])
173
+
174
+ engine = create_database_engine(tmp_path / "usage.sqlite")
175
+ try:
176
+ ingest_snapshot(engine, snapshot)
177
+ rows = {name: read_table(engine, name) for name in TABLES}
178
+ assert CANARY not in json.dumps(rows)
179
+ assert USER_ID not in json.dumps(rows)
180
+ output = tmp_path / "reports"
181
+ paths = export_csv(engine, output)
182
+ dashboard = output / "dashboard.html"
183
+ generate_dashboard(engine, dashboard)
184
+ assert all(CANARY not in path.read_text() for path in paths)
185
+ assert all(USER_ID not in path.read_text() for path in paths)
186
+ assert CANARY not in dashboard.read_text()
187
+ assert USER_ID not in dashboard.read_text()
188
+ finally:
189
+ engine.dispose()
@@ -22,6 +22,7 @@ def test_provider_status_is_explicit() -> None:
22
22
  result = runner.invoke(app, ["providers"])
23
23
  assert result.exit_code == 0
24
24
  assert "all auto-detect" in result.stdout
25
+ assert "aider supported" in result.stdout
25
26
  assert "codex supported" in result.stdout
26
27
  assert "gemini supported" in result.stdout
27
28
  assert "claude supported" in result.stdout
@@ -97,6 +98,34 @@ def test_collects_kilo_code(tmp_path: Path) -> None:
97
98
  assert "1 written" in result.stdout
98
99
 
99
100
 
101
+ def test_collects_aider(tmp_path: Path) -> None:
102
+ home = tmp_path / "aider"
103
+ home.mkdir()
104
+ (home / "analytics.jsonl").write_text(
105
+ '{"event":"launched","properties":{},"user_id":"synthetic",'
106
+ '"time":1777300000}\n'
107
+ '{"event":"exit","properties":{},"user_id":"synthetic",'
108
+ '"time":1777300001}\n'
109
+ )
110
+
111
+ result = runner.invoke(
112
+ app,
113
+ [
114
+ "collect",
115
+ "--provider",
116
+ "aider",
117
+ "--source",
118
+ f"desktop={home}",
119
+ "--database",
120
+ str(tmp_path / "aider.sqlite"),
121
+ ],
122
+ )
123
+
124
+ assert result.exit_code == 0, result.output
125
+ assert "Ingestion aider" in result.stdout
126
+ assert "1 written" in result.stdout
127
+
128
+
100
129
  def test_collects_claude_code(tmp_path: Path) -> None:
101
130
  home = tmp_path / "claude"
102
131
  path = home / "projects" / "project" / "session.jsonl"
@@ -56,7 +56,7 @@ wheels = [
56
56
 
57
57
  [[package]]
58
58
  name = "cli-consumption"
59
- version = "0.0.7"
59
+ version = "0.0.8"
60
60
  source = { editable = "." }
61
61
  dependencies = [
62
62
  { name = "fastapi" },
File without changes
File without changes