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
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
130 changes: 130 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,134 @@ 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);
expect(() => buildCliPlan(["usage", "budget", "set", "--text", " \n "]))
.toThrow(/non-empty JSON object/i);
expect(() => buildCliPlan(["usage", "budget", "set"]))
.toThrow(/at least one field/i);
expect(() => buildCliPlan(["usage", "budget", "set", "--text", "{}"]))
.toThrow(/at least one field/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.
});

it("usage budget cumulative routes with scope parameters", () => {
const plan = buildCliPlan(["usage", "budget", "cumulative", "--scope", "global"]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.label).toBe("usage budget cumulative");
expect(plan.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "budget",
action: "getCumulativeUsage",
args: { scope: "global", scopeId: null, provider: null },
},
});

const aliased = buildCliPlan(["quota", "budget", "totals", "--provider", "cursor"]);
expect(aliased.kind).toBe("execute");
if (aliased.kind !== "execute") return;
expect(aliased.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "budget",
action: "getCumulativeUsage",
args: { scope: "global", scopeId: null, provider: "cursor" },
},
});
});

it("usage command aliases resolve to usage help", () => {
const direct = buildCliPlan(["usage", "--help"]);
const quota = buildCliPlan(["quota", "--help"]);
const helpQuota = buildCliPlan(["help", "quota"]);
expect(direct.kind).toBe("help");
expect(quota.kind).toBe("help");
expect(helpQuota.kind).toBe("help");
if (direct.kind !== "help" || quota.kind !== "help" || helpQuota.kind !== "help") return;
expect(quota.text).toBe(direct.text);
expect(helpQuota.text).toBe(direct.text);
});
});
75 changes: 75 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,60 @@ 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);
let parsed: unknown;
if (text != null) {
const trimmed = text.trim();
if (!trimmed.length) {
throw new CliUsageError("Budget config must be a non-empty JSON object.");
}
try {
parsed = JSON.parse(trimmed);
} 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.");
if (Object.keys(parsed).length === 0) throw new CliUsageError("Budget config must contain at least one field.");
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 @@ -4183,6 +4255,8 @@ function buildCliPlan(command: string[]): CliPlan {
automation: "automations",
"auto-update": "update",
updates: "update",
quota: "usage",
quotas: "usage",
};
const primaryHelpKey = aliases[primary] ?? primary;
if (hasHelpFlag(args)) {
Expand Down Expand Up @@ -4272,6 +4346,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
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,9 @@ describe("buildProviderConnections", () => {

it("marks Cursor runtime available through the SDK when an env API key is set", async () => {
const prevKey = process.env.CURSOR_API_KEY;
const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY;
process.env.CURSOR_API_KEY = "test-key";
delete process.env.CURSOR_ADMIN_API_KEY;
try {
const result = await buildProviderConnections(
mergeCliStatuses([
Expand All @@ -247,17 +249,40 @@ describe("buildProviderConnections", () => {
expect(result.cursor.authAvailable).toBe(true);
expect(result.cursor.runtimeDetected).toBe(true);
expect(result.cursor.runtimeAvailable).toBe(true);
expect(result.cursor.usageAvailable).toBe(false);
expect(result.cursor.path).toBe("@cursor/sdk");
expect(result.cursor.blocker).toBeNull();
} finally {
if (prevKey === undefined) delete process.env.CURSOR_API_KEY;
else process.env.CURSOR_API_KEY = prevKey;
if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY;
else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey;
}
});

it("marks Cursor usage available only for Admin API-shaped keys", async () => {
const prevKey = process.env.CURSOR_API_KEY;
const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY;
process.env.CURSOR_API_KEY = "key_cursor_admin_test";
delete process.env.CURSOR_ADMIN_API_KEY;
try {
const result = await buildProviderConnections(mergeCliStatuses([]));
expect(result.cursor.authAvailable).toBe(true);
expect(result.cursor.runtimeAvailable).toBe(true);
expect(result.cursor.usageAvailable).toBe(true);
} finally {
if (prevKey === undefined) delete process.env.CURSOR_API_KEY;
else process.env.CURSOR_API_KEY = prevKey;
if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY;
else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey;
}
});

it("downgrades Cursor runtime availability when SDK model access rejects the key", async () => {
const prevKey = process.env.CURSOR_API_KEY;
const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY;
process.env.CURSOR_API_KEY = "test-key";
delete process.env.CURSOR_ADMIN_API_KEY;
mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => {
if (provider === "cursor") {
return {
Expand All @@ -278,6 +303,38 @@ describe("buildProviderConnections", () => {
} finally {
if (prevKey === undefined) delete process.env.CURSOR_API_KEY;
else process.env.CURSOR_API_KEY = prevKey;
if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY;
else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey;
}
});

it("preserves Cursor usage availability when SDK auth fails but an admin key is configured", async () => {
const prevKey = process.env.CURSOR_API_KEY;
const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY;
process.env.CURSOR_API_KEY = "test-key";
process.env.CURSOR_ADMIN_API_KEY = "key_cursor_admin_test";
mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => {
if (provider === "cursor") {
return {
provider: "cursor",
state: "auth-failed",
message: "Cursor rejected the configured API key for agent/model access.",
checkedAt: "2026-05-01T12:00:00.000Z",
};
}
return null;
});
try {
const result = await buildProviderConnections(mergeCliStatuses([]));
expect(result.cursor.authAvailable).toBe(true);
expect(result.cursor.runtimeAvailable).toBe(false);
expect(result.cursor.usageAvailable).toBe(true);
expect(result.cursor.blocker).toBe("Cursor rejected the configured API key for agent/model access.");
} finally {
if (prevKey === undefined) delete process.env.CURSOR_API_KEY;
else process.env.CURSOR_API_KEY = prevKey;
if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY;
else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey;
}
});
});
Loading
Loading