codex-usage-tracking 0.3.0__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.
Files changed (50) hide show
  1. codex_usage_tracker/__init__.py +7 -0
  2. codex_usage_tracker/__main__.py +6 -0
  3. codex_usage_tracker/allowance.py +759 -0
  4. codex_usage_tracker/api_payloads.py +90 -0
  5. codex_usage_tracker/cli.py +1326 -0
  6. codex_usage_tracker/context.py +410 -0
  7. codex_usage_tracker/costing.py +176 -0
  8. codex_usage_tracker/dashboard.py +389 -0
  9. codex_usage_tracker/diagnostics.py +624 -0
  10. codex_usage_tracker/formatting.py +225 -0
  11. codex_usage_tracker/json_contracts.py +350 -0
  12. codex_usage_tracker/mcp_server.py +371 -0
  13. codex_usage_tracker/models.py +92 -0
  14. codex_usage_tracker/parser.py +491 -0
  15. codex_usage_tracker/paths.py +18 -0
  16. codex_usage_tracker/plugin_data/__init__.py +1 -0
  17. codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
  18. codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
  19. codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
  20. codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
  21. codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
  22. codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
  23. codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
  24. codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
  25. codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
  26. codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
  27. codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
  28. codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
  29. codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
  30. codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
  31. codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
  32. codex_usage_tracker/plugin_installer.py +312 -0
  33. codex_usage_tracker/pricing.py +57 -0
  34. codex_usage_tracker/pricing_config.py +223 -0
  35. codex_usage_tracker/pricing_estimates.py +44 -0
  36. codex_usage_tracker/pricing_openai.py +253 -0
  37. codex_usage_tracker/projects.py +347 -0
  38. codex_usage_tracker/recommendations.py +270 -0
  39. codex_usage_tracker/reports.py +637 -0
  40. codex_usage_tracker/schema.py +71 -0
  41. codex_usage_tracker/server.py +400 -0
  42. codex_usage_tracker/store.py +666 -0
  43. codex_usage_tracker/support.py +147 -0
  44. codex_usage_tracker/threads.py +183 -0
  45. codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
  46. codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
  47. codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
  48. codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
  49. codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
  50. codex_usage_tracking-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1326 @@
