cli-consumption 0.0.1__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.
- cli_consumption/__init__.py +10 -0
- cli_consumption/__main__.py +4 -0
- cli_consumption/adapters/__init__.py +3 -0
- cli_consumption/adapters/base.py +18 -0
- cli_consumption/adapters/codex.py +591 -0
- cli_consumption/api.py +66 -0
- cli_consumption/cli.py +245 -0
- cli_consumption/dashboard.py +431 -0
- cli_consumption/exporting.py +31 -0
- cli_consumption/models.py +60 -0
- cli_consumption/py.typed +0 -0
- cli_consumption/storage.py +525 -0
- cli_consumption/sync.py +25 -0
- cli_consumption-0.0.1.dist-info/METADATA +192 -0
- cli_consumption-0.0.1.dist-info/RECORD +19 -0
- cli_consumption-0.0.1.dist-info/WHEEL +4 -0
- cli_consumption-0.0.1.dist-info/entry_points.txt +2 -0
- cli_consumption-0.0.1.dist-info/licenses/LICENSE +201 -0
- cli_consumption-0.0.1.dist-info/licenses/NOTICE +4 -0
cli_consumption/api.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, status
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
from sqlalchemy.engine import Engine
|
|
9
|
+
|
|
10
|
+
from cli_consumption import __version__
|
|
11
|
+
from cli_consumption.models import Snapshot
|
|
12
|
+
from cli_consumption.storage import ingest_snapshot, initialize_database
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SnapshotPayload(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="forbid")
|
|
17
|
+
|
|
18
|
+
provider: str
|
|
19
|
+
conversations: list[dict[str, Any]] = Field(default_factory=list)
|
|
20
|
+
turns: list[dict[str, Any]] = Field(default_factory=list)
|
|
21
|
+
model_calls: list[dict[str, Any]] = Field(default_factory=list)
|
|
22
|
+
tool_calls: list[dict[str, Any]] = Field(default_factory=list)
|
|
23
|
+
work_items: list[dict[str, Any]] = Field(default_factory=list)
|
|
24
|
+
context_samples: list[dict[str, Any]] = Field(default_factory=list)
|
|
25
|
+
turn_settings: list[dict[str, Any]] = Field(default_factory=list)
|
|
26
|
+
compaction_events: list[dict[str, Any]] = Field(default_factory=list)
|
|
27
|
+
subagents: list[dict[str, Any]] = Field(default_factory=list)
|
|
28
|
+
malformed_records: int = 0
|
|
29
|
+
duplicate_conversations: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_app(engine: Engine, api_token: str | None = None) -> FastAPI:
|
|
33
|
+
initialize_database(engine)
|
|
34
|
+
app = FastAPI(title="CLI Consumption collector", version=__version__)
|
|
35
|
+
|
|
36
|
+
def authorize(authorization: Annotated[str | None, Header()] = None) -> None:
|
|
37
|
+
if api_token is None:
|
|
38
|
+
return
|
|
39
|
+
expected = f"Bearer {api_token}"
|
|
40
|
+
if authorization is None or not secrets.compare_digest(authorization, expected):
|
|
41
|
+
raise HTTPException(
|
|
42
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
43
|
+
detail="Missing or invalid bearer token",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@app.get("/health")
|
|
47
|
+
def health() -> dict[str, str]:
|
|
48
|
+
return {"status": "ok", "version": __version__}
|
|
49
|
+
|
|
50
|
+
@app.post("/api/v1/snapshots", dependencies=[Depends(authorize)])
|
|
51
|
+
def receive_snapshot(payload: SnapshotPayload) -> dict[str, int | str]:
|
|
52
|
+
try:
|
|
53
|
+
result = ingest_snapshot(engine, Snapshot.from_dict(payload.model_dump()))
|
|
54
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
57
|
+
detail=str(error),
|
|
58
|
+
) from error
|
|
59
|
+
return {
|
|
60
|
+
"run_id": result.run_id,
|
|
61
|
+
"received": result.received,
|
|
62
|
+
"written": result.written,
|
|
63
|
+
"skipped": result.skipped,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return app
|
cli_consumption/cli.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from cli_consumption import __version__
|
|
11
|
+
from cli_consumption.adapters import CodexAdapter
|
|
12
|
+
from cli_consumption.api import create_app
|
|
13
|
+
from cli_consumption.dashboard import generate_dashboard
|
|
14
|
+
from cli_consumption.exporting import export_csv
|
|
15
|
+
from cli_consumption.models import Snapshot
|
|
16
|
+
from cli_consumption.storage import (
|
|
17
|
+
create_database_engine,
|
|
18
|
+
ingest_snapshot,
|
|
19
|
+
initialize_database,
|
|
20
|
+
)
|
|
21
|
+
from cli_consumption.sync import send_snapshot
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="cli-consumption",
|
|
25
|
+
help="Analyze AI coding CLI consumption without exporting conversation content.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def version_callback(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
typer.echo(__version__)
|
|
33
|
+
raise typer.Exit
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def main(
|
|
38
|
+
version: Annotated[
|
|
39
|
+
bool | None,
|
|
40
|
+
typer.Option("--version", callback=version_callback, is_eager=True),
|
|
41
|
+
] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Collect locally, consolidate offline, or send snapshots to an API."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command()
|
|
47
|
+
def providers() -> None:
|
|
48
|
+
"""Show implemented and planned CLI adapters."""
|
|
49
|
+
typer.echo("codex supported")
|
|
50
|
+
for provider in ("claude", "opencode", "kilo", "pi"):
|
|
51
|
+
typer.echo(f"{provider:<8} planned")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def collect(
|
|
56
|
+
source: Annotated[
|
|
57
|
+
list[str] | None,
|
|
58
|
+
typer.Option(
|
|
59
|
+
"--source",
|
|
60
|
+
"-s",
|
|
61
|
+
help="[LABEL=]CODEX_HOME. Repeat to consolidate copied machine data.",
|
|
62
|
+
),
|
|
63
|
+
] = None,
|
|
64
|
+
database: Annotated[
|
|
65
|
+
str,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--database",
|
|
68
|
+
"-d",
|
|
69
|
+
envvar="CLI_CONSUMPTION_DATABASE",
|
|
70
|
+
help="SQLite path or SQLAlchemy PostgreSQL URL.",
|
|
71
|
+
),
|
|
72
|
+
] = "cli-consumption.sqlite",
|
|
73
|
+
provider: Annotated[str, typer.Option(help="CLI provider to collect.")] = "codex",
|
|
74
|
+
project: Annotated[
|
|
75
|
+
list[str] | None,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--project",
|
|
78
|
+
help="NAME=PATH_PREFIX project mapping. Longest matching prefix wins.",
|
|
79
|
+
),
|
|
80
|
+
] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Collect one or more local/copied CLI data directories into SQL storage."""
|
|
83
|
+
snapshot = _collect_snapshot(provider, source, project)
|
|
84
|
+
engine = create_database_engine(database)
|
|
85
|
+
try:
|
|
86
|
+
result = ingest_snapshot(engine, snapshot)
|
|
87
|
+
finally:
|
|
88
|
+
engine.dispose()
|
|
89
|
+
typer.echo(
|
|
90
|
+
f"Ingestion {result.run_id}: {result.written} written, "
|
|
91
|
+
f"{result.skipped} unchanged, {snapshot.malformed_records} malformed skipped."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def sync(
|
|
97
|
+
endpoint: Annotated[
|
|
98
|
+
str,
|
|
99
|
+
typer.Option(
|
|
100
|
+
help="Collector base URL, for example https://usage.example.test."
|
|
101
|
+
),
|
|
102
|
+
],
|
|
103
|
+
source: Annotated[
|
|
104
|
+
list[str] | None,
|
|
105
|
+
typer.Option("--source", "-s", help="[LABEL=]CODEX_HOME. Repeat as needed."),
|
|
106
|
+
] = None,
|
|
107
|
+
provider: Annotated[str, typer.Option(help="CLI provider to collect.")] = "codex",
|
|
108
|
+
project: Annotated[
|
|
109
|
+
list[str] | None,
|
|
110
|
+
typer.Option("--project", help="NAME=PATH_PREFIX project mapping."),
|
|
111
|
+
] = None,
|
|
112
|
+
token_env: Annotated[
|
|
113
|
+
str,
|
|
114
|
+
typer.Option(help="Environment variable containing the API bearer token."),
|
|
115
|
+
] = "CLI_CONSUMPTION_API_TOKEN",
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Collect locally and send metadata-only records to a central collector."""
|
|
118
|
+
snapshot = _collect_snapshot(provider, source, project)
|
|
119
|
+
token = os.environ.get(token_env)
|
|
120
|
+
result = send_snapshot(snapshot, endpoint, token)
|
|
121
|
+
typer.echo(
|
|
122
|
+
f"Remote ingestion {result['run_id']}: {result['written']} written, "
|
|
123
|
+
f"{result['skipped']} unchanged."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("export")
|
|
128
|
+
def export_command(
|
|
129
|
+
database: Annotated[
|
|
130
|
+
str,
|
|
131
|
+
typer.Option("--database", "-d", envvar="CLI_CONSUMPTION_DATABASE"),
|
|
132
|
+
] = "cli-consumption.sqlite",
|
|
133
|
+
output: Annotated[Path, typer.Option("--output", "-o")] = Path("reports"),
|
|
134
|
+
dashboard: Annotated[bool, typer.Option("--dashboard/--no-dashboard")] = True,
|
|
135
|
+
share_safe: Annotated[
|
|
136
|
+
bool,
|
|
137
|
+
typer.Option(
|
|
138
|
+
"--share-safe",
|
|
139
|
+
help=("Write only a pseudonymized dashboard; omit detailed CSV exports."),
|
|
140
|
+
),
|
|
141
|
+
] = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Export normalized SQL tables to CSV and a self-contained HTML dashboard."""
|
|
144
|
+
if share_safe and not dashboard:
|
|
145
|
+
raise typer.BadParameter("--share-safe requires --dashboard")
|
|
146
|
+
if (
|
|
147
|
+
share_safe
|
|
148
|
+
and output.is_dir()
|
|
149
|
+
and any(path.name != "dashboard.html" for path in output.iterdir())
|
|
150
|
+
):
|
|
151
|
+
raise typer.BadParameter(
|
|
152
|
+
"--share-safe output directory must be empty or contain only dashboard.html"
|
|
153
|
+
)
|
|
154
|
+
engine = create_database_engine(database)
|
|
155
|
+
try:
|
|
156
|
+
initialize_database(engine)
|
|
157
|
+
paths = [] if share_safe else export_csv(engine, output)
|
|
158
|
+
if dashboard:
|
|
159
|
+
dashboard_path = output / "dashboard.html"
|
|
160
|
+
generate_dashboard(engine, dashboard_path, share_safe=share_safe)
|
|
161
|
+
paths.append(dashboard_path)
|
|
162
|
+
finally:
|
|
163
|
+
engine.dispose()
|
|
164
|
+
typer.echo(f"Wrote {len(paths)} files to {output.resolve()}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.command()
|
|
168
|
+
def serve(
|
|
169
|
+
database: Annotated[
|
|
170
|
+
str,
|
|
171
|
+
typer.Option("--database", "-d", envvar="CLI_CONSUMPTION_DATABASE"),
|
|
172
|
+
] = "cli-consumption.sqlite",
|
|
173
|
+
host: Annotated[str, typer.Option()] = "127.0.0.1",
|
|
174
|
+
port: Annotated[int, typer.Option()] = 8765,
|
|
175
|
+
token_env: Annotated[
|
|
176
|
+
str,
|
|
177
|
+
typer.Option(help="Environment variable containing the accepted bearer token."),
|
|
178
|
+
] = "CLI_CONSUMPTION_API_TOKEN",
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Run the optional central HTTP collector."""
|
|
181
|
+
import uvicorn
|
|
182
|
+
|
|
183
|
+
token = os.environ.get(token_env)
|
|
184
|
+
if token is None and host not in {"127.0.0.1", "localhost", "::1"}:
|
|
185
|
+
raise typer.BadParameter(
|
|
186
|
+
f"Set {token_env} before exposing the collector beyond localhost."
|
|
187
|
+
)
|
|
188
|
+
if token is None:
|
|
189
|
+
typer.echo(
|
|
190
|
+
"Warning: collector authentication is disabled on localhost.", err=True
|
|
191
|
+
)
|
|
192
|
+
engine = create_database_engine(database)
|
|
193
|
+
uvicorn.run(create_app(engine, token), host=host, port=port)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _collect_snapshot(
|
|
197
|
+
provider: str,
|
|
198
|
+
source_values: list[str] | None,
|
|
199
|
+
project_values: list[str] | None,
|
|
200
|
+
) -> Snapshot:
|
|
201
|
+
if provider != "codex":
|
|
202
|
+
raise typer.BadParameter(
|
|
203
|
+
f"Provider {provider!r} is not implemented yet. Run `providers` for status."
|
|
204
|
+
)
|
|
205
|
+
return CodexAdapter().collect(
|
|
206
|
+
_parse_sources(source_values or []),
|
|
207
|
+
_parse_project_mappings(project_values or []),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parse_sources(values: list[str]) -> list[tuple[str, Path]]:
|
|
212
|
+
if not values:
|
|
213
|
+
values = [f"{platform.node()}={Path.home() / '.codex'}"]
|
|
214
|
+
result: list[tuple[str, Path]] = []
|
|
215
|
+
labels: set[str] = set()
|
|
216
|
+
for index, value in enumerate(values, 1):
|
|
217
|
+
if "=" in value:
|
|
218
|
+
label, raw_path = value.split("=", 1)
|
|
219
|
+
else:
|
|
220
|
+
label, raw_path = f"machine-{index}", value
|
|
221
|
+
label = label.strip()
|
|
222
|
+
path = Path(raw_path).expanduser().resolve()
|
|
223
|
+
if not label or label in labels:
|
|
224
|
+
raise typer.BadParameter(
|
|
225
|
+
f"Source labels must be non-empty and unique: {label!r}"
|
|
226
|
+
)
|
|
227
|
+
if not (path / "sessions").is_dir():
|
|
228
|
+
raise typer.BadParameter(f"Missing sessions directory: {path / 'sessions'}")
|
|
229
|
+
labels.add(label)
|
|
230
|
+
result.append((label, path))
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_project_mappings(values: list[str]) -> list[tuple[str, str]]:
|
|
235
|
+
mappings: list[tuple[str, str]] = []
|
|
236
|
+
for value in values:
|
|
237
|
+
if "=" not in value:
|
|
238
|
+
raise typer.BadParameter(
|
|
239
|
+
f"Project mapping must be NAME=PATH_PREFIX: {value!r}"
|
|
240
|
+
)
|
|
241
|
+
name, prefix = (part.strip() for part in value.split("=", 1))
|
|
242
|
+
if not name or not prefix:
|
|
243
|
+
raise typer.BadParameter(f"Invalid project mapping: {value!r}")
|
|
244
|
+
mappings.append((name, prefix.rstrip("/\\")))
|
|
245
|
+
return mappings
|