Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
26 changes: 26 additions & 0 deletions devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand Down
28 changes: 28 additions & 0 deletions tests/test_devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading