Skip to content

Commit b7bd3eb

Browse files
committed
ship: prepare lane for review (automate + finalize passes)
- automate: extend ade-cli/cli.test.ts with 5 cases for the new `ade usage snapshot|refresh|budget` plan (123 → 123 incl. usage paths). - automate: refresh docs/ARCHITECTURE.md telemetry pointer to the header Usage popup (HeaderUsageControl + UsageQuotaPanel). - finalize/simplify: extract `finiteOrZero` for the Cursor spend reducers, flatten the cursorBlocker ternary into an if/else cascade, collapse the inline budget-set try block, drop unnecessary useMemo + try/catch on the ipc unsubscribe callbacks, replace nested ternaries in ExtraUsageCard / AuthChip / fillColor with explicit branches. - finalize/cli-parity: add `ade usage` examples to apps/ade-cli/README CLI surface inventory. Local gate: typecheck (desktop/ade-cli/web), eslint, vitest shards 1-8, and ade-cli tests all green.
1 parent 9d0a9b9 commit b7bd3eb

8 files changed

Lines changed: 148 additions & 62 deletions

File tree

apps/ade-cli/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ ade --socket macos-vm click --lane lane-id --x 120 --y 420 --text
8787
ade --socket update status --text
8888
ade --socket update check --text
8989
ade --socket update install --text
90+
ade usage snapshot --text
91+
ade usage refresh --text
92+
ade usage budget get --text
93+
ade usage budget set --from-file budget.json
94+
ade usage budget check --provider claude --scope global
95+
ade usage budget cumulative --scope global --text
9096
ade actions list
9197
ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts
9298
ade cursor cloud agents list --text

apps/ade-cli/src/cli.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2641,4 +2641,89 @@ describe("ADE CLI", () => {
26412641
expect((summarized as any).visual).toContain("\\- main (id: main) [main]");
26422642
expect((summarized as any).visual).toContain("\\- child (id: child) [feature]");
26432643
});
2644+
2645+
it("usage snapshot routes to the usage.getUsageSnapshot action with no args", () => {
2646+
const plan = buildCliPlan(["usage", "snapshot"]);
2647+
expect(plan.kind).toBe("execute");
2648+
if (plan.kind !== "execute") return;
2649+
expect(plan.label).toBe("usage snapshot");
2650+
expect(plan.steps).toHaveLength(1);
2651+
expect(plan.steps[0]?.params).toEqual({
2652+
name: "run_ade_action",
2653+
arguments: { domain: "usage", action: "getUsageSnapshot", args: {} },
2654+
});
2655+
2656+
// The `quota`/`quotas` aliases must dispatch to the same plan.
2657+
const aliased = buildCliPlan(["quota", "snapshot"]);
2658+
expect(aliased.kind).toBe("execute");
2659+
if (aliased.kind !== "execute") return;
2660+
expect(aliased.steps[0]?.params).toEqual(plan.steps[0]?.params);
2661+
});
2662+
2663+
it("usage refresh routes to the usage.forceRefresh action", () => {
2664+
const plan = buildCliPlan(["usage", "refresh"]);
2665+
expect(plan.kind).toBe("execute");
2666+
if (plan.kind !== "execute") return;
2667+
expect(plan.label).toBe("usage refresh");
2668+
expect(plan.steps).toHaveLength(1);
2669+
expect(plan.steps[0]?.params).toEqual({
2670+
name: "run_ade_action",
2671+
arguments: { domain: "usage", action: "forceRefresh", args: {} },
2672+
});
2673+
// `poll` is the documented alias.
2674+
const polled = buildCliPlan(["usage", "poll"]);
2675+
expect(polled.kind).toBe("execute");
2676+
if (polled.kind !== "execute") return;
2677+
expect(polled.steps[0]?.params).toEqual(plan.steps[0]?.params);
2678+
});
2679+
2680+
it("usage budget get routes to the budget.getConfig action", () => {
2681+
const plan = buildCliPlan(["usage", "budget", "get"]);
2682+
expect(plan.kind).toBe("execute");
2683+
if (plan.kind !== "execute") return;
2684+
expect(plan.label).toBe("usage budget get");
2685+
expect(plan.steps).toHaveLength(1);
2686+
expect(plan.steps[0]?.params).toEqual({
2687+
name: "run_ade_action",
2688+
arguments: { domain: "budget", action: "getConfig", args: {} },
2689+
});
2690+
});
2691+
2692+
it("usage budget set --from-file parses the JSON body and forwards it as args", () => {
2693+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-usage-budget-"));
2694+
const budgetPath = path.join(root, "budget.json");
2695+
const config = { caps: [{ provider: "claude", scope: "global", limitUsd: 25 }] };
2696+
fs.writeFileSync(budgetPath, JSON.stringify(config));
2697+
2698+
const plan = buildCliPlan(["usage", "budget", "set", "--from-file", budgetPath]);
2699+
expect(plan.kind).toBe("execute");
2700+
if (plan.kind !== "execute") return;
2701+
expect(plan.label).toBe("usage budget update");
2702+
expect(plan.steps[0]?.params).toEqual({
2703+
name: "run_ade_action",
2704+
arguments: { domain: "budget", action: "updateConfig", args: config },
2705+
});
2706+
2707+
// Empty body must surface as a CLI usage error, not silently send `{}`.
2708+
expect(() => buildCliPlan(["usage", "budget", "set", "--text", "[1,2,3]"]))
2709+
.toThrow(/must be a JSON object/i);
2710+
});
2711+
2712+
it("usage budget check defaults scope to global and forwards --provider", () => {
2713+
const plan = buildCliPlan(["usage", "budget", "check", "--provider", "claude"]);
2714+
expect(plan.kind).toBe("execute");
2715+
if (plan.kind !== "execute") return;
2716+
expect(plan.label).toBe("usage budget check");
2717+
expect(plan.steps[0]?.params).toEqual({
2718+
name: "run_ade_action",
2719+
arguments: {
2720+
domain: "budget",
2721+
action: "checkBudget",
2722+
args: { scope: "global", scopeId: null, provider: "claude" },
2723+
},
2724+
});
2725+
2726+
expect(() => buildCliPlan(["usage", "budget", "bogus"]))
2727+
.toThrow(/usage budget supports get, set, check, or cumulative/);
2728+
});
26442729
});

