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,624 @@
1
+ """Read-only environment diagnostics for the local Codex usage tracker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from codex_usage_tracker.paths import (
15
+ DEFAULT_CODEX_HOME,
16
+ DEFAULT_DASHBOARD_PATH,
17
+ DEFAULT_DB_PATH,
18
+ DEFAULT_MARKETPLACE_PATH,
19
+ DEFAULT_PLUGIN_LINK,
20
+ DEFAULT_PRICING_PATH,
21
+ )
22
+ from codex_usage_tracker.pricing import load_pricing_config
23
+ from codex_usage_tracker.store import refresh_metadata, schema_state
24
+
25
+ PLUGIN_NAME = "codex-usage-tracker"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DoctorCheck:
30
+ name: str
31
+ status: str
32
+ detail: str
33
+ remediation: str | None = None
34
+
35
+ def to_dict(self) -> dict[str, str | None]:
36
+ return asdict(self)
37
+
38
+
39
+ def run_doctor(
40
+ *,
41
+ codex_home: Path = DEFAULT_CODEX_HOME,
42
+ db_path: Path = DEFAULT_DB_PATH,
43
+ dashboard_path: Path = DEFAULT_DASHBOARD_PATH,
44
+ pricing_path: Path = DEFAULT_PRICING_PATH,
45
+ plugin_link: Path = DEFAULT_PLUGIN_LINK,
46
+ marketplace_path: Path = DEFAULT_MARKETPLACE_PATH,
47
+ repo_root: Path | None = None,
48
+ suggest_repair: bool = False,
49
+ ) -> dict[str, Any]:
50
+ """Run read-only setup checks and return a structured report."""
51
+
52
+ root = repo_root or _resolve_plugin_root(plugin_link) or find_project_root()
53
+ checks = [
54
+ _check_package_import(),
55
+ _check_codex_sessions(codex_home),
56
+ _check_database(db_path),
57
+ _check_database_schema(db_path),
58
+ _check_parser_diagnostics(db_path),
59
+ _check_dashboard_target(dashboard_path),
60
+ _check_pricing(pricing_path),
61
+ _check_project_root(root),
62
+ _check_plugin_link(plugin_link, root),
63
+ _check_marketplace(marketplace_path),
64
+ _check_mcp_config(root),
65
+ _check_mcp_runtime(root),
66
+ _check_mcp_import(),
67
+ ]
68
+ fail_count = sum(1 for check in checks if check.status == "fail")
69
+ warn_count = sum(1 for check in checks if check.status == "warn")
70
+ report: dict[str, Any] = {
71
+ "schema": "codex-usage-tracker-doctor-v1",
72
+ "status": "fail" if fail_count else "warn" if warn_count else "pass",
73
+ "failures": fail_count,
74
+ "warnings": warn_count,
75
+ "checks": [check.to_dict() for check in checks],
76
+ }
77
+ if suggest_repair:
78
+ report["repair_suggestions"] = [
79
+ check.remediation
80
+ for check in checks
81
+ if check.status in {"warn", "fail"} and check.remediation
82
+ ]
83
+ return report
84
+
85
+
86
+ def find_project_root() -> Path | None:
87
+ """Find a checkout root when running from source, installed package, or plugin cwd."""
88
+
89
+ candidates = [Path.cwd()]
90
+ module_path = Path(__file__).resolve()
91
+ candidates.extend(module_path.parents)
92
+ for candidate in candidates:
93
+ if (candidate / ".codex-plugin" / "plugin.json").exists() and (
94
+ candidate / ".mcp.json"
95
+ ).exists():
96
+ return candidate
97
+ return None
98
+
99
+
100
+ def _looks_like_plugin_root(path: Path) -> bool:
101
+ return (path / ".codex-plugin" / "plugin.json").exists() and (path / ".mcp.json").exists()
102
+
103
+
104
+ def _resolve_plugin_root(plugin_link: Path) -> Path | None:
105
+ if not plugin_link.exists() and not plugin_link.is_symlink():
106
+ return None
107
+ target = plugin_link.resolve() if plugin_link.is_symlink() else plugin_link
108
+ return target if _looks_like_plugin_root(target) else None
109
+
110
+
111
+ def _check_package_import() -> DoctorCheck:
112
+ spec = importlib.util.find_spec("codex_usage_tracker")
113
+ if spec is None:
114
+ return DoctorCheck(
115
+ "Python package",
116
+ "fail",
117
+ "codex_usage_tracker is not importable.",
118
+ 'Install from the repo with: python -m pip install ".[dev]"',
119
+ )
120
+ return DoctorCheck("Python package", "pass", "codex_usage_tracker is importable.")
121
+
122
+
123
+ def _check_codex_sessions(codex_home: Path) -> DoctorCheck:
124
+ sessions = codex_home / "sessions"
125
+ if sessions.is_dir():
126
+ return DoctorCheck("Codex sessions", "pass", f"Found sessions at {sessions}.")
127
+ if codex_home.exists():
128
+ return DoctorCheck(
129
+ "Codex sessions",
130
+ "warn",
131
+ f"Codex home exists, but sessions directory was not found: {sessions}",
132
+ "Open Codex and run at least one session, or pass --codex-home to refresh.",
133
+ )
134
+ return DoctorCheck(
135
+ "Codex sessions",
136
+ "warn",
137
+ f"Codex home was not found: {codex_home}",
138
+ "Start Codex once before refreshing usage data.",
139
+ )
140
+
141
+
142
+ def _check_database(db_path: Path) -> DoctorCheck:
143
+ if db_path.exists():
144
+ if os.access(db_path, os.R_OK):
145
+ return DoctorCheck("SQLite database", "pass", f"Database is readable: {db_path}")
146
+ return DoctorCheck(
147
+ "SQLite database",
148
+ "fail",
149
+ f"Database exists but is not readable: {db_path}",
150
+ "Check file permissions.",
151
+ )
152
+ if db_path.parent.exists():
153
+ return DoctorCheck(
154
+ "SQLite database",
155
+ "warn",
156
+ f"Database has not been created yet: {db_path}",
157
+ "Run: codex-usage-tracker refresh",
158
+ )
159
+ return DoctorCheck(
160
+ "SQLite database",
161
+ "warn",
162
+ f"Database directory has not been created yet: {db_path.parent}",
163
+ "Run: codex-usage-tracker refresh",
164
+ )
165
+
166
+
167
+ def _check_database_schema(db_path: Path) -> DoctorCheck:
168
+ state = schema_state(db_path)
169
+ if not state["exists"]:
170
+ return DoctorCheck(
171
+ "Database schema",
172
+ "warn",
173
+ "Database schema has not been initialized yet.",
174
+ "Run: codex-usage-tracker refresh",
175
+ )
176
+ version = state["schema_version"]
177
+ expected = state["expected_schema_version"]
178
+ if version != expected:
179
+ return DoctorCheck(
180
+ "Database schema",
181
+ "warn",
182
+ f"Database schema is at version {version}; expected {expected}.",
183
+ "Run: codex-usage-tracker rebuild-index if refresh does not migrate it cleanly.",
184
+ )
185
+ if not state["checksum_matches"]:
186
+ return DoctorCheck(
187
+ "Database schema",
188
+ "warn",
189
+ "usage_events schema checksum does not match the package schema.",
190
+ "Run: codex-usage-tracker rebuild-index after confirming your local aggregate index can be regenerated.",
191
+ )
192
+ return DoctorCheck(
193
+ "Database schema",
194
+ "pass",
195
+ f"Schema version {version} is current.",
196
+ )
197
+
198
+
199
+ def _check_parser_diagnostics(db_path: Path) -> DoctorCheck:
200
+ metadata = refresh_metadata(db_path)
201
+ if not metadata:
202
+ return DoctorCheck(
203
+ "Parser diagnostics",
204
+ "warn",
205
+ "No parser diagnostics are available yet.",
206
+ "Run: codex-usage-tracker refresh",
207
+ )
208
+ diagnostics = {
209
+ key.removeprefix("parser_"): _safe_int(value)
210
+ for key, value in metadata.items()
211
+ if key.startswith("parser_")
212
+ }
213
+ drift_keys = [
214
+ key
215
+ for key in (
216
+ "missing_last_token_usage",
217
+ "missing_total_token_usage",
218
+ "missing_cumulative_total",
219
+ "unknown_event_shape",
220
+ "partial_field_count",
221
+ "invalid_integer",
222
+ )
223
+ if diagnostics.get(key, 0)
224
+ ]
225
+ skipped = _safe_int(metadata.get("skipped_events"))
226
+ if skipped or drift_keys:
227
+ parts = [f"skipped_events={skipped}"] if skipped else []
228
+ parts.extend(f"{key}={diagnostics[key]}" for key in drift_keys)
229
+ return DoctorCheck(
230
+ "Parser diagnostics",
231
+ "warn",
232
+ "Schema drift detected in latest refresh: " + ", ".join(parts) + ".",
233
+ "Run: codex-usage-tracker inspect-log <path> on a skipped log, then rebuild-index after updating parser support.",
234
+ )
235
+ parsed = metadata.get("parsed_events", "0")
236
+ scanned = metadata.get("scanned_files", "0")
237
+ return DoctorCheck(
238
+ "Parser diagnostics",
239
+ "pass",
240
+ f"Latest refresh parsed {parsed} events from {scanned} files with no drift diagnostics.",
241
+ )
242
+
243
+
244
+ def _check_dashboard_target(dashboard_path: Path) -> DoctorCheck:
245
+ if dashboard_path.exists():
246
+ return DoctorCheck("Dashboard", "pass", f"Dashboard exists: {dashboard_path}")
247
+ return DoctorCheck(
248
+ "Dashboard",
249
+ "warn",
250
+ f"Dashboard has not been generated yet: {dashboard_path}",
251
+ "Run: codex-usage-tracker dashboard",
252
+ )
253
+
254
+
255
+ def _check_pricing(pricing_path: Path) -> DoctorCheck:
256
+ config = load_pricing_config(pricing_path)
257
+ if config.error:
258
+ return DoctorCheck(
259
+ "Pricing config",
260
+ "fail",
261
+ f"Pricing config is invalid: {config.error}",
262
+ f"Fix or recreate {pricing_path}.",
263
+ )
264
+ if not config.loaded:
265
+ return DoctorCheck(
266
+ "Pricing config",
267
+ "warn",
268
+ f"No local pricing config found: {pricing_path}",
269
+ "Cost estimates are disabled until you run: codex-usage-tracker update-pricing",
270
+ )
271
+ source = config.source or {}
272
+ source_url = source.get("url")
273
+ tier = source.get("tier")
274
+ source_detail = f" Source: {source_url} ({tier})." if source_url and tier else ""
275
+ return DoctorCheck(
276
+ "Pricing config",
277
+ "pass",
278
+ f"Loaded {len(config.models)} local model pricing entries from {pricing_path}.{source_detail}",
279
+ )
280
+
281
+
282
+ def _check_project_root(repo_root: Path | None) -> DoctorCheck:
283
+ if repo_root is None:
284
+ return DoctorCheck(
285
+ "Plugin root",
286
+ "warn",
287
+ "Could not find .codex-plugin/plugin.json and .mcp.json from current paths.",
288
+ "Run from the codex-usage-tracker repo, or install with: codex-usage-tracker install-plugin",
289
+ )
290
+ return DoctorCheck("Plugin root", "pass", f"Detected plugin root: {repo_root}")
291
+
292
+
293
+ def _check_plugin_link(plugin_link: Path, repo_root: Path | None) -> DoctorCheck:
294
+ if not plugin_link.exists() and not plugin_link.is_symlink():
295
+ return DoctorCheck(
296
+ "Plugin registration",
297
+ "warn",
298
+ f"Plugin path is missing: {plugin_link}",
299
+ "Run: codex-usage-tracker install-plugin",
300
+ )
301
+ if plugin_link.is_symlink():
302
+ target = plugin_link.resolve()
303
+ if _looks_like_plugin_root(target):
304
+ kind = "source checkout" if repo_root and target == repo_root.resolve() else "plugin wrapper"
305
+ return DoctorCheck(
306
+ "Plugin registration",
307
+ "pass",
308
+ f"Plugin symlink points to a {kind}: {target}.",
309
+ )
310
+ return DoctorCheck(
311
+ "Plugin registration",
312
+ "fail",
313
+ f"Plugin symlink points to {target}, but no plugin manifest and MCP config were found there.",
314
+ "Replace it with: codex-usage-tracker install-plugin --force",
315
+ )
316
+ if plugin_link.is_dir() and _looks_like_plugin_root(plugin_link):
317
+ return DoctorCheck(
318
+ "Plugin registration",
319
+ "pass",
320
+ f"Plugin directory exists: {plugin_link}.",
321
+ )
322
+ if not plugin_link.is_symlink():
323
+ return DoctorCheck(
324
+ "Plugin registration",
325
+ "fail",
326
+ f"Plugin path exists but is not a generated plugin directory or symlink: {plugin_link}",
327
+ "Move the existing path or install with: codex-usage-tracker install-plugin --force",
328
+ )
329
+ return DoctorCheck("Plugin registration", "pass", f"Plugin path exists: {plugin_link}.")
330
+
331
+
332
+ def _check_marketplace(marketplace_path: Path) -> DoctorCheck:
333
+ if not marketplace_path.exists():
334
+ return DoctorCheck(
335
+ "Marketplace entry",
336
+ "warn",
337
+ f"Marketplace file is missing: {marketplace_path}",
338
+ "Run: codex-usage-tracker install-plugin",
339
+ )
340
+ try:
341
+ data = json.loads(marketplace_path.read_text(encoding="utf-8"))
342
+ except (OSError, json.JSONDecodeError) as exc:
343
+ return DoctorCheck(
344
+ "Marketplace entry",
345
+ "fail",
346
+ f"Marketplace file is invalid: {exc}",
347
+ "Fix JSON or restore from backup before reinstalling.",
348
+ )
349
+ plugins = data.get("plugins") if isinstance(data, dict) else None
350
+ if not isinstance(plugins, list):
351
+ return DoctorCheck(
352
+ "Marketplace entry",
353
+ "fail",
354
+ "Marketplace JSON does not contain a plugins list.",
355
+ "Fix marketplace structure before reinstalling.",
356
+ )
357
+ for entry in plugins:
358
+ if isinstance(entry, dict) and entry.get("name") == PLUGIN_NAME:
359
+ return DoctorCheck(
360
+ "Marketplace entry",
361
+ "pass",
362
+ f"Found {PLUGIN_NAME} in {marketplace_path}.",
363
+ )
364
+ return DoctorCheck(
365
+ "Marketplace entry",
366
+ "warn",
367
+ f"No {PLUGIN_NAME} entry found in {marketplace_path}.",
368
+ "Run: codex-usage-tracker install-plugin",
369
+ )
370
+
371
+
372
+ def _check_mcp_config(repo_root: Path | None) -> DoctorCheck:
373
+ if repo_root is None:
374
+ return DoctorCheck(
375
+ "MCP config",
376
+ "warn",
377
+ "Cannot check .mcp.json without a detected project root.",
378
+ "Run from the codex-usage-tracker repo, or install with: codex-usage-tracker install-plugin",
379
+ )
380
+ config_path = repo_root / ".mcp.json"
381
+ if not config_path.exists():
382
+ return DoctorCheck(
383
+ "MCP config",
384
+ "fail",
385
+ f"Missing MCP config: {config_path}",
386
+ "Restore .mcp.json from the repo.",
387
+ )
388
+ try:
389
+ data = json.loads(config_path.read_text(encoding="utf-8"))
390
+ except (OSError, json.JSONDecodeError) as exc:
391
+ return DoctorCheck(
392
+ "MCP config",
393
+ "fail",
394
+ f"MCP config is invalid JSON: {exc}",
395
+ "Fix .mcp.json.",
396
+ )
397
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
398
+ server = servers.get(PLUGIN_NAME) if isinstance(servers, dict) else None
399
+ if not isinstance(server, dict):
400
+ return DoctorCheck(
401
+ "MCP config",
402
+ "fail",
403
+ f"No {PLUGIN_NAME} MCP server entry found.",
404
+ "Restore the server entry in .mcp.json.",
405
+ )
406
+ command = server.get("command")
407
+ if not isinstance(command, str) or not command:
408
+ return DoctorCheck(
409
+ "MCP config",
410
+ "fail",
411
+ "MCP server command is missing.",
412
+ "Set the command to a Python executable that can import codex_usage_tracker.",
413
+ )
414
+ command_path = (repo_root / command).resolve() if command.startswith(".") else Path(command)
415
+ if command.startswith(".") and not command_path.exists():
416
+ return DoctorCheck(
417
+ "MCP config",
418
+ "warn",
419
+ f"MCP command does not exist yet: {command_path}",
420
+ "Create the venv and install the package.",
421
+ )
422
+ env = server.get("env")
423
+ env_detail = (
424
+ " with PYTHONPATH override"
425
+ if isinstance(env, dict) and isinstance(env.get("PYTHONPATH"), str)
426
+ else ""
427
+ )
428
+ return DoctorCheck(
429
+ "MCP config",
430
+ "pass",
431
+ f"MCP server command is configured: {command}{env_detail}.",
432
+ )
433
+
434
+
435
+ def _check_mcp_runtime(repo_root: Path | None) -> DoctorCheck:
436
+ if repo_root is None:
437
+ return DoctorCheck(
438
+ "MCP runtime",
439
+ "warn",
440
+ "Cannot validate the MCP runtime without a detected plugin root.",
441
+ "Run from the codex-usage-tracker repo, or install with: codex-usage-tracker install-plugin",
442
+ )
443
+ config_path = repo_root / ".mcp.json"
444
+ try:
445
+ data = json.loads(config_path.read_text(encoding="utf-8"))
446
+ except (OSError, json.JSONDecodeError):
447
+ return DoctorCheck(
448
+ "MCP runtime",
449
+ "warn",
450
+ "Cannot validate the MCP runtime until .mcp.json is readable and valid.",
451
+ "Fix .mcp.json, then rerun: codex-usage-tracker doctor --suggest-repair",
452
+ )
453
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
454
+ server = servers.get(PLUGIN_NAME) if isinstance(servers, dict) else None
455
+ if not isinstance(server, dict):
456
+ return DoctorCheck(
457
+ "MCP runtime",
458
+ "warn",
459
+ "Cannot validate the MCP runtime until the codex-usage-tracker server is configured.",
460
+ "Restore the server entry in .mcp.json.",
461
+ )
462
+ args = server.get("args")
463
+ if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
464
+ return DoctorCheck(
465
+ "MCP runtime",
466
+ "warn",
467
+ "MCP server args are missing or not a string list.",
468
+ "Restore the generated plugin wrapper with: codex-usage-tracker install-plugin --force",
469
+ )
470
+ if _uses_bootstrap_launcher(args):
471
+ return _check_mcp_launcher(repo_root, args)
472
+ if not _uses_direct_mcp_module(args):
473
+ return DoctorCheck(
474
+ "MCP runtime",
475
+ "warn",
476
+ "MCP server does not use the expected module or launcher form, so import validation was skipped.",
477
+ "Restore the generated plugin wrapper with: codex-usage-tracker install-plugin --force",
478
+ )
479
+ command = _resolve_mcp_command(server.get("command"), repo_root)
480
+ if command is None:
481
+ return DoctorCheck(
482
+ "MCP runtime",
483
+ "fail",
484
+ f"MCP server command is not executable: {server.get('command')!r}.",
485
+ "Reinstall the plugin with a working Python: codex-usage-tracker install-plugin --force",
486
+ )
487
+ env = os.environ.copy()
488
+ configured_env = server.get("env")
489
+ if isinstance(configured_env, dict):
490
+ env.update({str(key): str(value) for key, value in configured_env.items()})
491
+ cwd = _resolve_mcp_cwd(server.get("cwd"), repo_root)
492
+ check = "import codex_usage_tracker.mcp_server; import mcp.server.fastmcp"
493
+ try:
494
+ result = subprocess.run(
495
+ [command, "-c", check],
496
+ cwd=cwd,
497
+ env=env,
498
+ capture_output=True,
499
+ text=True,
500
+ timeout=20,
501
+ )
502
+ except subprocess.TimeoutExpired:
503
+ return DoctorCheck(
504
+ "MCP runtime",
505
+ "fail",
506
+ f"MCP Python timed out while importing the server: {command}",
507
+ "Reinstall the plugin with a Python environment that can import codex_usage_tracker and mcp.",
508
+ )
509
+ except OSError as exc:
510
+ return DoctorCheck(
511
+ "MCP runtime",
512
+ "fail",
513
+ f"MCP Python could not be executed: {exc}",
514
+ "Reinstall the plugin with a working Python: codex-usage-tracker install-plugin --force",
515
+ )
516
+ if result.returncode:
517
+ stderr = _first_error_line(result.stderr) or _first_error_line(result.stdout)
518
+ detail = f"MCP Python cannot import the server: {command}"
519
+ if stderr:
520
+ detail += f" ({stderr})"
521
+ return DoctorCheck(
522
+ "MCP runtime",
523
+ "fail",
524
+ detail,
525
+ (
526
+ "If this is a source checkout, rerun: codex-usage-tracker install-plugin "
527
+ "--python .venv/bin/python --force. Otherwise reinstall with pipx and rerun setup."
528
+ ),
529
+ )
530
+ return DoctorCheck(
531
+ "MCP runtime",
532
+ "pass",
533
+ f"MCP Python can import codex_usage_tracker.mcp_server: {command}",
534
+ )
535
+
536
+
537
+ def _uses_direct_mcp_module(args: list[str]) -> bool:
538
+ return "-m" in args and "codex_usage_tracker.mcp_server" in args
539
+
540
+
541
+ def _uses_bootstrap_launcher(args: list[str]) -> bool:
542
+ return any(arg.endswith("skills/codex-usage-tracker/scripts/run_mcp.py") for arg in args)
543
+
544
+
545
+ def _check_mcp_launcher(repo_root: Path, args: list[str]) -> DoctorCheck:
546
+ script_args = [
547
+ (repo_root / arg).resolve()
548
+ for arg in args
549
+ if arg.endswith("skills/codex-usage-tracker/scripts/run_mcp.py")
550
+ ]
551
+ if not script_args:
552
+ return DoctorCheck(
553
+ "MCP runtime",
554
+ "warn",
555
+ "MCP launcher script could not be resolved.",
556
+ "Restore the bundled launcher path in .mcp.json.",
557
+ )
558
+ script_path = script_args[0]
559
+ if not script_path.exists():
560
+ return DoctorCheck(
561
+ "MCP runtime",
562
+ "fail",
563
+ f"MCP launcher script is missing: {script_path}",
564
+ "Restore skills/codex-usage-tracker/scripts/run_mcp.py.",
565
+ )
566
+ return DoctorCheck(
567
+ "MCP runtime",
568
+ "pass",
569
+ "MCP bootstrap launcher is present; it validates or installs the runtime on startup.",
570
+ )
571
+
572
+
573
+ def _resolve_mcp_command(command: object, repo_root: Path) -> str | None:
574
+ if not isinstance(command, str) or not command:
575
+ return None
576
+ command_path = Path(command)
577
+ if command_path.is_absolute():
578
+ return str(command_path) if command_path.exists() else None
579
+ if command.startswith("."):
580
+ resolved = (repo_root / command_path).resolve()
581
+ return str(resolved) if resolved.exists() else None
582
+ return shutil.which(command)
583
+
584
+
585
+ def _resolve_mcp_cwd(cwd: object, repo_root: Path) -> Path:
586
+ if not isinstance(cwd, str) or not cwd:
587
+ return repo_root
588
+ cwd_path = Path(cwd)
589
+ return cwd_path if cwd_path.is_absolute() else (repo_root / cwd_path).resolve()
590
+
591
+
592
+ def _first_error_line(text: str) -> str:
593
+ for line in text.splitlines():
594
+ stripped = line.strip()
595
+ if stripped:
596
+ return stripped
597
+ return ""
598
+
599
+
600
+ def _check_mcp_import() -> DoctorCheck:
601
+ module_spec = importlib.util.find_spec("codex_usage_tracker.mcp_server")
602
+ if module_spec is None:
603
+ return DoctorCheck(
604
+ "MCP module",
605
+ "fail",
606
+ "MCP server module could not be found.",
607
+ 'Install dependencies with: python -m pip install ".[dev]"',
608
+ )
609
+ sdk_spec = importlib.util.find_spec("mcp.server.fastmcp")
610
+ if sdk_spec is None:
611
+ return DoctorCheck(
612
+ "MCP module",
613
+ "fail",
614
+ "FastMCP SDK dependency could not be found.",
615
+ 'Install dependencies with: python -m pip install ".[dev]"',
616
+ )
617
+ return DoctorCheck("MCP module", "pass", "MCP server module is discoverable.")
618
+
619
+
620
+ def _safe_int(value: object) -> int:
621
+ try:
622
+ return int(str(value))
623
+ except (TypeError, ValueError):
624
+ return 0