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.
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yolo/SKILL.md +7 -5
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/PKG-INFO +19 -3
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/README.md +18 -2
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/docs/architecture.md +2 -2
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/docs/provider-support.md +17 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/pyproject.toml +1 -1
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/__init__.py +2 -0
- cli_consumption-0.0.8/src/cli_consumption/adapters/aider.py +347 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/cli.py +3 -0
- cli_consumption-0.0.8/tests/test_aider_adapter.py +189 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_cli.py +29 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/uv.lock +1 -1
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yeet-github/SKILL.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yolo/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.github/workflows/ci.yml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.github/workflows/release.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.gitignore +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.pre-commit-config.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/.python-version +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/AGENTS.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/CONTRIBUTING.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/LICENSE +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/NOTICE +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/docs/privacy.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/docs/roadmap.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/__init__.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/__main__.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/base.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/claude.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/codex.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/gemini.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/kilo.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/opencode.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/adapters/pi.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/api.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/dashboard.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/exporting.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/models.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/py.typed +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/storage.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/src/cli_consumption/sync.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/conftest.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_api.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_claude_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_codex_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_gemini_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_kilo_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_opencode_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_pi_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.8}/tests/test_storage_and_exports.py +0 -0
- {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.
|
|
14
|
-
3.
|
|
15
|
-
4.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -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"
|
|
File without changes
|
{cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/add-cli-adapter/agents/openai.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/evolve-storage-schema/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cli_consumption-0.0.7 → cli_consumption-0.0.8}/.agents/skills/yeet-github/agents/openai.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|