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/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