Skip to content

Show Free plan usage in zro status - #33

Merged
emirsoyturk merged 1 commit into
mainfrom
feat/free-plan-status
Oct 27, 2026
Merged

emirsoyturk merged 1 commit into
mainfrom
feat/free-plan-status

Conversation

@emirsoyturk

Copy link
Copy Markdown
Collaborator

zro status shows a Free plan account as Free, with the share of today's usage left and when it resets:

◆ Plan           Free
  ████████████████░░░░░░░░  64% left today · resets in 5h 12m

It reads billing.free (remainingPercent, resetsAt) from /api/cli/status, which moonmath-ai/zro-inference#370 adds. With a server that does not send it, the output stays as before ("Plan none"). No dollar amounts are shown for Free.

Tests: typecheck, 124 passed (2 skipped), including a new status test for a Free account.

When /api/cli/status sends billing.free, status shows the plan as Free with
the share of today's usage left and when it resets. Servers without the
field keep the existing output.
@emirsoyturk
emirsoyturk merged commit 8514830 into main Oct 27, 2026
26 checks passed
@emirsoyturk
emirsoyturk deleted the feat/free-plan-status branch October 27, 2026 22:16

@zro-inference zro-inference left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, tight PR: parseAccountStatus learns an optional billing.free field and printAccountStatus gains a Free branch plus a shared, well-named bar() helper and formatTimeUntil(), so Free users finally see their daily share and reset time instead of a bare none. The refactor of the paid path to reuse bar(ratio) is behavior-preserving and the Pro fixture test still passes. One blocker: the new Free bar is fed the raw server-supplied remainingPercent with no clamp, so an out-of-range value from any control plane crashes zro status mid-render with a RangeError and exit 1; several smaller robustness/coverage gaps (explicit free: null, the untested remainingPercent === null production path, and a $0.00 top-up line for Free) are noted inline.

JenClaude command reference

Reply with @MoonMathJen on this PR to trigger commands:

Command Example What it does
Re-review @MoonMathJen rereview Re-review after changes (full or scoped — see below)
Stop @MoonMathJen stop Cancel in-progress review
Stop + summarize @MoonMathJen stop and summarize Post partial findings so far
Focus specialty @MoonMathJen only security Review only the named specialty
Skip specialty @MoonMathJen skip test-coverage Review everything except the named specialty
Change model @MoonMathJen pr-review-model: kimi-k2.7-coder Run this review with a specific model
Add context @MoonMathJen focus on auth edge cases Free-text guidance passed to reviewers

Specialties: correctness, security, test-coverage, adversarial

Any text in the comment beyond a recognized command is passed to the reviewers as context. Use it to point them at areas that matter, explain what changed since the last review, or call out constraints (e.g. "skip generated files", "the auth flow is the riskiest part here"). Hard review rules still win over reviewer guidance.


JenClaude metrics: cost $0.000, time 14m 28s, tokens 3,162,284.

Comment thread src/run.ts
io.stdout.write(
free.remainingPercent === null
? ` ${resets}\n`
: ` ${bar(free.remainingPercent / 100)} ${colors.strong(`${free.remainingPercent}%`)} ` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Unclamped remainingPercent crashes zro status

free.remainingPercent arrives from /api/cli/status and is only validated as a finite number (line 825, via hasNumbers) — there is no range check. bar() then computes Math.round((p / 100) * 24) and does "░".repeat(24 - filled), so anything below ~-2 or above ~102 yields a negative repeat count and throws RangeError: Invalid count value: -12. The throw escapes printAccountStatus after the ACCOUNT header and Plan Free line are already written, so the user gets truncated output, no activity block, a raw Invalid count value on stderr and exit 1. This is reachable with a self-hosted or older control plane (ZRO_AUTH_URL can point anywhere), and a negative remaining percent is the natural encoding for an over-quota Free user. Note the paid path is safe only because line 872 clamps before calling bar().

Suggested fix: clamp inside bar() — const filled = Math.round(Math.min(Math.max(ratio, 0), 1) * width) — and clamp the printed percentage the same way so label and bar agree; add tests with remainingPercent: 150 and -5.

Comment thread src/run.ts
const activity = asRecord(root.activity30d);
const planValue = billing.plan;
const plan = planValue === null ? null : asRecord(planValue);
const free = billing.free === undefined ? null : asRecord(billing.free);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Explicit free: null degrades the entire ACCOUNT block

The branch mirrors the plan field one line above but drops the null case: billing.free === undefined ? null : asRecord(billing.free). Since asRecord(null) returns {}, a server that serializes the absent optional plan as JSON null produces a non-null free that then fails the guard at 824-827 (typeof free.resetsAt !== "string"), parseAccountStatus returns null, and zro status prints ◇ Details unavailable — losing plan, top-up credits and 30-day activity, with --json reporting accountStatus: "unavailable". A free object that omits remainingPercent fails identically, because undefined !== null. This is exactly the trust-boundary case the PR claims is safe (omission works, explicit null does not).

Suggested fix: const freeRaw = billing.free; const free = freeRaw === null || freeRaw === undefined ? null : asRecord(freeRaw);, treat remainingPercent === undefined like null in the guard, and add tests for free: null, a missing remainingPercent, and an unparseable resetsAt asserting the account still renders.

Comment thread src/run.ts
` ${bar(ratio)} ${colors.strong(`${Math.round(ratio * 100)}%`)} ${colors.muted("used")}\n`,
);
}
io.stdout.write(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Free accounts still get a dollar top-up line

The PR states "No dollar amounts are shown for Free", but the Top-up credits line below is unconditional, so the new test's own Free fixture renders ◇ Top-up credits $0.00 left · $0.00 total — an always-zero money line directly under a percentage. A Free account cannot hold pack balance (buying a pack moves it off Free), so this is pure noise that contradicts the stated contract, and the new test only asserts not.toContain("none"), so it cannot catch the mismatch.

Suggested fix: skip the Top-up line (or its money values) when free is set and packs.total === 0, or print "No credits" in words; then assert the intended behavior in the test (expect(output).not.toContain("$")).

Comment thread src/run.ts
io.stdout.write(` ${colors.good("◆")} Plan ${colors.strong("Free")}\n`);
const resets = colors.muted(`resets in ${formatTimeUntil(free.resetsAt)}`);
io.stdout.write(
free.remainingPercent === null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The remainingPercent === null branch is the production path but is untested

The server sets remainingPercent: null whenever the budget lookup returns nothing (budget fetch failure, account not yet reconciled onto Free), so this branch — rendering just resets in 5h 12m with no bar and no percentage — is the one real Free users will often hit. The new test only exercises remainingPercent: 64, so an inverted ternary or a stray ${free.remainingPercent}% would ship silently.

Suggested fix: add a second status test with free: { remainingPercent: null, resetsAt: ... } asserting the output contains resets in and does not contain % left today or undefined.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants