Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ ade --socket macos-vm click --lane lane-id --x 120 --y 420 --text
ade --socket update status --text
ade --socket update check --text
ade --socket update install --text
ade usage snapshot --text
ade usage refresh --text
ade usage budget get --text
ade usage budget set --from-file budget.json
ade usage budget check --provider claude --scope global
ade usage budget cumulative --scope global --text
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ade actions list
ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts
ade cursor cloud agents list --text
Expand Down
85 changes: 85 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2754,4 +2754,89 @@ describe("ADE CLI", () => {
expect((summarized as any).visual).toContain("\\- main (id: main) [main]");
expect((summarized as any).visual).toContain("\\- child (id: child) [feature]");
});

it("usage snapshot routes to the usage.getUsageSnapshot action with no args", () => {
const plan = buildCliPlan(["usage", "snapshot"]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.label).toBe("usage snapshot");
expect(plan.steps).toHaveLength(1);
expect(plan.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: { domain: "usage", action: "getUsageSnapshot", args: {} },
});

// The `quota`/`quotas` aliases must dispatch to the same plan.
const aliased = buildCliPlan(["quota", "snapshot"]);
expect(aliased.kind).toBe("execute");
if (aliased.kind !== "execute") return;
expect(aliased.steps[0]?.params).toEqual(plan.steps[0]?.params);
});

it("usage refresh routes to the usage.forceRefresh action", () => {
const plan = buildCliPlan(["usage", "refresh"]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.label).toBe("usage refresh");
expect(plan.steps).toHaveLength(1);
expect(plan.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: { domain: "usage", action: "forceRefresh", args: {} },
});
// `poll` is the documented alias.
const polled = buildCliPlan(["usage", "poll"]);
expect(polled.kind).toBe("execute");
if (polled.kind !== "execute") return;
expect(polled.steps[0]?.params).toEqual(plan.steps[0]?.params);
});

it("usage budget get routes to the budget.getConfig action", () => {
const plan = buildCliPlan(["usage", "budget", "get"]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.label).toBe("usage budget get");
expect(plan.steps).toHaveLength(1);
expect(plan.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: { domain: "budget", action: "getConfig", args: {} },
});
});

it("usage budget set --from-file parses the JSON body and forwards it as args", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-usage-budget-"));
const budgetPath = path.join(root, "budget.json");
const config = { caps: [{ provider: "claude", scope: "global", limitUsd: 25 }] };
fs.writeFileSync(budgetPath, JSON.stringify(config));

const plan = buildCliPlan(["usage", "budget", "set", "--from-file", budgetPath]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.label).toBe("usage budget update");
expect(plan.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: { domain: "budget", action: "updateConfig", args: config },
});

// Empty body must surface as a CLI usage error, not silently send `{}`.
expect(() => buildCliPlan(["usage", "budget", "set", "--text", "[1,2,3]"]))
.toThrow(/must be a JSON object/i);
});

it("usage budget check defaults scope to global and forwards --provider", () => {
const plan = buildCliPlan(["usage", "budget", "check", "--provider", "claude"]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.label).toBe("usage budget check");
expect(plan.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "budget",
action: "checkBudget",
args: { scope: "global", scopeId: null, provider: "claude" },
},
});

expect(() => buildCliPlan(["usage", "budget", "bogus"]))
.toThrow(/usage budget supports get, set, check, or cumulative/);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
69 changes: 69 additions & 0 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER}
$ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work
$ ade browser open | tabs | screenshot Use ADE's built-in browser pane
$ ade memory add | search | pin Use ADE memory
$ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails
$ ade settings action <method> Call project config actions
$ ade update status | check | install | dismiss Read auto-update state and drive install
$ ade actions list | run | status Escape hatch for every ADE service action
Expand Down Expand Up @@ -1143,6 +1144,23 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade memory search -q "release process" --text
$ ade memory pin <memory-id>
$ ade memory core --arg projectSummary="Current focus"
`,
usage: `${ADE_BANNER}
Usage and provider quotas

Reads live provider quota usage (Claude five-hour + weekly, Codex five-hour +
weekly, Cursor monthly via the team Admin API), pacing, costs, and budget
guardrails. The desktop app surfaces this same data in the top-bar Usage popup.

$ ade usage snapshot --text Cached snapshot (windows, pacing, costs, errors)
$ ade usage refresh --text Force a fresh poll (invalidates cost cache)
$ ade usage budget get --text Read automation guardrail config
$ ade usage budget set --from-file budget.json Save automation guardrail config
$ ade usage budget check --provider claude --scope global
$ ade usage budget cumulative --scope global Cumulative spend for the current week

Cursor uses the Admin API (https://api.cursor.com/teams/spend) — set
CURSOR_ADMIN_API_KEY (or CURSOR_API_KEY) so the poll can authenticate.
`,
cto: `${ADE_BANNER}
CTO and Work state
Expand Down Expand Up @@ -3661,6 +3679,56 @@ function buildSettingsPlan(args: string[]): CliPlan {
return { kind: "execute", label: `settings ${sub}`, steps: [actionStep("result", "project_config", sub, collectGenericObjectArgs(args))] };
}

function buildUsagePlan(args: string[]): CliPlan {
const sub = firstPositional(args) ?? "snapshot";
if (sub === "actions") return { kind: "execute", label: "usage actions", steps: [listActionsStep("actions", "usage")] };
if (sub === "action") return { kind: "execute", label: "usage action", steps: [buildActionRunStep(["usage", ...args])] };
if (sub === "snapshot" || sub === "get" || sub === "status") {
return { kind: "execute", label: "usage snapshot", steps: [actionStep("result", "usage", "getUsageSnapshot", {})] };
}
if (sub === "refresh" || sub === "poll") {
return { kind: "execute", label: "usage refresh", steps: [actionStep("result", "usage", "forceRefresh", {})] };
}
if (sub === "budget") {
const mode = firstPositional(args) ?? "get";
if (mode === "get") {
return { kind: "execute", label: "usage budget get", steps: [actionStep("result", "budget", "getConfig", {})] };
}
if (mode === "set" || mode === "update") {
const text = readFileTextInput(args);
const hasInlineBody = text != null && text.trim().length > 0;
let parsed: unknown;
if (hasInlineBody) {
try {
parsed = JSON.parse(text);
} catch (error) {
throw new CliUsageError(`Failed to parse budget config: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
parsed = collectGenericObjectArgs(args);
}
if (!isRecord(parsed)) throw new CliUsageError("Budget config must be a JSON object.");
return { kind: "execute", label: "usage budget update", steps: [actionStep("result", "budget", "updateConfig", parsed as JsonObject)] };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (mode === "check") {
return { kind: "execute", label: "usage budget check", steps: [actionStep("result", "budget", "checkBudget", collectGenericObjectArgs(args, {
scope: readValue(args, ["--scope"]) ?? "global",
scopeId: readValue(args, ["--scope-id"]),
provider: readValue(args, ["--provider"]) ?? "any",
}))] };
}
if (mode === "cumulative" || mode === "totals") {
return { kind: "execute", label: "usage budget cumulative", steps: [actionStep("result", "budget", "getCumulativeUsage", collectGenericObjectArgs(args, {
scope: readValue(args, ["--scope"]) ?? "global",
scopeId: readValue(args, ["--scope-id"]),
provider: readValue(args, ["--provider"]),
}))] };
}
throw new CliUsageError("usage budget supports get, set, check, or cumulative.");
}
return { kind: "execute", label: `usage ${sub}`, steps: [actionStep("result", "usage", sub, collectGenericObjectArgs(args))] };
}

function buildActionsPlan(args: string[]): CliPlan {
const sub = firstPositional(args) ?? "list";
if (sub === "list" || sub === "ls") return { kind: "execute", label: "actions list", steps: [listActionsStep("result", readValue(args, ["--domain"]) ?? firstPositional(args) ?? undefined)] };
Expand Down Expand Up @@ -4272,6 +4340,7 @@ function buildCliPlan(command: string[]): CliPlan {
if (primary === "macos-vm" || primary === "macos" || primary === "mac-vm" || primary === "macvm") return buildMacosVmPlan(args);
if (primary === "browser" || primary === "ade-browser" || primary === "built-in-browser" || primary === "builtin-browser") return buildBrowserPlan(args);
if (primary === "memory") return buildMemoryPlan(args);
if (primary === "usage" || primary === "quota" || primary === "quotas") return buildUsagePlan(args);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (primary === "settings" || primary === "config" || primary === "setting") return buildSettingsPlan(args);
if (primary === "actions" || primary === "action") return buildActionsPlan(args);
if (primary === "update" || primary === "auto-update" || primary === "updates") return buildUpdatePlan(args);
Expand Down
28 changes: 18 additions & 10 deletions apps/desktop/src/main/services/ai/providerConnectionStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export async function buildProviderConnections(

const cursorCli = cliStatuses.find((entry) => entry.cli === "cursor") ?? null;
const cursorEnvAuth = Boolean(process.env.CURSOR_API_KEY?.trim());
const cursorAdminEnvAuth = Boolean(process.env.CURSOR_ADMIN_API_KEY?.trim());
let cursorStoredAuth = false;
let cursorStoreUnavailable = false;
try {
Expand All @@ -184,37 +185,44 @@ export async function buildProviderConnections(
cursorStoreUnavailable = true;
}
const cursorSdkAuth = Boolean(cursorEnvAuth || cursorStoredAuth);
let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | undefined;
const cursorUsageAuth = Boolean(cursorSdkAuth || cursorAdminEnvAuth);
let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | "cursor-admin-env" | undefined;
if (cursorEnvAuth) cursorCredsSource = "cursor-env";
else if (cursorStoredAuth) cursorCredsSource = "cursor-api-key-store";
else if (cursorAdminEnvAuth) cursorCredsSource = "cursor-admin-env";
// Runtime is bundled with the app — it always exists. Only auth-related
// fields should depend on whether a Cursor API key is present.
const cursorFlags = {
runtimeDetected: true,
cliAuthenticated: false,
cliExplicitlyUnauthenticated: false,
localCredsDetected: cursorSdkAuth,
authAvailable: cursorSdkAuth,
localCredsDetected: cursorUsageAuth,
authAvailable: cursorUsageAuth,
runtimeAvailable: cursorSdkAuth,
};

const cursorBlocker: string | null = cursorSdkAuth
? null
: cursorStoreUnavailable
? "ADE could not read the Cursor API key store yet. Retry after the key store is ready."
: "Enter a Cursor API key from https://cursor.com/dashboard/integrations.";
let cursorBlocker: string | null;
if (cursorSdkAuth) {
cursorBlocker = null;
} else if (cursorAdminEnvAuth) {
cursorBlocker = "Cursor Admin API key is configured for usage; add a Cursor agent API key for Cursor runtime access.";
} else if (cursorStoreUnavailable) {
cursorBlocker = "ADE could not read the Cursor API key store yet. Retry after the key store is ready.";
} else {
cursorBlocker = "Enter a Cursor API key from https://cursor.com/dashboard/integrations.";
}

const cursor: AiProviderConnectionStatus = {
...createUnavailableStatus("cursor", checkedAt),
authAvailable: cursorFlags.authAvailable,
runtimeDetected: cursorFlags.runtimeDetected,
runtimeAvailable: cursorFlags.runtimeAvailable,
usageAvailable: cursorFlags.runtimeAvailable,
usageAvailable: cursorUsageAuth,
path: "@cursor/sdk",
sources: [
{
kind: "local-credentials",
detected: cursorSdkAuth,
detected: cursorUsageAuth,
source: cursorCredsSource,
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/services/cto/ctoStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ function buildCtoEnvironmentKnowledge(): string {
" /graph — Workspace dependency graph visualization showing lane relationships.",
" /history — Operation history timeline showing all past actions.",
" /automations — Automation rule builder: create rules triggered by events (PR opened, test failed, etc.).",
" /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, usage budgets, and external connectors.",
" /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, and external connectors. Live provider usage and automation guardrails are now in the header usage popup.",
" When an action should be opened in ADE, return a navigation suggestion. Never silently switch tabs.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"",
...buildCtoModelSelectionKnowledge(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const {
isTokenExpiredOrExpiring,
parseClaudeWindows,
parseCodexRateLimitWindows,
parseCursorSpendUsage,
calculatePacingByProvider,
pollCodexViaCliRpc,
resolveTokenPrice,
} = _testing;
Expand Down Expand Up @@ -456,6 +458,70 @@ describe("parseCodexRateLimitWindows", () => {
});
});

describe("parseCursorSpendUsage", () => {
it("normalizes Cursor team spend into a monthly used-percent window", () => {
const cycleStart = Date.UTC(2026, 4, 1);
const result = parseCursorSpendUsage({
subscriptionCycleStart: cycleStart,
teamMemberSpend: [
{ spendCents: 2500, hardLimitOverrideDollars: 100, fastPremiumRequests: 50 },
{ spendCents: 500, hardLimitOverrideDollars: 50, fastPremiumRequests: 20 },
],
});

expect(result.windows).toHaveLength(1);
expect(result.windows[0]?.provider).toBe("cursor");
expect(result.windows[0]?.windowType).toBe("monthly");
expect(result.windows[0]?.percentUsed).toBe(20);
expect(result.windows[0]?.windowDurationMs).toBeGreaterThan(0);
expect(result.extraUsage?.usedCreditsUsd).toBe(30);
expect(result.extraUsage?.monthlyLimitUsd).toBe(150);
expect(result.extraUsage?.utilization).toBe(20);
});

it("keeps Cursor spend as extra usage when no monthly limit is configured", () => {
const result = parseCursorSpendUsage({
teamMemberSpend: [
{ spendCents: 1250, hardLimitOverrideDollars: 0, fastPremiumRequests: 10 },
],
});

expect(result.windows).toEqual([]);
expect(result.extraUsage?.provider).toBe("cursor");
expect(result.extraUsage?.usedCreditsUsd).toBe(12.5);
expect(result.extraUsage?.monthlyLimitUsd).toBe(0);
expect(result.extraUsage?.utilization).toBeNull();
});

it("prefers overallSpendCents over on-demand spendCents when both are present", () => {
const cycleStart = Date.UTC(2026, 4, 1);
const result = parseCursorSpendUsage({
subscriptionCycleStart: cycleStart,
teamMemberSpend: [
{ spendCents: 1000, overallSpendCents: 5000, hardLimitOverrideDollars: 100 },
],
});

expect(result.extraUsage?.usedCreditsUsd).toBe(50);
expect(result.windows[0]?.percentUsed).toBe(50);
});

it("falls back to monthlyLimitDollars when no hard-limit override is set", () => {
const cycleStart = Date.UTC(2026, 4, 1);
const result = parseCursorSpendUsage({
subscriptionCycleStart: cycleStart,
teamMemberSpend: [
{ overallSpendCents: 2500, monthlyLimitDollars: 100 },
{ overallSpendCents: 0, monthlyLimitDollars: 100 },
],
});

expect(result.extraUsage?.monthlyLimitUsd).toBe(200);
expect(result.extraUsage?.usedCreditsUsd).toBe(25);
expect(result.windows[0]?.percentUsed).toBe(12.5);
});
});

describe("pollCodexViaCliRpc", () => {
const originalPlatform = process.platform;
const originalComSpec = process.env.ComSpec;
Expand Down Expand Up @@ -598,6 +664,7 @@ describe("createUsageTrackingService", () => {
const createFastDependencies = () => ({
pollClaudeUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })),
pollCodexUsage: vi.fn(async () => ({ windows: [] as never[], errors: [] as never[] })),
pollCursorUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })),
scanClaudeLogs: vi.fn(async () => [] as never[]),
scanCodexLogs: vi.fn(async () => [] as never[]),
});
Expand Down Expand Up @@ -652,6 +719,25 @@ describe("createUsageTrackingService", () => {
service.dispose();
});

it("calculates pacing separately for Claude, Codex, and Cursor windows", async () => {
const now = Date.now();
const weeklyResetMs = 3.5 * 24 * 60 * 60 * 1000;
const monthlyResetMs = 24 * 24 * 60 * 60 * 1000;
const weeklyReset = new Date(now + weeklyResetMs).toISOString();
const monthlyReset = new Date(now + monthlyResetMs).toISOString();
const windows = [
{ provider: "claude" as const, windowType: "weekly" as const, percentUsed: 40, resetsAt: weeklyReset, resetsInMs: weeklyResetMs },
{ provider: "codex" as const, windowType: "weekly" as const, percentUsed: 65, resetsAt: weeklyReset, resetsInMs: weeklyResetMs },
{ provider: "cursor" as const, windowType: "monthly" as const, percentUsed: 15, resetsAt: monthlyReset, resetsInMs: monthlyResetMs, windowDurationMs: 30 * 24 * 60 * 60 * 1000 },
];

const pacing = calculatePacingByProvider(windows);

expect(pacing?.claude?.status).toBe("behind");
expect(pacing?.codex?.status).toBe("far-ahead");
expect(pacing?.cursor?.status).toBe("slightly-behind");
});

it("forceRefresh invalidates cost cache and re-polls", async () => {
const logger = createLogger();
const dependencies = createFastDependencies();
Expand Down
Loading
Loading