1
+ """Command-line interface for local Codex usage tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import webbrowser
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from codex_usage_tracker import __version__
13
+ from codex_usage_tracker.allowance import (
14
+ update_rate_card,
15
+ write_allowance_from_text,
16
+ write_allowance_template,
17
+ )
18
+ from codex_usage_tracker.api_payloads import (
19
+ error_code,
20
+ path_payload,
21
+ plugin_install_payload,
22
+ plugin_uninstall_payload,
23
+ refresh_result_payload,
24
+ session_payload,
25
+ )
26
+ from codex_usage_tracker.context import DEFAULT_CONTEXT_CHARS, load_call_context
27
+ from codex_usage_tracker.dashboard import generate_dashboard
28
+ from codex_usage_tracker.diagnostics import run_doctor
29
+ from codex_usage_tracker.formatting import (
30
+ format_doctor,
31
+ format_session,
32
+ )
33
+ from codex_usage_tracker.parser import inspect_log, load_session_index
34
+ from codex_usage_tracker.paths import (
35
+ DEFAULT_ALLOWANCE_PATH,
36
+ DEFAULT_CODEX_HOME,
37
+ DEFAULT_DASHBOARD_PATH,
38
+ DEFAULT_DB_PATH,
39
+ DEFAULT_MARKETPLACE_PATH,
40
+ DEFAULT_PLUGIN_LINK,
41
+ DEFAULT_PRICING_PATH,
42
+ DEFAULT_PROJECTS_PATH,
43
+ DEFAULT_RATE_CARD_PATH,
44
+ DEFAULT_SUPPORT_BUNDLE_PATH,
45
+ DEFAULT_THRESHOLDS_PATH,
46
+ )
47
+ from codex_usage_tracker.plugin_installer import install_plugin, uninstall_plugin
48
+ from codex_usage_tracker.pricing import (
49
+ OPENAI_PRICING_MD_URL,
50
+ VALID_PRICING_TIERS,
51
+ pin_pricing_snapshot,
52
+ update_pricing_from_openai_docs,
53
+ write_pricing_template,
54
+ )
55
+ from codex_usage_tracker.projects import (
56
+ PRIVACY_MODE_CHOICES,
57
+ apply_project_privacy_to_rows,
58
+ write_project_template,
59
+ )
60
+ from codex_usage_tracker.recommendations import write_threshold_template
61
+ from codex_usage_tracker.reports import (
62
+ EXPENSIVE_PRESET_CHOICES,
63
+ QUERY_CREDIT_CONFIDENCE_CHOICES,
64
+ QUERY_PRICING_STATUS_CHOICES,
65
+ SUMMARY_GROUP_BY_CHOICES,
66
+ SUMMARY_PRESET_CHOICES,
67
+ build_expensive_calls_report,
68
+ build_pricing_coverage_report,
69
+ build_query_report,
70
+ build_recommendations_report,
71
+ build_summary_report,
72
+ )
73
+ from codex_usage_tracker.server import serve_dashboard
74
+ from codex_usage_tracker.store import (
75
+ export_usage_csv,
76
+ query_session_usage,
77
+ rebuild_usage_index,
78
+ refresh_usage_index,
79
+ reset_usage_database,
80
+ )
81
+ from codex_usage_tracker.support import build_support_bundle
82
+
83
+
84
+ def main() -> int:
85
+ try:
86
+ return _main()
87
+ except BrokenPipeError:
88
+ return 1
89
+ except (FileExistsError, FileNotFoundError, PermissionError, RuntimeError, ValueError, OSError) as exc:
90
+ print(f"Error: [{error_code(exc)}] {exc}", file=sys.stderr)
91
+ return 1
92
+
93
+
94
+ def _main() -> int:
95
+ parser = _build_parser()
96
+ args = parser.parse_args()
97
+ handler = _COMMAND_HANDLERS.get(args.command)
98
+ if handler is None:
99
+ parser.error("unknown command")
100
+ return 2
101
+ return handler(args)
102
+
103
+
104
+ def _build_parser() -> argparse.ArgumentParser:
105
+ parser = argparse.ArgumentParser(prog="codex-usage-tracker")
106
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
107
+ parser.add_argument("--db", type=Path, default=DEFAULT_DB_PATH)
108
+ parser.add_argument("--pricing", type=Path, default=DEFAULT_PRICING_PATH)
109
+ parser.add_argument("--allowance", type=Path, default=DEFAULT_ALLOWANCE_PATH)
110
+ parser.add_argument("--rate-card", type=Path, default=DEFAULT_RATE_CARD_PATH)
111
+ parser.add_argument("--thresholds", type=Path, default=DEFAULT_THRESHOLDS_PATH)
112
+ parser.add_argument("--projects", type=Path, default=DEFAULT_PROJECTS_PATH)
113
+ parser.add_argument(
114
+ "--privacy-mode",
115
+ choices=PRIVACY_MODE_CHOICES,
116
+ default="normal",
117
+ help=(
118
+ "Project metadata display mode: normal keeps local labels, redacted hides "
119
+ "raw paths and hashes unnamed projects, strict also hides branch, relative cwd, and tags."
120
+ ),
121
+ )
122
+ subparsers = parser.add_subparsers(dest="command", required=True)
123
+ _add_setup_parser(subparsers)
124
+ _add_doctor_parser(subparsers)
125
+ _add_install_plugin_parser(subparsers)
126
+ _add_upgrade_plugin_parser(subparsers)
127
+ _add_uninstall_plugin_parser(subparsers)
128
+ _add_refresh_parser(subparsers)
129
+ _add_inspect_log_parser(subparsers)
130
+ _add_rebuild_index_parser(subparsers)
131
+ _add_reset_db_parser(subparsers)
132
+ _add_summary_parser(subparsers)
133
+ _add_query_parser(subparsers)
134
+ _add_recommendations_parser(subparsers)
135
+ _add_session_parser(subparsers)
136
+ _add_context_parser(subparsers)
137
+ _add_dashboard_parsers(subparsers)
138
+ _add_expensive_parser(subparsers)
139
+ _add_pricing_coverage_parser(subparsers)
140
+ _add_export_parser(subparsers)
141
+ _add_pricing_parsers(subparsers)
142
+ _add_allowance_parser(subparsers)
143
+ _add_rate_card_parser(subparsers)
144
+ _add_threshold_parser(subparsers)
145
+ _add_project_parser(subparsers)
146
+ _add_support_bundle_parser(subparsers)
147
+ return parser
148
+
149
+
150
+ def _add_setup_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
151
+ setup = subparsers.add_parser(
152
+ "setup",
153
+ help="Run first-time setup: plugin install, pricing init, refresh, and doctor",
154
+ )
155
+ setup.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME)
156
+ setup.add_argument("--include-archived", action="store_true")
157
+ setup.add_argument("--plugin-dir", type=Path, default=DEFAULT_PLUGIN_LINK)
158
+ setup.add_argument("--marketplace", type=Path, default=DEFAULT_MARKETPLACE_PATH)
159
+ setup.add_argument(
160
+ "--python",
161
+ type=Path,
162
+ default=None,
163
+ dest="python_executable",
164
+ help="Python executable Codex should use for the MCP server.",
165
+ )
166
+ setup.add_argument(
167
+ "--force-plugin",
168
+ action="store_true",
169
+ help="Replace an existing generated plugin wrapper or source-checkout symlink.",
170
+ )
171
+ setup.add_argument("--skip-pricing", action="store_true")
172
+ setup.add_argument(
173
+ "--update-pricing",
174
+ action="store_true",
175
+ help="Fetch current pricing during setup instead of writing a local template.",
176
+ )
177
+ setup.add_argument("--json", action="store_true", dest="as_json")
178
+
179
+
180
+ def _add_doctor_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
181
+ doctor = subparsers.add_parser("doctor", help="Check local setup without writing files")
182
+ doctor.add_argument("--json", action="store_true", dest="as_json")
183
+ doctor.add_argument(
184
+ "--suggest-repair",
185
+ action="store_true",
186
+ help="Include read-only repair suggestions for warning and failure checks.",
187
+ )
188
+
189
+
190
+ def _add_install_plugin_parser(
191
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
192
+ ) -> None:
193
+ install_plugin_cmd = subparsers.add_parser(
194
+ "install-plugin",
195
+ help="Register this installed package as a local Codex plugin",
196
+ )
197
+ install_plugin_cmd.add_argument("--plugin-dir", type=Path, default=DEFAULT_PLUGIN_LINK)
198
+ install_plugin_cmd.add_argument("--marketplace", type=Path, default=DEFAULT_MARKETPLACE_PATH)
199
+ install_plugin_cmd.add_argument(
200
+ "--python",
201
+ type=Path,
202
+ default=None,
203
+ dest="python_executable",
204
+ help="Python executable Codex should use for the MCP server.",
205
+ )
206
+ install_plugin_cmd.add_argument(
207
+ "--force",
208
+ action="store_true",
209
+ help="Replace an existing generated plugin directory or source-checkout symlink.",
210
+ )
211
+ install_plugin_cmd.add_argument("--json", action="store_true", dest="as_json")
212
+
213
+
214
+ def _add_upgrade_plugin_parser(
215
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
216
+ ) -> None:
217
+ upgrade_plugin_cmd = subparsers.add_parser(
218
+ "upgrade-plugin",
219
+ help="Refresh the generated local Codex plugin wrapper for this installed package",
220
+ )
221
+ upgrade_plugin_cmd.add_argument("--plugin-dir", type=Path, default=DEFAULT_PLUGIN_LINK)
222
+ upgrade_plugin_cmd.add_argument("--marketplace", type=Path, default=DEFAULT_MARKETPLACE_PATH)
223
+ upgrade_plugin_cmd.add_argument(
224
+ "--python",
225
+ type=Path,
226
+ default=None,
227
+ dest="python_executable",
228
+ help="Python executable Codex should use for the MCP server.",
229
+ )
230
+ upgrade_plugin_cmd.add_argument("--json", action="store_true", dest="as_json")
231
+
232
+
233
+ def _add_uninstall_plugin_parser(
234
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
235
+ ) -> None:
236
+ uninstall_plugin_cmd = subparsers.add_parser(
237
+ "uninstall-plugin",
238
+ help="Remove the generated local Codex plugin wrapper and marketplace entry",
239
+ )
240
+ uninstall_plugin_cmd.add_argument("--plugin-dir", type=Path, default=DEFAULT_PLUGIN_LINK)
241
+ uninstall_plugin_cmd.add_argument("--marketplace", type=Path, default=DEFAULT_MARKETPLACE_PATH)
242
+ uninstall_plugin_cmd.add_argument("--json", action="store_true", dest="as_json")
243
+
244
+
245
+ def _add_refresh_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
246
+ refresh = subparsers.add_parser("refresh", help="Scan Codex logs into SQLite")
247
+ refresh.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME)
248
+ refresh.add_argument("--include-archived", action="store_true")
249
+ refresh.add_argument("--json", action="store_true", dest="as_json")
250
+
251
+
252
+ def _add_inspect_log_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
253
+ inspect = subparsers.add_parser(
254
+ "inspect-log",
255
+ help="Inspect one Codex JSONL log through the parser without writing to SQLite",
256
+ )
257
+ inspect.add_argument("path", type=Path)
258
+ inspect.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME)
259
+ inspect.add_argument("--json", action="store_true", dest="as_json")
260
+
261
+
262
+ def _add_rebuild_index_parser(
263
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
264
+ ) -> None:
265
+ rebuild = subparsers.add_parser(
266
+ "rebuild-index",
267
+ help="Clear aggregate rows and rescan local Codex logs",
268
+ )
269
+ rebuild.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME)
270
+ rebuild.add_argument("--include-archived", action="store_true")
271
+ rebuild.add_argument("--json", action="store_true", dest="as_json")
272
+
273
+
274
+ def _add_reset_db_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
275
+ reset = subparsers.add_parser(
276
+ "reset-db",
277
+ help="Clear tracker-owned aggregate rows and refresh metadata",
278
+ )
279
+ reset.add_argument(
280
+ "--yes",
281
+ action="store_true",
282
+ help="Confirm clearing local aggregate usage rows. Raw Codex logs are not touched.",
283
+ )
284
+ reset.add_argument("--json", action="store_true", dest="as_json")
285
+
286
+
287
+ def _add_summary_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
288
+ summary = subparsers.add_parser("summary", help="Show aggregate usage summary")
289
+ summary.add_argument(
290
+ "--group-by",
291
+ choices=SUMMARY_GROUP_BY_CHOICES,
292
+ default="thread",
293
+ )
294
+ summary.add_argument(
295
+ "--preset",
296
+ choices=SUMMARY_PRESET_CHOICES,
297
+ help="Convenience preset for common summaries",
298
+ )
299
+ summary.add_argument("--since", help="Only include calls at or after this ISO date/time")
300
+ summary.add_argument("--limit", type=int, default=20)
301
+ summary.add_argument("--json", action="store_true", dest="as_json")
302
+
303
+
304
+ def _add_query_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
305
+ query = subparsers.add_parser(
306
+ "query",
307
+ help="Return stable JSON aggregate usage rows with filters",
308
+ )
309
+ query.add_argument("--since", help="Only include calls at or after this ISO date/time")
310
+ query.add_argument("--until", help="Only include calls at or before this ISO date/time")
311
+ query.add_argument("--model")
312
+ query.add_argument("--effort")
313
+ query.add_argument("--thread")
314
+ query.add_argument("--project")
315
+ query.add_argument("--pricing-status", choices=QUERY_PRICING_STATUS_CHOICES)
316
+ query.add_argument("--credit-confidence", choices=QUERY_CREDIT_CONFIDENCE_CHOICES)
317
+ query.add_argument("--min-tokens", type=int)
318
+ query.add_argument("--min-credits", type=float)
319
+ query.add_argument("--limit", type=int, default=100, help="Maximum rows to return; use 0 for all")
320
+ query.add_argument(
321
+ "--json",
322
+ action="store_true",
323
+ dest="as_json",
324
+ help="Accepted for consistency; query always returns JSON.",
325
+ )
326
+
327
+
328
+ def _add_recommendations_parser(
329
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
330
+ ) -> None:
331
+ recommendations = subparsers.add_parser(
332
+ "recommendations",
333
+ help="Rank aggregate usage rows and threads by action recommendation severity",
334
+ )
335
+ recommendations.add_argument("--since", help="Only include calls at or after this ISO date/time")
336
+ recommendations.add_argument("--until", help="Only include calls at or before this ISO date/time")
337
+ recommendations.add_argument("--model")
338
+ recommendations.add_argument("--effort")
339
+ recommendations.add_argument("--thread")
340
+ recommendations.add_argument("--project")
341
+ recommendations.add_argument("--min-score", type=float)
342
+ recommendations.add_argument("--limit", type=int, default=20, help="Maximum rows to return; use 0 for all")
343
+ recommendations.add_argument("--json", action="store_true", dest="as_json")
344
+
345
+
346
+ def _add_session_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
347
+ session = subparsers.add_parser("session", help="Show one session's usage")
348
+ session.add_argument("session_id", nargs="?")
349
+ session.add_argument("--limit", type=int, default=200)
350
+ session.add_argument("--json", action="store_true", dest="as_json")
351
+
352
+
353
+ def _add_context_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
354
+ context = subparsers.add_parser(
355
+ "context",
356
+ help="Load raw logged context for one usage record on demand",
357
+ )
358
+ context.add_argument("record_id")
359
+ context.add_argument("--max-chars", type=int, default=DEFAULT_CONTEXT_CHARS)
360
+ context.add_argument("--max-entries", type=int, default=80)
361
+ context.add_argument(
362
+ "--include-tool-output",
363
+ action="store_true",
364
+ help="Include redacted, size-limited tool output in the on-demand context.",
365
+ )
366
+ context.add_argument("--json", action="store_true", dest="as_json")
367
+
368
+
369
+ def _add_dashboard_parsers(
370
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
371
+ ) -> None:
372
+ dashboard = subparsers.add_parser("dashboard", help="Generate static dashboard")
373
+ dashboard.add_argument("--output", type=Path, default=DEFAULT_DASHBOARD_PATH)
374
+ dashboard.add_argument("--limit", type=int, default=5000, help="Maximum calls to load; use 0 for all")
375
+ dashboard.add_argument("--since", help="Only include calls at or after this ISO date/time")
376
+ dashboard.add_argument(
377
+ "--include-archived",
378
+ action="store_true",
379
+ help="Include archived session rows already present in the SQLite index.",
380
+ )
381
+ dashboard.add_argument("--open", action="store_true")
382
+ dashboard.add_argument("--json", action="store_true", dest="as_json")
383
+
384
+ open_dashboard = subparsers.add_parser(
385
+ "open-dashboard", help="Generate the default dashboard and open it"
386
+ )
387
+ open_dashboard.add_argument("--output", type=Path, default=DEFAULT_DASHBOARD_PATH)
388
+ open_dashboard.add_argument("--limit", type=int, default=5000, help="Maximum calls to load; use 0 for all")
389
+ open_dashboard.add_argument("--since", help="Only include calls at or after this ISO date/time")
390
+ open_dashboard.add_argument(
391
+ "--include-archived",
392
+ action="store_true",
393
+ help="Include archived sessions when refreshing and in the generated dashboard.",
394
+ )
395
+ open_dashboard.add_argument(
396
+ "--refresh",
397
+ action="store_true",
398
+ help="Refresh the SQLite index before generating the dashboard",
399
+ )
400
+ open_dashboard.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME)
401
+ open_dashboard.add_argument("--json", action="store_true", dest="as_json")
402
+
403
+ serve = subparsers.add_parser(
404
+ "serve-dashboard",
405
+ help="Serve dashboard with lazy localhost context loading",
406
+ )
407
+ serve.add_argument("--output", type=Path, default=DEFAULT_DASHBOARD_PATH)
408
+ serve.add_argument("--limit", type=int, default=5000, help="Initial maximum calls to load; use 0 for all")
409
+ serve.add_argument("--since", help="Only include calls at or after this ISO date/time")
410
+ serve.add_argument("--host", default="127.0.0.1")
411
+ serve.add_argument("--port", type=int, default=8765)
412
+ serve.add_argument("--context-chars", type=int, default=DEFAULT_CONTEXT_CHARS)
413
+ serve.add_argument(
414
+ "--context-api",
415
+ choices=["explicit", "disabled"],
416
+ default="explicit",
417
+ help="Enable explicit per-row context loading or disable the context API.",
418
+ )
419
+ serve.add_argument(
420
+ "--no-context-api",
421
+ action="store_true",
422
+ help="Serve aggregate dashboard refresh only and disable /api/context.",
423
+ )
424
+ serve.add_argument("--open", action="store_true")
425
+ serve.add_argument(
426
+ "--refresh",
427
+ action="store_true",
428
+ help="Refresh the SQLite index before generating and serving the dashboard",
429
+ )
430
+ serve.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME)
431
+ serve.add_argument("--include-archived", action="store_true")
432
+ serve.add_argument(
433
+ "--json",
434
+ action="store_true",
435
+ dest="as_json",
436
+ help="Accepted for API consistency; serve-dashboard still runs as a long-lived server.",
437
+ )
438
+
439
+
440
+ def _add_expensive_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
441
+ expensive = subparsers.add_parser("expensive", help="Show largest last-call usage rows")
442
+ expensive.add_argument("--limit", type=int, default=20)
443
+ expensive.add_argument("--since", help="Only include calls at or after this ISO date/time")
444
+ expensive.add_argument(
445
+ "--preset",
446
+ choices=EXPENSIVE_PRESET_CHOICES,
447
+ help="Convenience date window",
448
+ )
449
+ expensive.add_argument("--json", action="store_true", dest="as_json")
450
+
451
+
452
+ def _add_pricing_coverage_parser(
453
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
454
+ ) -> None:
455
+ pricing_coverage = subparsers.add_parser(
456
+ "pricing-coverage", help="Show priced, estimated, and unpriced token coverage"
457
+ )
458
+ pricing_coverage.add_argument("--since", help="Only include calls at or after this ISO date/time")
459
+ pricing_coverage.add_argument("--limit", type=int, default=20)
460
+ pricing_coverage.add_argument(
461
+ "--json",
462
+ action="store_true",
463
+ dest="as_json",
464
+ help="Return the coverage report as JSON",
465
+ )
466
+
467
+
468
+ def _add_export_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
469
+ export = subparsers.add_parser("export", help="Export aggregate usage CSV")
470
+ export.add_argument("--output", type=Path, required=True)
471
+ export.add_argument("--limit", type=int)
472
+ export.add_argument("--json", action="store_true", dest="as_json")
473
+
474
+
475
+ def _add_pricing_parsers(
476
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
477
+ ) -> None:
478
+ pricing = subparsers.add_parser("init-pricing", help="Write a local pricing template")
479
+ pricing.add_argument("--output", type=Path, default=DEFAULT_PRICING_PATH)
480
+ pricing.add_argument("--force", action="store_true")
481
+ pricing.add_argument("--json", action="store_true", dest="as_json")
482
+
483
+ update_pricing = subparsers.add_parser(
484
+ "update-pricing", help="Fetch OpenAI text-token pricing into the local config"
485
+ )
486
+ update_pricing.add_argument("--output", type=Path, default=None)
487
+ update_pricing.add_argument("--source", choices=["openai-docs"], default="openai-docs")
488
+ update_pricing.add_argument("--tier", choices=VALID_PRICING_TIERS, default="standard")
489
+ update_pricing.add_argument("--source-url", default=OPENAI_PRICING_MD_URL)
490
+ update_pricing.add_argument(
491
+ "--no-estimates",
492
+ action="store_true",
493
+ help="Skip estimated prices for internal Codex model labels.",
494
+ )
495
+ update_pricing.add_argument("--json", action="store_true", dest="as_json")
496
+
497
+ pin_pricing = subparsers.add_parser(
498
+ "pin-pricing",
499
+ help="Copy the current local pricing config to a reproducible report snapshot",
500
+ )
501
+ pin_pricing.add_argument("--output", type=Path, required=True)
502
+ pin_pricing.add_argument("--force", action="store_true")
503
+ pin_pricing.add_argument("--json", action="store_true", dest="as_json")
504
+
505
+
506
+ def _add_allowance_parser(
507
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
508
+ ) -> None:
509
+ allowance = subparsers.add_parser(
510
+ "init-allowance",
511
+ help="Write a local template for optional Codex allowance windows",
512
+ )
513
+ allowance.add_argument("--output", type=Path, default=None)
514
+ allowance.add_argument("--force", action="store_true")
515
+ allowance.add_argument("--json", action="store_true", dest="as_json")
516
+
517
+ parse_allowance = subparsers.add_parser(
518
+ "parse-allowance",
519
+ help="Update allowance windows from pasted Codex /status or usage text",
520
+ )
521
+ parse_allowance.add_argument(
522
+ "text",
523
+ nargs="*",
524
+ help="Pasted usage text. Reads stdin when omitted.",
525
+ )
526
+ parse_allowance.add_argument("--output", type=Path, default=None)
527
+ parse_allowance.add_argument(
528
+ "--force",
529
+ action="store_true",
530
+ help="Overwrite an invalid existing allowance config.",
531
+ )
532
+ parse_allowance.add_argument("--json", action="store_true", dest="as_json")
533
+
534
+
535
+ def _add_rate_card_parser(
536
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
537
+ ) -> None:
538
+ rate_card = subparsers.add_parser(
539
+ "update-rate-card",
540
+ help="Write the bundled or supplied Codex credit rate-card snapshot locally",
541
+ )
542
+ rate_card.add_argument("--output", type=Path, default=None)
543
+ rate_card.add_argument(
544
+ "--source-file",
545
+ type=Path,
546
+ default=None,
547
+ help="Validate and copy this JSON rate-card snapshot instead of the bundled one.",
548
+ )
549
+ rate_card.add_argument("--json", action="store_true", dest="as_json")
550
+
551
+
552
+ def _add_threshold_parser(
553
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
554
+ ) -> None:
555
+ thresholds = subparsers.add_parser(
556
+ "init-thresholds",
557
+ help="Write a local template for dashboard recommendation thresholds",
558
+ )
559
+ thresholds.add_argument("--output", type=Path, default=None)
560
+ thresholds.add_argument("--force", action="store_true")
561
+ thresholds.add_argument("--json", action="store_true", dest="as_json")
562
+
563
+
564
+ def _add_project_parser(
565
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
566
+ ) -> None:
567
+ projects = subparsers.add_parser(
568
+ "init-projects",
569
+ help="Write a local template for project aliases, ignored paths, and tags",
570
+ )
571
+ projects.add_argument("--output", type=Path, default=None)
572
+ projects.add_argument("--force", action="store_true")
573
+ projects.add_argument("--json", action="store_true", dest="as_json")
574
+
575
+
576
+ def _add_support_bundle_parser(
577
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
578
+ ) -> None:
579
+ support = subparsers.add_parser(
580
+ "support-bundle",
581
+ help="Write a privacy-preserving diagnostic bundle for support",
582
+ )
583
+ support.add_argument("--output", type=Path, default=DEFAULT_SUPPORT_BUNDLE_PATH)
584
+ support.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME)
585
+ support.add_argument("--json", action="store_true", dest="as_json")
586
+
587
+
588
+ def _print_json(payload: dict[str, Any]) -> None:
589
+ print(json.dumps(payload, indent=2, sort_keys=True, default=str), flush=True)
590
+
591
+
592
+ def _run_setup(args: argparse.Namespace) -> int:
593
+ lines = ["Codex Usage Tracker setup summary", ""]
594
+ codex_home_exists = args.codex_home.expanduser().exists()
595
+ lines.append(
596
+ f"Codex home: {args.codex_home.expanduser()} "
597
+ f"({'found' if codex_home_exists else 'not found yet'})"
598
+ )
599
+ install_result = install_plugin(
600
+ plugin_dir=args.plugin_dir,
601
+ marketplace_path=args.marketplace,
602
+ python_executable=args.python_executable,
603
+ force=args.force_plugin,
604
+ )
605
+ lines.append(f"Plugin: installed at {install_result.plugin_dir}")
606
+ lines.append(f"MCP Python: {install_result.python_executable}")
607
+ pricing_payload: dict[str, Any]
608
+ if args.skip_pricing:
609
+ lines.append("Pricing: skipped")
610
+ pricing_payload = {"status": "skipped", "path": path_payload(args.pricing)}
611
+ elif args.update_pricing:
612
+ pricing_result = update_pricing_from_openai_docs(args.pricing)
613
+ lines.append(
614
+ f"Pricing: updated {pricing_result.model_count} entries from {pricing_result.source_url}"
615
+ )
616
+ pricing_payload = {
617
+ "status": "updated",
618
+ "path": path_payload(pricing_result.path),
619
+ "source_url": pricing_result.source_url,
620
+ "tier": pricing_result.tier,
621
+ "fetched_at": pricing_result.fetched_at,
622
+ "model_count": pricing_result.model_count,
623
+ "estimated_model_count": pricing_result.estimated_model_count,
624
+ }
625
+ elif args.pricing.expanduser().exists():
626
+ lines.append(f"Pricing: existing config at {args.pricing}")
627
+ pricing_payload = {"status": "existing", "path": path_payload(args.pricing)}
628
+ else:
629
+ pricing_output = write_pricing_template(args.pricing)
630
+ lines.append(f"Pricing: wrote local template at {pricing_output}")
631
+ pricing_payload = {"status": "initialized", "path": path_payload(pricing_output)}
632
+ refresh_result = refresh_usage_index(
633
+ codex_home=args.codex_home,
634
+ db_path=args.db,
635
+ include_archived=args.include_archived,
636
+ )
637
+ lines.append(
638
+ f"Refresh: scanned {refresh_result.scanned_files} files, parsed "
639
+ f"{refresh_result.parsed_events} events, skipped {refresh_result.skipped_events}"
640
+ )
641
+ doctor_report = run_doctor(
642
+ codex_home=args.codex_home,
643
+ db_path=args.db,
644
+ pricing_path=args.pricing,
645
+ plugin_link=args.plugin_dir,
646
+ marketplace_path=args.marketplace,
647
+ suggest_repair=True,
648
+ )
649
+ lines.append(f"Doctor: {doctor_report['status']}")
650
+ if doctor_report.get("repair_suggestions"):
651
+ lines.append("Repair suggestions:")
652
+ lines.extend(f"- {suggestion}" for suggestion in doctor_report["repair_suggestions"])
653
+ lines.append("")
654
+ lines.append("Restart Codex to discover or refresh the plugin tools.")
655
+ if args.as_json:
656
+ _print_json(
657
+ {
658
+ "schema": "codex-usage-tracker-setup-v1",
659
+ "codex_home": path_payload(args.codex_home),
660
+ "codex_home_exists": codex_home_exists,
661
+ "plugin": plugin_install_payload(
662
+ install_result,
663
+ schema="codex-usage-tracker-plugin-install-v1",
664
+ ),
665
+ "pricing": pricing_payload,
666
+ "refresh": refresh_result_payload(
667
+ refresh_result,
668
+ schema="codex-usage-tracker-refresh-v1",
669
+ ),
670
+ "doctor": doctor_report,
671
+ "restart_required": True,
672
+ }
673
+ )
674
+ return 0 if doctor_report["status"] != "fail" else 1
675
+ print("\n".join(lines))
676
+ return 0 if doctor_report["status"] != "fail" else 1
677
+
678
+
679
+ def _run_doctor(args: argparse.Namespace) -> int:
680
+ report = run_doctor(
681
+ db_path=args.db,
682
+ pricing_path=args.pricing,
683
+ suggest_repair=args.suggest_repair,
684
+ )
685
+ print(json.dumps(report, indent=2) if args.as_json else format_doctor(report))
686
+ return 0 if report["status"] != "fail" else 1
687
+
688
+
689
+ def _run_install_plugin(args: argparse.Namespace) -> int:
690
+ result = install_plugin(
691
+ plugin_dir=args.plugin_dir,
692
+ marketplace_path=args.marketplace,
693
+ python_executable=args.python_executable,
694
+ force=args.force,
695
+ )
696
+ if args.as_json:
697
+ _print_json(plugin_install_payload(result, schema="codex-usage-tracker-plugin-install-v1"))
698
+ return 0
699
+ replacement_note = " Replaced existing plugin path." if result.replaced_existing else ""
700
+ print(f"Installed Codex Usage Tracker plugin at {result.plugin_dir}.{replacement_note}")
701
+ print(f"MCP Python: {result.python_executable}")
702
+ print(f"Updated marketplace: {result.marketplace_path}")
703
+ print("Restart Codex to discover the plugin.")
704
+ return 0
705
+
706
+
707
+ def _run_upgrade_plugin(args: argparse.Namespace) -> int:
708
+ result = install_plugin(
709
+ plugin_dir=args.plugin_dir,
710
+ marketplace_path=args.marketplace,
711
+ python_executable=args.python_executable,
712
+ force=True,
713
+ )
714
+ if args.as_json:
715
+ _print_json(plugin_install_payload(result, schema="codex-usage-tracker-plugin-upgrade-v1"))
716
+ return 0
717
+ print(f"Upgraded Codex Usage Tracker plugin at {result.plugin_dir}.")
718
+ print(f"MCP Python: {result.python_executable}")
719
+ print(f"Updated marketplace: {result.marketplace_path}")
720
+ print("Restart Codex to discover the refreshed plugin.")
721
+ return 0
722
+
723
+
724
+ def _run_uninstall_plugin(args: argparse.Namespace) -> int:
725
+ result = uninstall_plugin(
726
+ plugin_dir=args.plugin_dir,
727
+ marketplace_path=args.marketplace,
728
+ )
729
+ if args.as_json:
730
+ _print_json(plugin_uninstall_payload(result))
731
+ return 0
732
+ print(
733
+ f"Removed plugin path: {'yes' if result.removed_plugin_path else 'already absent'} "
734
+ f"({result.plugin_dir})"
735
+ )
736
+ print(
737
+ f"Removed marketplace entry: {'yes' if result.removed_marketplace_entry else 'not present'} "
738
+ f"({result.marketplace_path})"
739
+ )
740
+ print("Restart Codex to unload plugin tools from new sessions.")
741
+ return 0
742
+
743
+
744
+ def _run_refresh(args: argparse.Namespace) -> int:
745
+ result = refresh_usage_index(
746
+ codex_home=args.codex_home,
747
+ db_path=args.db,
748
+ include_archived=args.include_archived,
749
+ )
750
+ if args.as_json:
751
+ _print_json(refresh_result_payload(result, schema="codex-usage-tracker-refresh-v1"))
752
+ return 0
753
+ print(
754
+ f"Scanned {result.scanned_files} files, parsed {result.parsed_events} "
755
+ f"usage events, upserted {result.inserted_or_updated_events} rows into {result.db_path}."
756
+ )
757
+ if result.skipped_events:
758
+ print(f"Skipped {result.skipped_events} malformed token-count events.")
759
+ if result.parser_diagnostics:
760
+ diagnostics = ", ".join(
761
+ f"{key}={value}" for key, value in result.parser_diagnostics.items()
762
+ )
763
+ print(f"Parser diagnostics: {diagnostics}")
764
+ return 0
765
+
766
+
767
+ def _run_inspect_log(args: argparse.Namespace) -> int:
768
+ payload = inspect_log(args.path, session_index=load_session_index(args.codex_home))
769
+ if args.as_json:
770
+ print(json.dumps(payload, indent=2))
771
+ return 0
772
+ print(f"Log: {payload['path']}")
773
+ print(f"Adapter: {payload['adapter']}")
774
+ print(f"File session id: {payload['file_session_id'] or 'unknown'}")
775
+ print(f"Parsed events: {payload['event_count']}")
776
+ if payload["session_ids"]:
777
+ print("Sessions: " + ", ".join(str(value) for value in payload["session_ids"]))
778
+ if payload["models"]:
779
+ print("Models: " + ", ".join(str(value) for value in payload["models"]))
780
+ diagnostics = payload["diagnostics"]
781
+ if diagnostics:
782
+ print(
783
+ "Diagnostics: "
784
+ + ", ".join(f"{key}={value}" for key, value in dict(diagnostics).items())
785
+ )
786
+ else:
787
+ print("Diagnostics: none")
788
+ return 0
789
+
790
+
791
+ def _run_rebuild_index(args: argparse.Namespace) -> int:
792
+ result = rebuild_usage_index(
793
+ codex_home=args.codex_home,
794
+ db_path=args.db,
795
+ include_archived=args.include_archived,
796
+ )
797
+ if args.as_json:
798
+ _print_json(refresh_result_payload(result, schema="codex-usage-tracker-rebuild-index-v1"))
799
+ return 0
800
+ print(
801
+ f"Rebuilt aggregate index: scanned {result.scanned_files} files, parsed "
802
+ f"{result.parsed_events} usage events, upserted "
803
+ f"{result.inserted_or_updated_events} rows into {result.db_path}."
804
+ )
805
+ if result.skipped_events:
806
+ print(f"Skipped {result.skipped_events} malformed token-count events.")
807
+ if result.parser_diagnostics:
808
+ diagnostics = ", ".join(
809
+ f"{key}={value}" for key, value in result.parser_diagnostics.items()
810
+ )
811
+ print(f"Parser diagnostics: {diagnostics}")
812
+ return 0
813
+
814
+
815
+ def _run_reset_db(args: argparse.Namespace) -> int:
816
+ if not args.yes:
817
+ raise ValueError(
818
+ "reset-db clears local aggregate usage rows. Re-run with --yes to confirm."
819
+ )
820
+ result = reset_usage_database(db_path=args.db)
821
+ if args.as_json:
822
+ _print_json({"schema": "codex-usage-tracker-reset-db-v1", **result})
823
+ return 0
824
+ print(
825
+ f"Cleared {result['deleted_usage_events']} aggregate usage rows from {result['db_path']}."
826
+ )
827
+ print("Raw Codex logs were not touched.")
828
+ return 0
829
+
830
+
831
+ def _run_summary(args: argparse.Namespace) -> int:
832
+ report = build_summary_report(
833
+ db_path=args.db,
834
+ pricing_path=args.pricing,
835
+ group_by=args.group_by,
836
+ preset=args.preset,
837
+ since=args.since,
838
+ limit=args.limit,
839
+ projects_path=args.projects,
840
+ privacy_mode=args.privacy_mode,
841
+ )
842
+ if args.as_json:
843
+ _print_json(report.payload())
844
+ return 0
845
+ print(report.render())
846
+ return 0
847
+
848
+
849
+ def _run_query(args: argparse.Namespace) -> int:
850
+ report = build_query_report(
851
+ db_path=args.db,
852
+ pricing_path=args.pricing,
853
+ allowance_path=args.allowance,
854
+ projects_path=args.projects,
855
+ since=args.since,
856
+ until=args.until,
857
+ model=args.model,
858
+ effort=args.effort,
859
+ thread=args.thread,
860
+ project=args.project,
861
+ pricing_status=args.pricing_status,
862
+ credit_confidence=args.credit_confidence,
863
+ min_tokens=args.min_tokens,
864
+ min_credits=args.min_credits,
865
+ limit=args.limit,
866
+ privacy_mode=args.privacy_mode,
867
+ )
868
+ _print_json(report.payload)
869
+ return 0
870
+
871
+
872
+ def _run_recommendations(args: argparse.Namespace) -> int:
873
+ report = build_recommendations_report(
874
+ db_path=args.db,
875
+ pricing_path=args.pricing,
876
+ allowance_path=args.allowance,
877
+ projects_path=args.projects,
878
+ since=args.since,
879
+ until=args.until,
880
+ model=args.model,
881
+ effort=args.effort,
882
+ thread=args.thread,
883
+ project=args.project,
884
+ min_score=args.min_score,
885
+ limit=args.limit,
886
+ privacy_mode=args.privacy_mode,
887
+ )
888
+ if args.as_json:
889
+ _print_json(report.payload)
890
+ return 0
891
+ print(report.render())
892
+ return 0
893
+
894
+
895
+ def _run_session(args: argparse.Namespace) -> int:
896
+ rows = query_session_usage(args.db, args.session_id, args.limit)
897
+ rows = apply_project_privacy_to_rows(rows, privacy_mode=args.privacy_mode)
898
+ if args.as_json:
899
+ _print_json(
900
+ session_payload(
901
+ rows,
902
+ requested_session_id=args.session_id,
903
+ limit=args.limit,
904
+ privacy_mode=args.privacy_mode,
905
+ )
906
+ )
907
+ return 0
908
+ print(format_session(rows))
909
+ return 0
910
+
911
+
912
+ def _run_context(args: argparse.Namespace) -> int:
913
+ payload = load_call_context(
914
+ record_id=args.record_id,
915
+ db_path=args.db,
916
+ max_chars=args.max_chars,
917
+ max_entries=args.max_entries,
918
+ include_tool_output=args.include_tool_output,
919
+ )
920
+ print(json.dumps(payload, indent=2))
921
+ return 0
922
+
923
+
924
+ def _run_dashboard(args: argparse.Namespace) -> int:
925
+ output = generate_dashboard(
926
+ db_path=args.db,
927
+ output_path=args.output,
928
+ limit=args.limit,
929
+ pricing_path=args.pricing,
930
+ allowance_path=args.allowance,
931
+ rate_card_path=args.rate_card,
932
+ since=args.since,
933
+ thresholds_path=args.thresholds,
934
+ projects_path=args.projects,
935
+ privacy_mode=args.privacy_mode,
936
+ include_archived=args.include_archived,
937
+ )
938
+ if args.as_json:
939
+ _print_json(
940
+ {
941
+ "schema": "codex-usage-tracker-dashboard-v1",
942
+ "dashboard_path": path_payload(output),
943
+ "file_url": output.resolve().as_uri(),
944
+ "opened": args.open,
945
+ "limit": None if args.limit <= 0 else args.limit,
946
+ "since": args.since,
947
+ "privacy_mode": args.privacy_mode,
948
+ "include_archived": args.include_archived,
949
+ }
950
+ )
951
+ else:
952
+ print(f"Wrote dashboard to {output}")
953
+ if args.open:
954
+ webbrowser.open(output.resolve().as_uri())
955
+ return 0
956
+
957
+
958
+ def _run_open_dashboard(args: argparse.Namespace) -> int:
959
+ refresh_payload = None
960
+ if args.refresh:
961
+ refresh_payload = refresh_result_payload(
962
+ refresh_usage_index(
963
+ codex_home=args.codex_home,
964
+ db_path=args.db,
965
+ include_archived=args.include_archived,
966
+ ),
967
+ schema="codex-usage-tracker-refresh-v1",
968
+ )
969
+ output = generate_dashboard(
970
+ db_path=args.db,
971
+ output_path=args.output,
972
+ limit=args.limit,
973
+ pricing_path=args.pricing,
974
+ allowance_path=args.allowance,
975
+ rate_card_path=args.rate_card,
976
+ since=args.since,
977
+ thresholds_path=args.thresholds,
978
+ projects_path=args.projects,
979
+ privacy_mode=args.privacy_mode,
980
+ include_archived=args.include_archived,
981
+ )
982
+ if args.as_json:
983
+ _print_json(
984
+ {
985
+ "schema": "codex-usage-tracker-open-dashboard-v1",
986
+ "dashboard_path": path_payload(output),
987
+ "file_url": output.resolve().as_uri(),
988
+ "opened": True,
989
+ "limit": None if args.limit <= 0 else args.limit,
990
+ "since": args.since,
991
+ "refresh": refresh_payload,
992
+ "privacy_mode": args.privacy_mode,
993
+ "include_archived": args.include_archived,
994
+ }
995
+ )
996
+ else:
997
+ print(f"Opening dashboard at {output}")
998
+ webbrowser.open(output.resolve().as_uri())
999
+ return 0
1000
+
1001
+
1002
+ def _run_serve_dashboard(args: argparse.Namespace) -> int:
1003
+ if args.as_json:
1004
+ _print_json(
1005
+ {
1006
+ "schema": "codex-usage-tracker-serve-dashboard-v1",
1007
+ "host": args.host,
1008
+ "port": args.port,
1009
+ "dashboard_path": path_payload(args.output),
1010
+ "limit": None if args.limit <= 0 else args.limit,
1011
+ "since": args.since,
1012
+ "context_api": "disabled" if args.no_context_api else args.context_api,
1013
+ "refresh_before_start": args.refresh,
1014
+ "privacy_mode": args.privacy_mode,
1015
+ "include_archived": args.include_archived,
1016
+ }
1017
+ )
1018
+ if args.refresh:
1019
+ refresh_usage_index(
1020
+ codex_home=args.codex_home,
1021
+ db_path=args.db,
1022
+ include_archived=args.include_archived,
1023
+ )
1024
+ serve_dashboard(
1025
+ db_path=args.db,
1026
+ output_path=args.output,
1027
+ pricing_path=args.pricing,
1028
+ allowance_path=args.allowance,
1029
+ rate_card_path=args.rate_card,
1030
+ limit=args.limit,
1031
+ since=args.since,
1032
+ host=args.host,
1033
+ port=args.port,
1034
+ context_chars=args.context_chars,
1035
+ open_browser=args.open,
1036
+ codex_home=args.codex_home,
1037
+ include_archived=args.include_archived,
1038
+ context_api="disabled" if args.no_context_api else args.context_api,
1039
+ thresholds_path=args.thresholds,
1040
+ projects_path=args.projects,
1041
+ privacy_mode=args.privacy_mode,
1042
+ )
1043
+ return 0
1044
+
1045
+
1046
+ def _run_expensive(args: argparse.Namespace) -> int:
1047
+ report = build_expensive_calls_report(
1048
+ db_path=args.db,
1049
+ pricing_path=args.pricing,
1050
+ limit=args.limit,
1051
+ preset=args.preset,
1052
+ since=args.since,
1053
+ privacy_mode=args.privacy_mode,
1054
+ )
1055
+ if args.as_json:
1056
+ _print_json(report.payload())
1057
+ return 0
1058
+ print(report.render())
1059
+ return 0
1060
+
1061
+
1062
+ def _run_pricing_coverage(args: argparse.Namespace) -> int:
1063
+ report = build_pricing_coverage_report(
1064
+ db_path=args.db,
1065
+ pricing_path=args.pricing,
1066
+ since=args.since,
1067
+ )
1068
+ print(json.dumps(report.payload, indent=2) if args.as_json else report.render(args.limit))
1069
+ return 0
1070
+
1071
+
1072
+ def _run_export(args: argparse.Namespace) -> int:
1073
+ count = export_usage_csv(
1074
+ output_path=args.output,
1075
+ db_path=args.db,
1076
+ limit=args.limit,
1077
+ privacy_mode=args.privacy_mode,
1078
+ )
1079
+ if args.as_json:
1080
+ _print_json(
1081
+ {
1082
+ "schema": "codex-usage-tracker-export-v1",
1083
+ "rows": count,
1084
+ "csv_path": path_payload(args.output),
1085
+ "limit": args.limit,
1086
+ "privacy_mode": args.privacy_mode,
1087
+ }
1088
+ )
1089
+ return 0
1090
+ print(f"Wrote {count} aggregate usage rows to {args.output}")
1091
+ return 0
1092
+
1093
+
1094
+ def _run_init_pricing(args: argparse.Namespace) -> int:
1095
+ output = write_pricing_template(args.output, force=args.force)
1096
+ if args.as_json:
1097
+ _print_json(
1098
+ {
1099
+ "schema": "codex-usage-tracker-init-pricing-v1",
1100
+ "pricing_path": path_payload(output),
1101
+ "created": True,
1102
+ }
1103
+ )
1104
+ return 0
1105
+ print(f"Wrote local pricing template to {output}")
1106
+ return 0
1107
+
1108
+
1109
+ def _run_update_pricing(args: argparse.Namespace) -> int:
1110
+ output = args.output or args.pricing
1111
+ result = update_pricing_from_openai_docs(
1112
+ output,
1113
+ tier=args.tier,
1114
+ source_url=args.source_url,
1115
+ include_estimates=not args.no_estimates,
1116
+ )
1117
+ if args.as_json:
1118
+ _print_json(
1119
+ {
1120
+ "schema": "codex-usage-tracker-update-pricing-v1",
1121
+ "pricing_path": path_payload(result.path),
1122
+ "source_url": result.source_url,
1123
+ "tier": result.tier,
1124
+ "fetched_at": result.fetched_at,
1125
+ "model_count": result.model_count,
1126
+ "estimated_model_count": result.estimated_model_count,
1127
+ "backup_path": path_payload(result.backup_path) if result.backup_path else None,
1128
+ }
1129
+ )
1130
+ return 0
1131
+ estimate_suffix = (
1132
+ f", including {result.estimated_model_count} estimated internal model"
1133
+ f"{'' if result.estimated_model_count == 1 else 's'}"
1134
+ if result.estimated_model_count
1135
+ else ""
1136
+ )
1137
+ print(
1138
+ f"Wrote {result.model_count} {result.tier} pricing entries from "
1139
+ f"{result.source_url} to {result.path}{estimate_suffix}"
1140
+ + (f" (backup: {result.backup_path})" if result.backup_path else "")
1141
+ )
1142
+ return 0
1143
+
1144
+
1145
+ def _run_pin_pricing(args: argparse.Namespace) -> int:
1146
+ output = pin_pricing_snapshot(
1147
+ source_path=args.pricing,
1148
+ output_path=args.output,
1149
+ force=args.force,
1150
+ )
1151
+ if args.as_json:
1152
+ _print_json(
1153
+ {
1154
+ "schema": "codex-usage-tracker-pin-pricing-v1",
1155
+ "pricing_path": path_payload(output),
1156
+ "source_pricing_path": path_payload(args.pricing),
1157
+ }
1158
+ )
1159
+ return 0
1160
+ print(f"Pinned pricing snapshot to {output}")
1161
+ print("Use this file with --pricing for reproducible historical reports.")
1162
+ return 0
1163
+
1164
+
1165
+ def _run_init_allowance(args: argparse.Namespace) -> int:
1166
+ output = write_allowance_template(args.output or args.allowance, force=args.force)
1167
+ if args.as_json:
1168
+ _print_json(
1169
+ {
1170
+ "schema": "codex-usage-tracker-init-allowance-v1",
1171
+ "allowance_path": path_payload(output),
1172
+ "created": True,
1173
+ }
1174
+ )
1175
+ return 0
1176
+ print(f"Wrote allowance template to {output}")
1177
+ return 0
1178
+
1179
+
1180
+ def _run_parse_allowance(args: argparse.Namespace) -> int:
1181
+ text = " ".join(args.text).strip()
1182
+ if not text:
1183
+ if sys.stdin.isatty():
1184
+ raise ValueError("provide pasted usage text or pipe it on stdin")
1185
+ text = sys.stdin.read().strip()
1186
+ output = write_allowance_from_text(
1187
+ text,
1188
+ path=args.output or args.allowance,
1189
+ force=args.force,
1190
+ )
1191
+ if args.as_json:
1192
+ _print_json(
1193
+ {
1194
+ "schema": "codex-usage-tracker-parse-allowance-v1",
1195
+ "allowance_path": path_payload(output),
1196
+ "updated": True,
1197
+ }
1198
+ )
1199
+ return 0
1200
+ print(f"Updated allowance windows from pasted usage text at {output}")
1201
+ return 0
1202
+
1203
+
1204
+ def _run_update_rate_card(args: argparse.Namespace) -> int:
1205
+ result = update_rate_card(
1206
+ args.output or args.rate_card,
1207
+ source_file=args.source_file,
1208
+ )
1209
+ if args.as_json:
1210
+ _print_json(
1211
+ {
1212
+ "schema": "codex-usage-tracker-update-rate-card-v1",
1213
+ "rate_card_path": path_payload(result.path),
1214
+ "source_url": result.source_url,
1215
+ "fetched_at": result.fetched_at,
1216
+ "model_count": result.model_count,
1217
+ "alias_count": result.alias_count,
1218
+ "backup_path": path_payload(result.backup_path) if result.backup_path else None,
1219
+ }
1220
+ )
1221
+ return 0
1222
+ print(
1223
+ f"Wrote {result.model_count} Codex credit rates and {result.alias_count} aliases "
1224
+ f"to {result.path}"
1225
+ + (f" from {result.source_url}" if result.source_url else "")
1226
+ + (f" (backup: {result.backup_path})" if result.backup_path else "")
1227
+ )
1228
+ return 0
1229
+
1230
+
1231
+ def _run_init_thresholds(args: argparse.Namespace) -> int:
1232
+ output = write_threshold_template(args.output or args.thresholds, force=args.force)
1233
+ if args.as_json:
1234
+ _print_json(
1235
+ {
1236
+ "schema": "codex-usage-tracker-init-thresholds-v1",
1237
+ "thresholds_path": path_payload(output),
1238
+ "created": True,
1239
+ }
1240
+ )
1241
+ return 0
1242
+ print(f"Wrote recommendation threshold template to {output}")
1243
+ return 0
1244
+
1245
+
1246
+ def _run_init_projects(args: argparse.Namespace) -> int:
1247
+ output = write_project_template(args.output or args.projects, force=args.force)
1248
+ if args.as_json:
1249
+ _print_json(
1250
+ {
1251
+ "schema": "codex-usage-tracker-init-projects-v1",
1252
+ "projects_path": path_payload(output),
1253
+ "created": True,
1254
+ }
1255
+ )
1256
+ return 0
1257
+ print(f"Wrote project attribution template to {output}")
1258
+ return 0
1259
+
1260
+
1261
+ def _run_support_bundle(args: argparse.Namespace) -> int:
1262
+ output = build_support_bundle(
1263
+ output_path=args.output,
1264
+ codex_home=args.codex_home,
1265
+ db_path=args.db,
1266
+ pricing_path=args.pricing,
1267
+ allowance_path=args.allowance,
1268
+ rate_card_path=args.rate_card,
1269
+ thresholds_path=args.thresholds,
1270
+ projects_path=args.projects,
1271
+ privacy_mode=args.privacy_mode,
1272
+ )
1273
+ if args.as_json:
1274
+ _print_json(
1275
+ {
1276
+ "schema": "codex-usage-tracker-support-bundle-v1",
1277
+ "support_bundle_path": path_payload(output),
1278
+ "privacy": {
1279
+ "contains_raw_logs": False,
1280
+ "contains_prompts": False,
1281
+ "contains_assistant_messages": False,
1282
+ "contains_tool_output": False,
1283
+ "project_metadata_mode": args.privacy_mode,
1284
+ },
1285
+ }
1286
+ )
1287
+ return 0
1288
+ print(f"Wrote privacy-preserving support bundle to {output}")
1289
+ print("Bundle excludes raw logs, prompts, assistant messages, tool output, and context text.")
1290
+ return 0
1291
+
1292
+
1293
+ _COMMAND_HANDLERS = {
1294
+ "setup": _run_setup,
1295
+ "doctor": _run_doctor,
1296
+ "install-plugin": _run_install_plugin,
1297
+ "upgrade-plugin": _run_upgrade_plugin,
1298
+ "uninstall-plugin": _run_uninstall_plugin,
1299
+ "refresh": _run_refresh,
1300
+ "inspect-log": _run_inspect_log,
1301
+ "rebuild-index": _run_rebuild_index,
1302
+ "reset-db": _run_reset_db,
1303
+ "summary": _run_summary,
1304
+ "query": _run_query,
1305
+ "recommendations": _run_recommendations,
1306
+ "session": _run_session,
1307
+ "context": _run_context,
1308
+ "dashboard": _run_dashboard,
1309
+ "open-dashboard": _run_open_dashboard,
1310
+ "serve-dashboard": _run_serve_dashboard,
1311
+ "expensive": _run_expensive,
1312
+ "pricing-coverage": _run_pricing_coverage,
1313
+ "export": _run_export,
1314
+ "init-pricing": _run_init_pricing,
1315
+ "update-pricing": _run_update_pricing,
1316
+ "pin-pricing": _run_pin_pricing,
1317
+ "init-allowance": _run_init_allowance,
1318
+ "parse-allowance": _run_parse_allowance,
1319
+ "update-rate-card": _run_update_rate_card,
1320
+ "init-thresholds": _run_init_thresholds,
1321
+ "init-projects": _run_init_projects,
1322
+ "support-bundle": _run_support_bundle,
1323
+ }
1324
+
1325
+ if __name__ == "__main__":
1326
+ raise SystemExit(main())