cli-consumption 0.0.1__tar.gz → 0.0.2__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.1 → cli_consumption-0.0.2}/.github/workflows/release.yaml +37 -10
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/CONTRIBUTING.md +4 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/PKG-INFO +4 -4
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/README.md +3 -3
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/docs/architecture.md +3 -2
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/docs/privacy.md +2 -1
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/docs/roadmap.md +1 -1
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/pyproject.toml +1 -1
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/cli.py +14 -3
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/dashboard.py +31 -20
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_cli.py +38 -2
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_storage_and_exports.py +7 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/uv.lock +1 -1
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/yeet-github/SKILL.md +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/yolo/SKILL.md +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/yolo/agents/openai.yaml +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.github/workflows/ci.yml +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.gitignore +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.pre-commit-config.yaml +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.python-version +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/AGENTS.md +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/LICENSE +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/NOTICE +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/docs/provider-support.md +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/__init__.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/__main__.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/adapters/__init__.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/adapters/base.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/adapters/codex.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/api.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/exporting.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/models.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/py.typed +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/storage.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/sync.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/conftest.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_api.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_codex_adapter.py +0 -0
- {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_sync.py +0 -0
|
@@ -4,6 +4,12 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
branches: [main]
|
|
6
6
|
paths: [pyproject.toml]
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
release_sha:
|
|
10
|
+
description: Commit on main where the version first appeared
|
|
11
|
+
required: true
|
|
12
|
+
type: string
|
|
7
13
|
|
|
8
14
|
concurrency:
|
|
9
15
|
group: release
|
|
@@ -16,32 +22,49 @@ jobs:
|
|
|
16
22
|
contents: read
|
|
17
23
|
outputs:
|
|
18
24
|
changed: ${{ steps.version.outputs.changed }}
|
|
25
|
+
release-sha: ${{ steps.version.outputs.release-sha }}
|
|
19
26
|
version: ${{ steps.version.outputs.version }}
|
|
20
27
|
steps:
|
|
21
28
|
- uses: actions/checkout@v4
|
|
22
29
|
with:
|
|
23
30
|
fetch-depth: 0
|
|
24
|
-
|
|
25
|
-
with:
|
|
26
|
-
python-version: "3.14"
|
|
31
|
+
ref: ${{ inputs.release_sha || github.sha }}
|
|
27
32
|
- name: Detect project version change
|
|
28
33
|
id: version
|
|
29
34
|
env:
|
|
30
35
|
BEFORE_SHA: ${{ github.event.before }}
|
|
36
|
+
EVENT_NAME: ${{ github.event_name }}
|
|
31
37
|
run: |
|
|
32
38
|
set -euo pipefail
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
release_commit="$(git rev-parse HEAD)"
|
|
41
|
+
if ! git merge-base --is-ancestor "${release_commit}" origin/main; then
|
|
42
|
+
echo "Release commit ${release_commit} is not on main." >&2
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
current_version="$(
|
|
47
|
+
python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])'
|
|
48
|
+
)"
|
|
35
49
|
previous_version=""
|
|
36
|
-
|
|
50
|
+
comparison_sha="${BEFORE_SHA}"
|
|
51
|
+
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
|
|
52
|
+
comparison_sha="${release_commit}^"
|
|
53
|
+
fi
|
|
54
|
+
if git cat-file -e "${comparison_sha}:pyproject.toml" 2>/dev/null; then
|
|
37
55
|
previous_version="$(
|
|
38
|
-
git show "${
|
|
39
|
-
|
|
56
|
+
git show "${comparison_sha}:pyproject.toml" |
|
|
57
|
+
python3 -c 'import sys, tomllib; print(tomllib.load(sys.stdin.buffer)["project"]["version"])'
|
|
40
58
|
)"
|
|
41
59
|
fi
|
|
42
60
|
|
|
61
|
+
echo "release-sha=${release_commit}" >> "${GITHUB_OUTPUT}"
|
|
43
62
|
echo "version=${current_version}" >> "${GITHUB_OUTPUT}"
|
|
44
63
|
if [[ "${current_version}" == "${previous_version}" ]]; then
|
|
64
|
+
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
|
|
65
|
+
echo "Version ${current_version} did not first appear in ${release_commit}." >&2
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
45
68
|
echo "changed=false" >> "${GITHUB_OUTPUT}"
|
|
46
69
|
else
|
|
47
70
|
echo "changed=true" >> "${GITHUB_OUTPUT}"
|
|
@@ -55,6 +78,8 @@ jobs:
|
|
|
55
78
|
contents: read
|
|
56
79
|
steps:
|
|
57
80
|
- uses: actions/checkout@v4
|
|
81
|
+
with:
|
|
82
|
+
ref: ${{ needs.detect-version.outputs.release-sha }}
|
|
58
83
|
- uses: astral-sh/setup-uv@v6
|
|
59
84
|
with:
|
|
60
85
|
enable-cache: true
|
|
@@ -83,8 +108,10 @@ jobs:
|
|
|
83
108
|
- uses: actions/checkout@v4
|
|
84
109
|
with:
|
|
85
110
|
fetch-depth: 0
|
|
111
|
+
ref: ${{ needs.detect-version.outputs.release-sha }}
|
|
86
112
|
- name: Tag version commit
|
|
87
113
|
env:
|
|
114
|
+
RELEASE_SHA: ${{ needs.detect-version.outputs.release-sha }}
|
|
88
115
|
RELEASE_VERSION: ${{ needs.detect-version.outputs.version }}
|
|
89
116
|
run: |
|
|
90
117
|
set -euo pipefail
|
|
@@ -93,14 +120,14 @@ jobs:
|
|
|
93
120
|
git fetch --tags origin
|
|
94
121
|
if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then
|
|
95
122
|
tagged_commit="$(git rev-list -n 1 "${tag}")"
|
|
96
|
-
if [[ "${tagged_commit}" != "${
|
|
97
|
-
echo "Tag ${tag} already points to ${tagged_commit}, not ${
|
|
123
|
+
if [[ "${tagged_commit}" != "${RELEASE_SHA}" ]]; then
|
|
124
|
+
echo "Tag ${tag} already points to ${tagged_commit}, not ${RELEASE_SHA}." >&2
|
|
98
125
|
exit 1
|
|
99
126
|
fi
|
|
100
127
|
else
|
|
101
128
|
git config user.name "github-actions[bot]"
|
|
102
129
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
103
|
-
git tag --annotate "${tag}" --message "Release ${RELEASE_VERSION}" "${
|
|
130
|
+
git tag --annotate "${tag}" --message "Release ${RELEASE_VERSION}" "${RELEASE_SHA}"
|
|
104
131
|
git push origin "refs/tags/${tag}"
|
|
105
132
|
fi
|
|
106
133
|
|
|
@@ -36,3 +36,7 @@ Update the project version with `uv version <version>` and include the resulting
|
|
|
36
36
|
squash-merged into `main`, the release workflow reruns the quality gates, builds the
|
|
37
37
|
distributions, tags the merge commit as `v<version>`, and publishes to PyPI through
|
|
38
38
|
Trusted Publishing. Do not create the release tag manually.
|
|
39
|
+
|
|
40
|
+
If a release fails before publication, rerun it for the original version commit with
|
|
41
|
+
`gh workflow run release.yaml --ref main -f release_sha=<merge-commit>`. The workflow
|
|
42
|
+
rejects commits outside `main` and conflicting existing tags.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cli-consumption
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
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
|
|
@@ -53,7 +53,7 @@ uv run cli-consumption export --output reports
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
Open `reports/dashboard.html` locally. The generated dashboard is self-contained and
|
|
56
|
-
makes no network requests.
|
|
56
|
+
makes no network requests. Detailed normalized CSV tables are opt-in with `--csv`.
|
|
57
57
|
|
|
58
58
|
The dashboard supports time, provider, machine, project, and model filters. It reports
|
|
59
59
|
period-over-period activity, token composition, cache efficiency, turn latency and
|
|
@@ -62,7 +62,7 @@ duration and reliability, configuration cohorts, compactions, subagent delegatio
|
|
|
62
62
|
ingestion quality. Token events are local usage metadata rather than billing records,
|
|
63
63
|
and a technically completed turn is not a measure of task quality or productivity.
|
|
64
64
|
|
|
65
|
-
For a dashboard that is safer to share,
|
|
65
|
+
For a dashboard that is safer to share, pseudonymize
|
|
66
66
|
machine, project, model, and role labels, group tool names, round timestamps to days,
|
|
67
67
|
and hide small rows in the cohort-comparison view:
|
|
68
68
|
|
|
@@ -162,7 +162,7 @@ server. See [Architecture](docs/architecture.md) for trade-offs.
|
|
|
162
162
|
cli-consumption collect Collect local or copied provider data into SQL
|
|
163
163
|
cli-consumption sync Collect and send a snapshot to a central API
|
|
164
164
|
cli-consumption serve Run the central collection API
|
|
165
|
-
cli-consumption export Write
|
|
165
|
+
cli-consumption export Write the HTML dashboard and optional CSV tables
|
|
166
166
|
cli-consumption providers Show supported and planned providers
|
|
167
167
|
```
|
|
168
168
|
|
|
@@ -25,7 +25,7 @@ uv run cli-consumption export --output reports
|
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Open `reports/dashboard.html` locally. The generated dashboard is self-contained and
|
|
28
|
-
makes no network requests.
|
|
28
|
+
makes no network requests. Detailed normalized CSV tables are opt-in with `--csv`.
|
|
29
29
|
|
|
30
30
|
The dashboard supports time, provider, machine, project, and model filters. It reports
|
|
31
31
|
period-over-period activity, token composition, cache efficiency, turn latency and
|
|
@@ -34,7 +34,7 @@ duration and reliability, configuration cohorts, compactions, subagent delegatio
|
|
|
34
34
|
ingestion quality. Token events are local usage metadata rather than billing records,
|
|
35
35
|
and a technically completed turn is not a measure of task quality or productivity.
|
|
36
36
|
|
|
37
|
-
For a dashboard that is safer to share,
|
|
37
|
+
For a dashboard that is safer to share, pseudonymize
|
|
38
38
|
machine, project, model, and role labels, group tool names, round timestamps to days,
|
|
39
39
|
and hide small rows in the cohort-comparison view:
|
|
40
40
|
|
|
@@ -134,7 +134,7 @@ server. See [Architecture](docs/architecture.md) for trade-offs.
|
|
|
134
134
|
cli-consumption collect Collect local or copied provider data into SQL
|
|
135
135
|
cli-consumption sync Collect and send a snapshot to a central API
|
|
136
136
|
cli-consumption serve Run the central collection API
|
|
137
|
-
cli-consumption export Write
|
|
137
|
+
cli-consumption export Write the HTML dashboard and optional CSV tables
|
|
138
138
|
cli-consumption providers Show supported and planned providers
|
|
139
139
|
```
|
|
140
140
|
|
|
@@ -7,7 +7,7 @@ and reporting. This lets users compare multiple AI coding CLIs without forcing t
|
|
|
7
7
|
local formats into one parser.
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
provider files -> adapter -> metadata-only snapshot -> SQL storage -> CSV
|
|
10
|
+
provider files -> adapter -> metadata-only snapshot -> SQL storage -> dashboard/CSV
|
|
11
11
|
|
|
|
12
12
|
+-> HTTPS collector -> central SQL storage
|
|
13
13
|
```
|
|
@@ -21,7 +21,8 @@ provider files -> adapter -> metadata-only snapshot -> SQL storage -> CSV/dashbo
|
|
|
21
21
|
- `storage`: owns the normalized schema, idempotent replacement rules, SQLite, and
|
|
22
22
|
PostgreSQL engine creation.
|
|
23
23
|
- `api` and `sync`: offer an optional push workflow for recurring multi-machine use.
|
|
24
|
-
- `
|
|
24
|
+
- `dashboard` and `exporting`: provide an offline HTML view by default and portable CSV
|
|
25
|
+
tables when explicitly requested.
|
|
25
26
|
- `cli`: exposes the same capabilities through one executable.
|
|
26
27
|
|
|
27
28
|
When Codex exposes its local thread graph, the adapter also records metadata-only
|
|
@@ -59,7 +59,8 @@ and period comparisons are computed inside the document. The remaining project n
|
|
|
59
59
|
machine labels, model names, tool names, roles, token counters, statuses, and activity
|
|
60
60
|
timestamps are still private operational metadata.
|
|
61
61
|
|
|
62
|
-
`export
|
|
62
|
+
The normal `export` command writes only the HTML dashboard; detailed CSV tables require
|
|
63
|
+
the explicit `--csv` option. `export --share-safe` rejects that option. Within its HTML
|
|
63
64
|
document, machine, project, model, and subagent-role labels are replaced with local
|
|
64
65
|
aliases; tools are reduced to broad categories; timestamps are rounded to UTC days;
|
|
65
66
|
and the comparison table hides cohorts smaller than five closed turns. The underlying
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
- Offline multi-machine deduplication
|
|
7
7
|
- SQLite and PostgreSQL storage
|
|
8
8
|
- Metadata-only central collection API
|
|
9
|
-
- CSV exports, a self-contained dashboard, and a share-safe dashboard profile
|
|
9
|
+
- Opt-in CSV exports, a self-contained dashboard, and a share-safe dashboard profile
|
|
10
10
|
- Context-pressure, work-item reliability, configuration-cohort, and delegation views
|
|
11
11
|
|
|
12
12
|
## Next provider increments
|
|
@@ -132,17 +132,28 @@ def export_command(
|
|
|
132
132
|
] = "cli-consumption.sqlite",
|
|
133
133
|
output: Annotated[Path, typer.Option("--output", "-o")] = Path("reports"),
|
|
134
134
|
dashboard: Annotated[bool, typer.Option("--dashboard/--no-dashboard")] = True,
|
|
135
|
+
csv_exports: Annotated[
|
|
136
|
+
bool,
|
|
137
|
+
typer.Option(
|
|
138
|
+
"--csv/--no-csv",
|
|
139
|
+
help="Also write detailed normalized SQL tables as CSV files.",
|
|
140
|
+
),
|
|
141
|
+
] = False,
|
|
135
142
|
share_safe: Annotated[
|
|
136
143
|
bool,
|
|
137
144
|
typer.Option(
|
|
138
145
|
"--share-safe",
|
|
139
|
-
help=
|
|
146
|
+
help="Write a pseudonymized dashboard and reject detailed CSV exports.",
|
|
140
147
|
),
|
|
141
148
|
] = False,
|
|
142
149
|
) -> None:
|
|
143
|
-
"""
|
|
150
|
+
"""Write a self-contained HTML dashboard and optional detailed CSV tables."""
|
|
144
151
|
if share_safe and not dashboard:
|
|
145
152
|
raise typer.BadParameter("--share-safe requires --dashboard")
|
|
153
|
+
if share_safe and csv_exports:
|
|
154
|
+
raise typer.BadParameter("--share-safe cannot be combined with --csv")
|
|
155
|
+
if not dashboard and not csv_exports:
|
|
156
|
+
raise typer.BadParameter("enable --dashboard or --csv")
|
|
146
157
|
if (
|
|
147
158
|
share_safe
|
|
148
159
|
and output.is_dir()
|
|
@@ -154,7 +165,7 @@ def export_command(
|
|
|
154
165
|
engine = create_database_engine(database)
|
|
155
166
|
try:
|
|
156
167
|
initialize_database(engine)
|
|
157
|
-
paths =
|
|
168
|
+
paths = export_csv(engine, output) if csv_exports else []
|
|
158
169
|
if dashboard:
|
|
159
170
|
dashboard_path = output / "dashboard.html"
|
|
160
171
|
generate_dashboard(engine, dashboard_path, share_safe=share_safe)
|
|
@@ -309,45 +309,55 @@ def _document(payload: str) -> str:
|
|
|
309
309
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
310
310
|
<title>CLI Consumption</title>
|
|
311
311
|
<style>
|
|
312
|
-
:root {{ color-scheme:
|
|
312
|
+
:root {{ color-scheme:light; --bg:#f7f8fa; --ink:#172033; --muted:#64748b; --accent:#087f6d; --blue:#2563eb; --violet:#7c3aed; --amber:#b45309; --red:#be123c; --line:#d7dde7; --control:#fff; --track:#e3e8ef; --hover:#edf1f6; }}
|
|
313
|
+
@media (prefers-color-scheme:dark) {{ :root:not([data-theme]) {{ color-scheme:dark; --bg:#09111d; --ink:#edf4fc; --muted:#94a3b8; --accent:#5eead4; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#fb7185; --line:#26364a; --control:#111c2b; --track:#1b2a3d; --hover:#142235; }} }}
|
|
314
|
+
:root[data-theme="dark"] {{ color-scheme:dark; --bg:#09111d; --ink:#edf4fc; --muted:#94a3b8; --accent:#5eead4; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#fb7185; --line:#26364a; --control:#111c2b; --track:#1b2a3d; --hover:#142235; }}
|
|
315
|
+
:root[data-theme="light"] {{ color-scheme:light; --bg:#f7f8fa; --ink:#172033; --muted:#64748b; --accent:#087f6d; --blue:#2563eb; --violet:#7c3aed; --amber:#b45309; --red:#be123c; --line:#d7dde7; --control:#fff; --track:#e3e8ef; --hover:#edf1f6; }}
|
|
313
316
|
* {{ box-sizing:border-box }}
|
|
314
|
-
body {{ margin:0; background:
|
|
315
|
-
main {{ max-width:1440px; margin:auto; padding:
|
|
316
|
-
h1 {{ margin:0; font-size:clamp(
|
|
317
|
+
body {{ margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 Inter,ui-sans-serif,system-ui,sans-serif }}
|
|
318
|
+
main {{ max-width:1440px; margin:auto; padding:30px 24px 64px }}
|
|
319
|
+
h1 {{ margin:0; font-size:clamp(30px,4vw,48px); letter-spacing:-.04em; white-space:nowrap }}
|
|
317
320
|
h2 {{ margin:0; font-size:18px }} h3 {{ margin:0 0 12px; font-size:14px; color:var(--muted) }}
|
|
321
|
+
.header {{ display:grid; grid-template-columns:auto minmax(280px,1fr) auto; gap:28px; align-items:center }}
|
|
322
|
+
.header-copy {{ max-width:760px }}
|
|
318
323
|
.eyebrow {{ color:var(--accent); text-transform:uppercase; letter-spacing:.15em; font-size:12px; font-weight:800 }}
|
|
319
|
-
.subtitle,.muted,.definition {{ color:var(--muted) }} .subtitle {{
|
|
324
|
+
.subtitle,.muted,.definition {{ color:var(--muted) }} .subtitle {{ margin:4px 0 0 }}
|
|
325
|
+
.theme-toggle {{ border:1px solid var(--line); background:var(--control); color:var(--ink); border-radius:999px; padding:9px 13px; cursor:pointer; font:inherit; white-space:nowrap }}
|
|
326
|
+
.theme-toggle:hover {{ background:var(--hover) }}
|
|
320
327
|
.filters,.cards,.grid,.metric-grid,.quality-grid {{ display:grid; gap:14px }}
|
|
321
328
|
.filters {{ grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); margin:28px 0 14px }}
|
|
322
329
|
.custom-dates {{ display:none; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:14px }} .custom-dates.visible {{ display:grid }}
|
|
323
330
|
.cards {{ grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); margin:18px 0 }}
|
|
324
331
|
.grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); margin-bottom:14px }}
|
|
325
332
|
.metric-grid,.quality-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)) }}
|
|
326
|
-
.panel,.card
|
|
327
|
-
|
|
333
|
+
.panel,.card {{ border:0; background:var(--bg) }}
|
|
334
|
+
select,input {{ border:1px solid var(--line); background:var(--control); border-radius:12px }}
|
|
335
|
+
.panel {{ padding:18px 0; overflow:auto }} .panel.full {{ margin-bottom:14px }} .card {{ padding:14px 0 }}
|
|
328
336
|
.panel-head {{ display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:17px }}
|
|
329
|
-
.card
|
|
337
|
+
.card-label {{ display:flex; align-items:center; gap:6px; color:var(--muted) }}
|
|
338
|
+
.card strong {{ display:block; font-size:27px; letter-spacing:-.03em }} label {{ color:var(--muted) }}
|
|
339
|
+
.info {{ position:relative; width:18px; height:18px; border:1px solid var(--line); border-radius:50%; background:transparent; color:var(--muted); padding:0; cursor:help; font:700 11px/16px Inter,ui-sans-serif,system-ui,sans-serif }}
|
|
340
|
+
.info::after {{ content:attr(data-tooltip); position:absolute; z-index:5; left:50%; bottom:calc(100% + 8px); width:min(280px,70vw); padding:9px 11px; border:1px solid var(--line); border-radius:9px; background:var(--control); color:var(--ink); font:12px/1.4 Inter,ui-sans-serif,system-ui,sans-serif; text-align:left; text-transform:none; letter-spacing:0; box-shadow:0 8px 24px rgb(0 0 0 / .16); opacity:0; pointer-events:none; transform:translate(-50%,4px); transition:opacity .12s,transform .12s }}
|
|
341
|
+
.info:hover::after,.info:focus-visible::after {{ opacity:1; transform:translate(-50%,0) }}
|
|
330
342
|
.delta {{ font-size:12px; margin-top:4px }} .delta.better {{ color:var(--accent) }} .delta.worse {{ color:var(--red) }} .delta.neutral {{ color:var(--muted) }}
|
|
331
343
|
.badge {{ display:none; width:max-content; margin-top:10px; padding:5px 9px; border:1px solid var(--accent); border-radius:99px; color:var(--accent); font-size:11px; text-transform:uppercase; letter-spacing:.08em }} .badge.visible {{ display:block }}
|
|
332
344
|
label {{ display:grid; gap:6px; font-size:11px; text-transform:uppercase; letter-spacing:.08em }}
|
|
333
345
|
select,input {{ color:var(--ink); padding:10px 12px; width:100% }}
|
|
334
346
|
table {{ width:100%; border-collapse:collapse; white-space:nowrap }} th,td {{ padding:9px 8px; border-bottom:1px solid var(--line); text-align:left }}
|
|
335
|
-
th {{ color:var(--muted); font-size:11px; text-transform:uppercase; cursor:pointer }} tbody tr:hover {{ background
|
|
347
|
+
th {{ color:var(--muted); font-size:11px; text-transform:uppercase; cursor:pointer }} tbody tr:hover {{ background:var(--hover) }}
|
|
336
348
|
.bars {{ display:grid; gap:10px }} .bar {{ display:grid; grid-template-columns:minmax(110px,1.2fr) 4fr auto; gap:10px; align-items:center }}
|
|
337
|
-
.track {{ height:11px; display:flex; background
|
|
349
|
+
.track {{ height:11px; display:flex; background:var(--track); border-radius:99px; overflow:hidden }} .fill {{ height:100%; background:var(--blue) }}
|
|
338
350
|
.seg-cache {{ background:var(--blue) }} .seg-uncached {{ background:var(--violet) }} .seg-visible {{ background:var(--accent) }} .seg-reasoning {{ background:var(--amber) }} .seg-other {{ background:var(--red) }}
|
|
339
351
|
.legend {{ display:flex; flex-wrap:wrap; gap:12px; margin:0 0 15px; color:var(--muted); font-size:12px }} .legend i {{ display:inline-block; width:9px; height:9px; border-radius:2px; margin-right:5px }}
|
|
340
|
-
.stat {{ padding:12px
|
|
341
|
-
.status {{ display:flex; height:13px; border-radius:99px; overflow:hidden; background
|
|
352
|
+
.stat {{ padding:12px 0; border:0; background:var(--bg) }} .stat b {{ display:block; font-size:20px }}
|
|
353
|
+
.status {{ display:flex; height:13px; border-radius:99px; overflow:hidden; background:var(--track); margin:12px 0 }} .completed {{ background:var(--accent) }} .aborted {{ background:var(--red) }} .progress {{ background:var(--amber) }}
|
|
342
354
|
.empty {{ color:var(--muted); padding:26px 0 }} .definition {{ font-size:12px; margin:12px 0 0 }} footer {{ color:var(--muted); margin-top:26px }}
|
|
343
355
|
details {{ border-top:1px solid var(--line); margin-top:18px; padding-top:14px }} summary {{ cursor:pointer; color:var(--muted) }}
|
|
344
|
-
@media (max-width:850px) {{ .grid {{ grid-template-columns:1fr }} .bar {{ grid-template-columns:minmax(90px,1fr) 2fr auto }} }}
|
|
356
|
+
@media (max-width:850px) {{ .header {{ grid-template-columns:1fr auto; gap:12px }} .header-copy {{ grid-column:1 / -1; grid-row:2 }} .grid {{ grid-template-columns:1fr }} .bar {{ grid-template-columns:minmax(90px,1fr) 2fr auto }} }}
|
|
345
357
|
</style>
|
|
346
358
|
</head>
|
|
347
359
|
<body><main>
|
|
348
|
-
<div class="eyebrow">Local-first AI CLI observability</div>
|
|
349
|
-
<h1>CLI Consumption</h1>
|
|
350
|
-
<p class="subtitle">Understand activity, responsiveness, token composition, and workflows without exporting prompts, responses, or tool arguments.</p>
|
|
360
|
+
<header class="header"><h1>CLI Consumption</h1><div class="header-copy"><div class="eyebrow">Local-first AI CLI observability</div><p class="subtitle">Understand activity, responsiveness, token composition, and workflows without exporting prompts, responses, or tool arguments.</p></div><button class="theme-toggle" id="themeToggle" type="button" aria-label="Switch color theme">Theme</button></header>
|
|
351
361
|
<div class="badge" id="privacyBadge">Share-safe dashboard</div>
|
|
352
362
|
<section class="filters">
|
|
353
363
|
<label>Period<select id="period"><option value="all">All history</option><option value="7">Latest 7 days</option><option value="30">Latest 30 days</option><option value="90">Latest 90 days</option><option value="custom">Custom range</option></select></label>
|
|
@@ -360,11 +370,11 @@ def _document(payload: str) -> str:
|
|
|
360
370
|
<section class="cards" id="cards"></section>
|
|
361
371
|
<section class="grid">
|
|
362
372
|
<article class="panel"><div class="panel-head"><h2>Activity and token composition</h2><span class="muted" id="activityCaption"></span></div><div class="legend"><span><i class="seg-cache"></i>Cached input</span><span><i class="seg-uncached"></i>Uncached input</span><span><i class="seg-visible"></i>Visible output</span><span><i class="seg-reasoning"></i>Reasoning</span><span><i class="seg-other"></i>Unattributed</span></div><div class="bars" id="activity"></div></article>
|
|
363
|
-
<article class="panel"><div class="panel-head"><h2>
|
|
373
|
+
<article class="panel"><div class="panel-head"><h2>Tools</h2><span class="muted">names only</span></div><h3>Top tools</h3><div class="bars" id="tools"></div><h3 style="margin-top:20px">Categories</h3><div class="bars" id="toolCategories"></div><h3 style="margin-top:20px">Frequent transitions</h3><div class="bars" id="toolTransitions"></div></article>
|
|
364
374
|
</section>
|
|
365
375
|
<section class="grid">
|
|
366
376
|
<article class="panel"><div class="panel-head"><h2>Models</h2><span class="muted">tokens and efficiency</span></div><div id="models"></div></article>
|
|
367
|
-
<article class="panel"><div class="panel-head"><h2>
|
|
377
|
+
<article class="panel"><div class="panel-head"><h2>Turn performance</h2><span class="muted">p50 / p75 / p95</span></div><div id="performance"></div><p class="definition">Duration is provider-reported. Technical throughput is closed turns per active hour: overlapping intervals are merged in detailed reports, while share-safe reports sum turn durations. It is not a productivity score.</p></article>
|
|
368
378
|
</section>
|
|
369
379
|
<section class="grid">
|
|
370
380
|
<article class="panel"><div class="panel-head"><h2>Workflow complexity</h2><span class="muted">turns, context, delegation</span></div><div class="metric-grid" id="workflow"></div><h3 style="margin-top:20px">Subagent roles</h3><div class="bars" id="agentRoles"></div></article>
|
|
@@ -403,7 +413,7 @@ function activeMs(turns){{if(data.meta.shareSafe)return total(turns,'durationMs'
|
|
|
403
413
|
function maxConcurrent(turns){{if(data.meta.shareSafe)return null;const byMachine={{}};turns.forEach(t=>{{const c=convByKey[t.conversationKey];if(!c||!validDate(t.startedAt)||!validDate(t.endedAt))return;(byMachine[c.machine]??=[]).push([Date.parse(t.startedAt),1],[Date.parse(t.endedAt),-1]);}});let peak=0;Object.values(byMachine).forEach(points=>{{points.sort((a,b)=>a[0]-b[0]||a[1]-b[1]);let active=0;points.forEach(([,change])=>{{active+=change;peak=Math.max(peak,active);}});}});return peak;}}
|
|
404
414
|
function metrics(s){{const closed=s.turns.filter(t=>t.status==='completed'||t.status==='aborted'),closedKeys=new Set(closed.map(t=>t.key)),closedCalls=s.calls.filter(c=>c.turnKey!==null&&closedKeys.has(c.turnKey)),durations=closed.map(t=>t.durationMs).filter(x=>x!==null),ttfts=closed.map(t=>t.ttftMs).filter(x=>x!==null),turnTokens=closed.map(t=>t.total_tokens),turnTools=closed.map(t=>t.toolCalls),input=total(closedCalls,'input_tokens'),cached=total(closedCalls,'cached_input_tokens'),output=total(closedCalls,'output_tokens'),tokens=total(closedCalls,'total_tokens'),active=activeMs(closed),pressures=s.contexts.map(c=>100*Number(c.inputTokens)/Number(c.contextWindowTokens)).filter(Number.isFinite),activeDays=new Set(s.turns.map(t=>day(t.startedAt)).filter(x=>x!=='unknown')).size;return{{turns:s.turns.length,completed:s.turns.filter(t=>t.status==='completed').length,aborted:s.turns.filter(t=>t.status==='aborted').length,tokens,tokensPerTurn:percentile(turnTokens,.5),toolsPerTurn:percentile(turnTools,.5),cacheRate:ratio(cached,input),durationP50:percentile(durations,.5),durationP75:percentile(durations,.75),durationP95:percentile(durations,.95),ttftP50:percentile(ttfts,.5),ttftP75:percentile(ttfts,.75),ttftP95:percentile(ttfts,.95),tokenP75:percentile(turnTokens,.75),tokenP95:percentile(turnTokens,.95),toolP75:percentile(turnTools,.75),toolP95:percentile(turnTools,.95),abortRate:ratio(s.turns.filter(t=>t.status==='aborted').length,closed.length),reasoningShare:ratio(total(closedCalls,'reasoning_output_tokens'),output),activeMs:active,throughput:active?3600000*closed.length/active:0,pressureP50:percentile(pressures,.5),pressureP95:percentile(pressures,.95),activeDays}};}}
|
|
405
415
|
function delta(current,previous,preference='neutral'){{if(previous===null||previous===undefined||!Number.isFinite(previous)||previous===0)return'';const change=100*(current-previous)/Math.abs(previous),better=preference==='higher'?change>0:preference==='lower'?change<0:null,style=better===null?'neutral':better?'better':'worse';return`<div class="delta ${{style}}">${{change>=0?'+':''}}${{change.toFixed(1)}}% vs previous period</div>`;}}
|
|
406
|
-
function card(label,value,current,previous,preference='neutral'){{return`<div class="card"><span>${{label}}</span><strong>${{value}}</strong>${{delta(current,previous,preference)}}</div>`;}}
|
|
416
|
+
function card(label,value,current,previous,tooltip,preference='neutral'){{return`<div class="card"><div class="card-label"><span>${{label}}</span><button class="info" type="button" aria-label="${{escapeHtml(label)}}: ${{escapeHtml(tooltip)}}" data-tooltip="${{escapeHtml(tooltip)}}" title="${{escapeHtml(tooltip)}}">i</button></div><strong>${{value}}</strong>${{delta(current,previous,preference)}}</div>`;}}
|
|
407
417
|
function stat(label,value,detail=''){{return`<div class="stat"><span class="muted">${{label}}</span><b>${{value}}</b>${{detail?`<small class="muted">${{detail}}</small>`:''}}</div>`;}}
|
|
408
418
|
function group(rows,key,value=null){{const out={{}};rows.forEach(r=>{{const label=r[key]||'unknown';out[label]=(out[label]||0)+(value?(Number(r[value])||0):1);}});return Object.entries(out).sort((a,b)=>b[1]-a[1]);}}
|
|
409
419
|
function toolCategory(name){{const categories=['Agent coordination','Shell and processes','Files and workspace','Web','Planning','Media','Integrations','Other'];if(categories.includes(name))return name;if(['spawn_agent','wait_agent','list_agents','send_message','followup_task','interrupt_agent'].includes(name))return'Agent coordination';if(['exec_command','write_stdin','wait'].includes(name))return'Shell and processes';if(['apply_patch','view_image'].includes(name))return'Files and workspace';if(name==='web__run')return'Web';if(['update_plan','create_goal','get_goal','update_goal'].includes(name))return'Planning';if(name.includes('imagegen'))return'Media';if(name.startsWith('mcp__')||name.includes('mcp_resource'))return'Integrations';return'Other';}}
|
|
@@ -423,9 +433,10 @@ function sortExplorer(key){{if(tableSort.key===key)tableSort.direction*=-1;else
|
|
|
423
433
|
function renderTable(conversations,turns){{const statuses={{}};turns.forEach(t=>{{const s=statuses[t.conversationKey]??={{completed:0,aborted:0,progress:0}};if(t.status==='completed')s.completed++;else if(t.status==='aborted')s.aborted++;else s.progress++;}});const outcome=c=>{{const s=statuses[c.key];return s?.aborted?'contains abort':s?.progress?'in progress':s?.completed?'technically closed':'unknown';}},value=(c,key)=>key==='models'?c.models.join(', '):key==='outcome'?outcome(c):c[key];const rows=conversations.slice().sort((a,b)=>{{const av=value(a,tableSort.key)??'',bv=value(b,tableSort.key)??'';return tableSort.direction*(typeof av==='number'&&typeof bv==='number'?av-bv:String(av).localeCompare(String(bv)));}});$('conversationCount').textContent=`${{rows.length}} conversations`;$('conversationDetail').innerHTML='';$('table').innerHTML=rows.length?`<table><thead><tr><th onclick="sortExplorer('startedAt')">Started</th><th onclick="sortExplorer('provider')">Provider</th><th onclick="sortExplorer('machine')">Machine</th><th onclick="sortExplorer('project')">Project</th><th onclick="sortExplorer('models')">Models</th><th onclick="sortExplorer('turns')">Turns</th><th onclick="sortExplorer('outcome')">Technical status</th><th onclick="sortExplorer('compactions')">Compactions</th><th onclick="sortExplorer('total_tokens')">Tokens</th><th onclick="sortExplorer('durationSeconds')">Wall duration</th></tr></thead><tbody>${{rows.map(c=>`<tr style="cursor:pointer" onclick="showConversation(${{c.key}})"><td>${{escapeHtml(c.startedAt||'')}}</td><td>${{escapeHtml(c.provider)}}</td><td>${{escapeHtml(c.machine)}}</td><td>${{escapeHtml(c.project)}}</td><td>${{escapeHtml(c.models.join(', '))}}</td><td>${{fmt(c.turns)}}</td><td>${{outcome(c)}}</td><td>${{fmt(c.compactions)}}</td><td>${{fmt(c.total_tokens)}}</td><td>${{formatDuration((Number(c.durationSeconds)||0)*1000)}}</td></tr>`).join('')}}</tbody></table>`:'<div class="empty">No conversations match these filters.</div>';}}
|
|
424
434
|
function showConversation(key){{if(!currentSlice)return;const c=convByKey[key],turns=currentSlice.turns.filter(t=>t.conversationKey===key),calls=currentSlice.calls.filter(x=>x.conversationKey===key),tools=currentSlice.tools.filter(x=>x.conversationKey===key),work=currentSlice.work.filter(x=>x.conversationKey===key),contexts=currentSlice.contexts.filter(x=>x.conversationKey===key),closed=turns.filter(t=>t.status==='completed'||t.status==='aborted'),durations=closed.map(t=>t.durationMs).filter(x=>x!==null),ttfts=closed.map(t=>t.ttftMs).filter(x=>x!==null),pressures=contexts.map(x=>100*Number(x.inputTokens)/Number(x.contextWindowTokens));$('conversationDetail').innerHTML=`<details open><summary>${{escapeHtml(c.project)}} · ${{escapeHtml(c.startedAt||'')}}</summary><div class="quality-grid" style="margin-top:14px">${{stat('Turns in range',fmt(turns.length))}}${{stat('Tokens in range',fmt(total(calls,'total_tokens')))}}${{stat('Median TTFT',formatDuration(percentile(ttfts,.5)))}}${{stat('Median duration',formatDuration(percentile(durations,.5)))}}${{stat('Context p95',pct(percentile(pressures,.95)))}}${{stat('Technical work items',fmt(work.length))}}</div><h3 style="margin-top:16px">Tools in this conversation</h3><div class="bars">${{group(tools,'tool').slice(0,8).map(([name,count])=>`<div class="bar"><span>${{escapeHtml(name)}}</span><div class="track"><div class="fill" style="width:${{100*count/Math.max(tools.length,1)}}%"></div></div><b>${{fmt(count)}}</b></div>`).join('')||'<div class="empty">No tool calls.</div>'}}</div></details>`;}}
|
|
425
435
|
function formatDuration(ms){{if(ms===null||ms===undefined||!Number.isFinite(Number(ms)))return'—';const seconds=Number(ms)/1000;if(seconds<60)return`${{seconds.toFixed(1)}}s`;if(seconds<3600)return`${{(seconds/60).toFixed(1)}}m`;return`${{(seconds/3600).toFixed(1)}}h`;}}
|
|
426
|
-
function render(){{const f=selected(),s=slice(f),m=metrics(s),previous=f.range?.previous?metrics(slice(f,f.range.previous)):null,closed=s.turns.filter(t=>t.status==='completed'||t.status==='aborted'),delegation=delegationStats(s),peak=maxConcurrent(closed),compacted=new Set(s.compactions.map(c=>c.conversationKey)).size,delegated=new Set(s.subagents.map(x=>x.conversationKey)).size,subTokens=total(s.subagents,'tokens');currentSlice=s;$('cards').innerHTML=card('Closed turns',fmt(m.completed+m.aborted),m.completed+m.aborted,previous?previous.completed+previous.aborted:null)+card('Active days',fmt(m.activeDays),m.activeDays,previous?.activeDays,'higher')+card('Total tokens',short(m.tokens),m.tokens,previous?.tokens)+card('Median tokens / turn',short(m.tokensPerTurn),m.tokensPerTurn,previous?.tokensPerTurn)+card('Median TTFT',formatDuration(m.ttftP50),m.ttftP50,previous?.ttftP50,'lower')+card('Median duration',formatDuration(m.durationP50),m.durationP50,previous?.durationP50,'lower')+card('Technical throughput',m.throughput.toFixed(1)+'/h',m.throughput,previous?.throughput,'higher')+card('Context pressure p95',pct(m.pressureP95),m.pressureP95,previous?.pressureP95,'lower')+card(
|
|
436
|
+
function render(){{const f=selected(),s=slice(f),m=metrics(s),previous=f.range?.previous?metrics(slice(f,f.range.previous)):null,closed=s.turns.filter(t=>t.status==='completed'||t.status==='aborted'),delegation=delegationStats(s),peak=maxConcurrent(closed),compacted=new Set(s.compactions.map(c=>c.conversationKey)).size,delegated=new Set(s.subagents.map(x=>x.conversationKey)).size,subTokens=total(s.subagents,'tokens'),activeLabel=data.meta.shareSafe?'Summed turn time':'Active time',activeHelp=data.meta.shareSafe?'Sum of provider-reported durations for closed turns; exact overlap cannot be recovered from day-rounded timestamps.':'Union of provider-reported closed-turn intervals per machine, so overlapping turns are counted once.';currentSlice=s;$('cards').innerHTML=card('Closed turns',fmt(m.completed+m.aborted),m.completed+m.aborted,previous?previous.completed+previous.aborted:null,'Turns whose provider status is completed or aborted in the selected range.')+card('Active days',fmt(m.activeDays),m.activeDays,previous?.activeDays,'Distinct UTC calendar days containing at least one turn in the selected range.','higher')+card('Total tokens',short(m.tokens),m.tokens,previous?.tokens,'Provider-reported total tokens from model usage events attributed to closed turns; this is not billing data.')+card('Median tokens / turn',short(m.tokensPerTurn),m.tokensPerTurn,previous?.tokensPerTurn,'Median provider-reported total-token count across completed and aborted turns.')+card('Median TTFT',formatDuration(m.ttftP50),m.ttftP50,previous?.ttftP50,'Median provider-reported time to first token across closed turns with TTFT data.','lower')+card('Median duration',formatDuration(m.durationP50),m.durationP50,previous?.durationP50,'Median provider-reported elapsed duration across completed and aborted turns with duration data.','lower')+card('Technical throughput',m.throughput.toFixed(1)+'/h',m.throughput,previous?.throughput,'Completed and aborted turns divided by active hours. This is a technical activity rate, not a productivity score.','higher')+card('Context pressure p95',pct(m.pressureP95),m.pressureP95,previous?.pressureP95,'95th percentile of latest model-call input tokens divided by the reported context-window size.','lower')+card(activeLabel,formatDuration(m.activeMs),m.activeMs,previous?.activeMs,activeHelp);drawActivity(s.calls,f.range);renderPerformance(m);renderModels(s.calls);drawBars('tools',group(s.tools,'tool'));drawBars('toolCategories',group(s.tools.map(t=>({{category:toolCategory(t.tool)}})),'category'));drawBars('toolTransitions',toolTransitions(s.tools));$('workflow').innerHTML=stat('Turns using tools',pct(ratio(s.turns.filter(t=>t.toolCalls>0).length,s.turns.length)))+stat('Compacted conversations',pct(ratio(compacted,s.conversations.length)))+stat('Delegating conversations',pct(ratio(delegated,s.conversations.length)),`${{fmt(s.subagents.length)}} edges`)+stat('Mapped child threads',fmt(delegation.mapped),`${{fmt(delegation.closed)}} technically closed`)+stat('Max delegation fan-out',fmt(delegation.maxFanout))+stat('Max delegation depth',fmt(delegation.maxDepth))+stat('Reported subagent tokens',short(subTokens),'may overlap parent totals')+stat('Peak concurrent turns',peak===null?'Hidden':fmt(peak),data.meta.shareSafe?'exact times rounded':'per-machine peak');drawBars('agentRoles',group(s.subagents,'role'));renderOutcomes(s.turns);renderContext(s,m);renderWork(s);renderCohorts(s);renderQuality(s,f);renderTable(s.conversations,s.turns);}}
|
|
427
437
|
options('provider',data.conversations.map(c=>c.provider));options('machine',data.conversations.map(c=>c.machine));options('project',data.conversations.map(c=>c.project));options('model',data.modelCalls.map(c=>c.model));
|
|
428
438
|
const dates=data.conversations.map(c=>c.startedAt).filter(validDate).sort();if(dates.length){{$('from').value=dates[0].slice(0,10);$('to').value=dates.at(-1).slice(0,10);}}
|
|
429
439
|
if(data.meta.shareSafe)$('privacyBadge').classList.add('visible');
|
|
440
|
+
const themeButton=$('themeToggle');let savedTheme=null;try{{savedTheme=localStorage.getItem('cli-consumption-theme');}}catch{{}}if(savedTheme==='light'||savedTheme==='dark')document.documentElement.dataset.theme=savedTheme;function currentTheme(){{return document.documentElement.dataset.theme||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');}}function updateThemeButton(){{const theme=currentTheme(),next=theme==='dark'?'light':'dark';themeButton.textContent=theme==='dark'?'Light mode':'Dark mode';themeButton.setAttribute('aria-label',`Switch to ${{next}} mode`);}}themeButton.addEventListener('click',()=>{{const next=currentTheme()==='dark'?'light':'dark';document.documentElement.dataset.theme=next;try{{localStorage.setItem('cli-consumption-theme',next);}}catch{{}}updateThemeButton();}});updateThemeButton();
|
|
430
441
|
document.querySelectorAll('select,input').forEach(e=>e.addEventListener('change',()=>{{$('customDates').classList.toggle('visible',$('period').value==='custom');render();}}));render();
|
|
431
442
|
</script></body></html>"""
|
|
@@ -10,6 +10,11 @@ from cli_consumption.cli import app
|
|
|
10
10
|
runner = CliRunner()
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def normalized_cli_output(output: str) -> str:
|
|
14
|
+
"""Remove terminal formatting and wrapping from CLI output assertions."""
|
|
15
|
+
return " ".join(strip_ansi(output).split())
|
|
16
|
+
|
|
17
|
+
|
|
13
18
|
def test_provider_status_is_explicit() -> None:
|
|
14
19
|
result = runner.invoke(app, ["providers"])
|
|
15
20
|
assert result.exit_code == 0
|
|
@@ -54,6 +59,24 @@ def test_collect_and_export(tmp_path: Path, rollout_factory) -> None:
|
|
|
54
59
|
)
|
|
55
60
|
assert result.exit_code == 0, result.output
|
|
56
61
|
assert (reports / "dashboard.html").is_file()
|
|
62
|
+
assert [path.name for path in reports.iterdir()] == ["dashboard.html"]
|
|
63
|
+
|
|
64
|
+
csv_reports = tmp_path / "csv-reports"
|
|
65
|
+
result = runner.invoke(
|
|
66
|
+
app,
|
|
67
|
+
[
|
|
68
|
+
"export",
|
|
69
|
+
"--database",
|
|
70
|
+
str(database),
|
|
71
|
+
"--output",
|
|
72
|
+
str(csv_reports),
|
|
73
|
+
"--csv",
|
|
74
|
+
"--no-dashboard",
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
assert result.exit_code == 0, result.output
|
|
78
|
+
assert (csv_reports / "conversations.csv").is_file()
|
|
79
|
+
assert not (csv_reports / "dashboard.html").exists()
|
|
57
80
|
|
|
58
81
|
safe_reports = tmp_path / "safe-reports"
|
|
59
82
|
result = runner.invoke(
|
|
@@ -97,5 +120,18 @@ def test_collect_and_export(tmp_path: Path, rollout_factory) -> None:
|
|
|
97
120
|
],
|
|
98
121
|
)
|
|
99
122
|
assert result.exit_code == 2
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
assert "requires --dashboard" in normalized_cli_output(result.output)
|
|
124
|
+
|
|
125
|
+
result = runner.invoke(
|
|
126
|
+
app,
|
|
127
|
+
["export", "--database", str(database), "--share-safe", "--csv"],
|
|
128
|
+
)
|
|
129
|
+
assert result.exit_code == 2
|
|
130
|
+
assert "cannot be combined" in normalized_cli_output(result.output)
|
|
131
|
+
|
|
132
|
+
result = runner.invoke(
|
|
133
|
+
app,
|
|
134
|
+
["export", "--database", str(database), "--no-dashboard"],
|
|
135
|
+
)
|
|
136
|
+
assert result.exit_code == 2
|
|
137
|
+
assert "enable --dashboard or --csv" in normalized_cli_output(result.output)
|
|
@@ -56,6 +56,13 @@ def test_ingestion_is_idempotent_and_exports_are_self_contained(
|
|
|
56
56
|
html = dashboard.read_text(encoding="utf-8")
|
|
57
57
|
assert "CLI Consumption" in html
|
|
58
58
|
assert "Turn performance" in html
|
|
59
|
+
assert 'id="themeToggle"' in html
|
|
60
|
+
assert "localStorage.getItem('cli-consumption-theme')" in html
|
|
61
|
+
assert "radial-gradient" not in html
|
|
62
|
+
assert "linear-gradient" not in html
|
|
63
|
+
assert html.index("<h2>Models</h2>") < html.index("<h2>Turn performance</h2>")
|
|
64
|
+
assert "Median provider-reported total-token count" in html
|
|
65
|
+
assert html.count('class="info"') == 1
|
|
59
66
|
assert "Data quality" in html
|
|
60
67
|
assert "https://" not in html
|
|
61
68
|
assert "secret value" not in html
|
|
File without changes
|
{cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/add-cli-adapter/agents/openai.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/evolve-storage-schema/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cli_consumption-0.0.1 → cli_consumption-0.0.2}/.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
|