From d345b9a294bf6192a62e399bba7f605088cbf393 Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 30 Oct 2026 00:22:11 -0400 Subject: [PATCH] feat: --usage-json prints the usage read for scripts --- CHANGELOG.md | 3 +++ README.md | 1 + devdash.py | 26 ++++++++++++++++++++++++++ tests/test_devdash.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067b1eb..37108d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project are documented here. The format follows ### Added +- `--usage-json` prints one usage read as JSON and exits: omp's report, providers omp dropped + filled from the last good read and marked `stale`, and a `pace` in points on each limit that has one. + Scripts (for example a quota-aware model router) read it instead of re-implementing devdash. - Custom integrations: any number of `[[integrations]]` sections, each a command whose output devdash shows at `top`, `after-usage`, `after-my-prs`, or `bottom`. Each runs on its own thread and schedule, with a timeout that kills its whole process group, and a diff --git a/README.md b/README.md index 09eae87..bd712bf 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ devdash --exclude acme # hide every repository of a user or organiza devdash --no-review --no-usage # show only your own PRs devdash --no-integration weather # hide one custom integration (--no-integrations hides all) devdash --profile personal --once # show usage from the personal omp profile +devdash --usage-json # print the usage read as JSON for scripts, with pace points devdash --github alice # MY PRS / REVIEW REQUESTED for this gh login devdash --help # all flags ``` diff --git a/devdash.py b/devdash.py index ba0b0d2..e771243 100755 --- a/devdash.py +++ b/devdash.py @@ -357,6 +357,27 @@ def carry_over(old, new): return new +def usage_json(profile=None): + """One usage read for scripts: omp's report, providers omp dropped filled from the + last good read (marked stale), and a `pace` in points on each limit that has one.""" + cache = profile if profile is not None else os.environ.get("OMP_PROFILE") + data = load_last_good(cache) + try: + data = carry_over(data, fetch_usage(False, profile)) + save_last_good(data, cache) + except Exception as e: + if not data: + raise SystemExit(f"devdash: usage read failed ({e})") from None + data = {"reports": [dict(r, stale=True) for r in data.get("reports", [])]} + now = time.time() + for report in data.get("reports", []): + for lim in report.get("limits", []): + points = pace(lim, now) + if points is not None: + lim["pace"] = points + return data + + def last_good_path(profile=None): """Return the cache path for a selected omp profile, or the legacy default.""" if profile is None: @@ -1565,12 +1586,17 @@ def parse_args(): ap.add_argument("--cached", action="store_true", help="never force a usage refetch; show whatever omp's shared cache holds") ap.add_argument("--once", action="store_true", help="print one frame and exit") + ap.add_argument("--usage-json", action="store_true", + help="print the AI usage read as JSON, with stale carry-over and pace points, and exit") ap.add_argument("-V", "--version", action="version", version=f"devdash {__version__}") return ap.parse_args() def main(): args = parse_args() + if args.usage_json: + print(json.dumps(usage_json(args.profile))) + return path = args.config or os.environ.get("DEVDASH_CONFIG") cfg = load_config(os.path.expanduser(path or CONFIG_PATH), explicit=bool(path)) gh, usage = cfg["github"], cfg["usage"] diff --git a/tests/test_devdash.py b/tests/test_devdash.py index dcee816..eecf811 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -1022,3 +1022,31 @@ def test_integrations_render_in_their_slots_between_built_in_sections(): heads = [r for r in console.export_text().splitlines() if r.split(" ")[0].isupper() and r.strip()] assert [h.split(" ")[0].strip() for h in heads] == [ "TOP", "USAGE", "AFTER-USAGE", "MY PRS 0", "AFTER-MY-PRS", "REVIEW REQUESTED 0", "BOTTOM", "BOTTOM2"] + + +def test_usage_json_adds_pace_and_keeps_stale_reads(monkeypatch, tmp_path): + day = 86400 + now = time.time() + weekly = {"window": {"id": "7d", "durationMs": 7 * day * 1000, "resetsAt": (now + 3.5 * day) * 1000}, + "amount": {"usedFraction": 0.8}, "status": "ok"} + old = {"reports": [{"provider": "anthropic", "limits": [weekly]}]} + new = {"reports": [{"provider": "cursor", "limits": []}], "accountsWithoutUsage": [{"provider": "anthropic"}]} + monkeypatch.setattr(devdash, "LAST_GOOD", str(tmp_path / "last.json")) + monkeypatch.delenv("OMP_PROFILE", raising=False) + devdash.save_last_good(old) + monkeypatch.setattr(devdash, "fetch_usage", lambda invalidate, profile=None: new) + data = devdash.usage_json() + anthropic = next(r for r in data["reports"] if r["provider"] == "anthropic") + assert anthropic["stale"] is True + assert anthropic["limits"][0]["pace"] == 30 + + +def test_usage_json_falls_back_to_last_good_on_failure(monkeypatch, tmp_path): + monkeypatch.setattr(devdash, "LAST_GOOD", str(tmp_path / "last.json")) + monkeypatch.delenv("OMP_PROFILE", raising=False) + devdash.save_last_good({"reports": [{"provider": "anthropic", "limits": []}]}) + + def fail(invalidate, profile=None): + raise RuntimeError("429") + monkeypatch.setattr(devdash, "fetch_usage", fail) + assert devdash.usage_json()["reports"][0]["stale"] is True