apps/ade-cli/src/cli.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3673,10 +3673,12 @@ function buildUsagePlan(args: string[]): CliPlan {
36733673
}
36743674
if (mode === "set" || mode === "update") {
36753675
const text = readFileTextInput(args);
3676-
let parsed: unknown = null;
3677-
if (text != null && text.trim().length > 0) {
3678-
try { parsed = JSON.parse(text); }
3679-
catch (error) {
3676+
const hasInlineBody = text != null && text.trim().length > 0;
3677+
let parsed: unknown;
3678+
if (hasInlineBody) {
3679+
try {
3680+
parsed = JSON.parse(text);
3681+
} catch (error) {
36803682
throw new CliUsageError(`Failed to parse budget config: ${error instanceof Error ? error.message : String(error)}`);
36813683
}
36823684
} else {

apps/desktop/src/main/services/ai/providerConnectionStatus.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,10 @@ export async function buildProviderConnections(
186186
}
187187
const cursorSdkAuth = Boolean(cursorEnvAuth || cursorStoredAuth);
188188
const cursorUsageAuth = Boolean(cursorSdkAuth || cursorAdminEnvAuth);
189-
let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | undefined;
189+
let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | "cursor-admin-env" | undefined;
190190
if (cursorEnvAuth) cursorCredsSource = "cursor-env";
191191
else if (cursorStoredAuth) cursorCredsSource = "cursor-api-key-store";
192+
else if (cursorAdminEnvAuth) cursorCredsSource = "cursor-admin-env";
192193
// Runtime is bundled with the app — it always exists. Only auth-related
193194
// fields should depend on whether a Cursor API key is present.
194195
const cursorFlags = {
@@ -200,13 +201,16 @@ export async function buildProviderConnections(
200201
runtimeAvailable: cursorSdkAuth,
201202
};
202203

203-
const cursorBlocker: string | null = cursorSdkAuth
204-
? null
205-
: cursorAdminEnvAuth
206-
? "Cursor Admin API key is configured for usage; add a Cursor agent API key for Cursor runtime access."
207-
: cursorStoreUnavailable
208-
? "ADE could not read the Cursor API key store yet. Retry after the key store is ready."
209-
: "Enter a Cursor API key from https://cursor.com/dashboard/integrations.";
204+
let cursorBlocker: string | null;
205+
if (cursorSdkAuth) {
206+
cursorBlocker = null;
207+
} else if (cursorAdminEnvAuth) {
208+
cursorBlocker = "Cursor Admin API key is configured for usage; add a Cursor agent API key for Cursor runtime access.";
209+
} else if (cursorStoreUnavailable) {
210+
cursorBlocker = "ADE could not read the Cursor API key store yet. Retry after the key store is ready.";
211+
} else {
212+
cursorBlocker = "Enter a Cursor API key from https://cursor.com/dashboard/integrations.";
213+
}
210214

211215
const cursor: AiProviderConnectionStatus = {
212216
...createUnavailableStatus("cursor", checkedAt),
@@ -219,7 +223,7 @@ export async function buildProviderConnections(
219223
{
220224
kind: "local-credentials",
221225
detected: cursorUsageAuth,
222-
source: cursorCredsSource ?? (cursorAdminEnvAuth ? "cursor-admin-env" : undefined),
226+
source: cursorCredsSource,
223227
},
224228
{
225229
kind: "cli",

apps/desktop/src/main/services/usage/usageTrackingService.ts

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,13 @@ function getCursorApiKey(): { key: string; source: "cursor-admin-env" | "cursor-
285285
return null;
286286
}
287287

288+
function finiteOrZero(value: unknown): number {
289+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
290+
}
291+
288292
function addOneMonth(timestampMs: number): number {
289-
const date = new Date(timestampMs);
290-
if (!Number.isFinite(date.getTime())) return 0;
291-
const next = new Date(date.getTime());
293+
const next = new Date(timestampMs);
294+
if (!Number.isFinite(next.getTime())) return 0;
292295
next.setMonth(next.getMonth() + 1);
293296
return next.getTime();
294297
}
@@ -300,40 +303,30 @@ function parseCursorSpendUsage(data: CursorSpendResponse): {
300303
const members = Array.isArray(data.teamMemberSpend) ? data.teamMemberSpend : [];
301304
if (members.length === 0) return { windows: [], extraUsage: null };
302305

306+
// overallSpendCents = on-demand + included usage (the real total spend);
307+
// spendCents alone captures only on-demand pay-as-you-go.
303308
const memberSpendCents = (member: CursorSpendMember): number => {
304-
// overallSpendCents = on-demand + included usage (the real total spend)
305-
// spendCents alone captures only on-demand pay-as-you-go.
306-
const overall = typeof member.overallSpendCents === "number" && Number.isFinite(member.overallSpendCents)
307-
? member.overallSpendCents
308-
: null;
309-
if (overall != null) return overall;
310-
return typeof member.spendCents === "number" && Number.isFinite(member.spendCents) ? member.spendCents : 0;
309+
if (typeof member.overallSpendCents === "number" && Number.isFinite(member.overallSpendCents)) {
310+
return member.overallSpendCents;
311+
}
312+
return finiteOrZero(member.spendCents);
311313
};
314+
// hardLimitOverrideDollars is the per-user override; fall back to the
315+
// team-wide monthlyLimitDollars when no override is configured.
312316
const memberLimitCents = (member: CursorSpendMember): number => {
313-
// hardLimitOverrideDollars is the per-user override; fall back to the
314-
// team-wide monthlyLimitDollars when no override is configured.
315-
const override =
316-
typeof member.hardLimitOverrideDollars === "number" && Number.isFinite(member.hardLimitOverrideDollars) && member.hardLimitOverrideDollars > 0
317-
? member.hardLimitOverrideDollars
318-
: 0;
319-
if (override > 0) return override * 100;
320-
const monthly =
321-
typeof member.monthlyLimitDollars === "number" && Number.isFinite(member.monthlyLimitDollars) && member.monthlyLimitDollars > 0
322-
? member.monthlyLimitDollars
323-
: 0;
324-
return monthly > 0 ? monthly * 100 : 0;
317+
const overrideDollars = finiteOrZero(member.hardLimitOverrideDollars);
318+
if (overrideDollars > 0) return overrideDollars * 100;
319+
const monthlyDollars = finiteOrZero(member.monthlyLimitDollars);
320+
return monthlyDollars > 0 ? monthlyDollars * 100 : 0;
325321
};
326322

327323
const totalSpendCents = members.reduce((sum, member) => sum + memberSpendCents(member), 0);
328324
const totalLimitCents = members.reduce((sum, member) => sum + memberLimitCents(member), 0);
329325

330-
const cycleStartMs =
331-
typeof data.subscriptionCycleStart === "number" && Number.isFinite(data.subscriptionCycleStart)
332-
? data.subscriptionCycleStart
333-
: 0;
326+
const cycleStartMs = finiteOrZero(data.subscriptionCycleStart);
334327
const resetMs = cycleStartMs > 0 ? addOneMonth(cycleStartMs) : 0;
335328
const resetsAt = resetMs > 0 ? new Date(resetMs).toISOString() : "";
336-
const windowDurationMs = resetMs > 0 && cycleStartMs > 0 ? Math.max(0, resetMs - cycleStartMs) : undefined;
329+
const windowDurationMs = resetMs > 0 ? Math.max(0, resetMs - cycleStartMs) : undefined;
337330

338331
const windows: UsageWindow[] = [];
339332
const utilization = totalLimitCents > 0 ? Math.min(100, (totalSpendCents / totalLimitCents) * 100) : null;

apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1+
import { useCallback, useEffect, useRef, useState } from "react";
22
import { CaretDown, CaretRight, Gauge, X } from "@phosphor-icons/react";
33
import type {
44
BudgetCapConfig,
@@ -73,11 +73,7 @@ export function HeaderUsageControl() {
7373
});
7474
return () => {
7575
cancelled = true;
76-
try {
77-
unsubscribe();
78-
} catch {
79-
// noop
80-
}
76+
unsubscribe();
8177
};
8278
}, []);
8379

@@ -124,7 +120,7 @@ export function HeaderUsageControl() {
124120

125121
const percent = summaryPercent(snapshot);
126122
const hasErrors = (snapshot?.errors.length ?? 0) > 0;
127-
const tone = useMemo(() => usageTone(percent, hasErrors), [hasErrors, percent]);
123+
const tone = usageTone(percent, hasErrors);
128124
const title = summaryTitle(snapshot, percent, hasErrors);
129125
const showDot = percent > 0 || hasErrors;
130126

apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,8 @@ export function UsageQuotaPanel({
130130
useEffect(() => {
131131
void load();
132132
if (!window.ade?.usage) return;
133-
const unsubscribe = window.ade.usage.onUpdate((nextSnapshot) => applySnapshot(nextSnapshot));
134-
return () => {
135-
try {
136-
unsubscribe();
137-
} catch {
138-
// noop
139-
}
140-
};
133+
const unsubscribe = window.ade.usage.onUpdate(applySnapshot);
134+
return unsubscribe;
141135
}, [applySnapshot, load]);
142136

143137
useEffect(() => {
@@ -306,7 +300,9 @@ function ExtraUsageCard({ extra }: { extra: ExtraUsage }) {
306300
const usedUsd = extra.usedCreditsUsd;
307301
const limitUsd = extra.monthlyLimitUsd;
308302
const percent = limitUsd > 0 ? Math.min(100, (usedUsd / limitUsd) * 100) : 0;
309-
const fillColor = percent > 90 ? "#EF4444" : percent > 70 ? "#F59E0B" : meta.color;
303+
let fillColor = meta.color;
304+
if (percent > 90) fillColor = "#EF4444";
305+
else if (percent > 70) fillColor = "#F59E0B";
310306

311307
const formatUsd = (v: number) => v.toLocaleString("en-US", { style: "currency", currency: extra.currency.toUpperCase() });
312308

@@ -353,11 +349,15 @@ function AuthChip({
353349
label: string;
354350
entry: AiProviderConnectionStatus | null;
355351
}) {
356-
const tone = entry?.runtimeAvailable
357-
? { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" }
358-
: entry?.authAvailable
359-
? { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy: entry.runtimeDetected ? "sign-in required" : "auth found locally" }
360-
: { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" };
352+
let tone: { border: string; bg: string; text: string; copy: string };
353+
if (entry?.runtimeAvailable) {
354+
tone = { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" };
355+
} else if (entry?.authAvailable) {
356+
const copy = entry.runtimeDetected ? "sign-in required" : "auth found locally";
357+
tone = { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy };
358+
} else {
359+
tone = { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" };
360+
}
361361

362362
return (
363363
<div

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1015,7 +1015,7 @@ Post-packaging hardening (`apps/desktop/scripts/`):
10151015
- **IPC tracing** — every handler emits `ipc.invoke.begin` / `ipc.invoke.done` / `ipc.invoke.failed` with call ID, channel, window ID, duration, summarized args. Mandatory for new handlers.
10161016
- **Renderer lifecycle**`renderer.route_change`, `renderer.tab_change`, `renderer.window_error`, `renderer.unhandled_rejection`, `renderer.event_loop_stall`. Mandatory for new surfaces that introduce novel lifecycle transitions.
10171017
- **Startup tasks**`project.startup_task_enabled`, `project.startup_task_skipped`, `project.startup_task_begin`, `project.startup_task_done` with durations.
1018-
- **Usage tracking**`usageTrackingService.ts` + `budgetCapService.ts` account for tokens and cost per provider/model/call-type; surfaced in Missions UI + Settings.
1018+
- **Usage tracking**`usageTrackingService.ts` + `budgetCapService.ts` account for tokens and cost per provider/model/call-type; surfaced in Missions UI and the top-bar Usage popup (`HeaderUsageControl``UsageQuotaPanel` + collapsible `BudgetCapEditor`).
10191019
- **No external telemetry** — ADE does not ship analytics to any cloud service. All telemetry is local.
10201020

10211021
### 15.3 Error surfaces

0 commit comments

Comments
 (0)