Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
126 changes: 126 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,130 @@ 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);
});

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);
});
});
74 changes: 74 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,59 @@ 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.");
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 +4254,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 +4345,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,8 @@ 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;
}
});
});
Loading
Loading