feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
This commit is contained in:
@@ -4,7 +4,11 @@
|
||||
* reset since Codex last wrote reading as empty, and the Codex login's
|
||||
* account + expiry read from `auth.json`; and a saved Claude profile asked
|
||||
* with its own token only while it is valid, never refreshed, and never shown
|
||||
* with the usage of a token that belongs to a different account.
|
||||
* with the usage of a token that belongs to a different account; each
|
||||
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
|
||||
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
|
||||
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
|
||||
* while valid, never refreshed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +16,19 @@ const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { codexWindow, windowLabel } = require("../lib/quota");
|
||||
|
||||
// No test may read (or send) the machine's real Cursor login.
|
||||
process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-xdg-")
|
||||
);
|
||||
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
// Nor its real Grok, Kimi or OpenCode logins.
|
||||
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
|
||||
process.env[k] = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
|
||||
);
|
||||
}
|
||||
|
||||
test("windowLabel names the plan windows", () => {
|
||||
assert.strictEqual(windowLabel(300), "5h");
|
||||
assert.strictEqual(windowLabel(10080), "wk");
|
||||
@@ -47,11 +64,18 @@ test("codexAccount reads the email and access-token expiry, never a token", () =
|
||||
fs.writeFileSync(
|
||||
path.join(home, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: { id_token: jwt({ email: "a@b.c" }), access_token: jwt({ exp: 1_800_000_000 }) },
|
||||
tokens: {
|
||||
id_token: jwt({
|
||||
email: "a@b.c",
|
||||
"https://api.openai.com/auth": { chatgpt_plan_type: "plus" },
|
||||
}),
|
||||
access_token: jwt({ exp: 1_800_000_000 }),
|
||||
},
|
||||
})
|
||||
);
|
||||
assert.deepStrictEqual(codexAccount(), {
|
||||
account: "a@b.c",
|
||||
plan: "plus",
|
||||
expiresAt: new Date(1_800_000_000_000).toISOString(),
|
||||
});
|
||||
} finally {
|
||||
@@ -177,7 +201,13 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
// Thắng's identity, Victor's token.
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "live-v",
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
subscriptionType: "max",
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
`${home}.json`,
|
||||
@@ -200,6 +230,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
assert.strictEqual(live.label, "Victor");
|
||||
assert.strictEqual(live.account, "victor@x.y");
|
||||
assert.strictEqual(live.ownerUuid, undefined);
|
||||
assert.strictEqual(live.plan, "max");
|
||||
assert.deepStrictEqual(
|
||||
claude.filter((t) => !t.active).map((t) => t.label),
|
||||
["Thắng"]
|
||||
@@ -212,3 +243,344 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Cursor: the CLI's own login asks cursor.com for its plan + cycle usage, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-cursor-"));
|
||||
const auth = path.join(root, "config", "cursor", "auth.json");
|
||||
fs.mkdirSync(path.dirname(auth), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "cursor"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "cursor", "cli-config.json"),
|
||||
JSON.stringify({ authInfo: { email: "c@d.e", authId: "auth0|user_1" } })
|
||||
);
|
||||
const prev = {
|
||||
xdg: process.env.XDG_CONFIG_HOME,
|
||||
dir: process.env.AGENTHUB_CURSOR_DIR,
|
||||
fetch: global.fetch,
|
||||
};
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(root, "cursor");
|
||||
const asked = [];
|
||||
global.fetch = async (url, opts) => {
|
||||
if (!url.includes("cursor.com")) return { ok: false, status: 401, json: async () => ({}) };
|
||||
asked.push(`${url} ${opts.headers.Cookie}`);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
billingCycleEnd: "2099-01-01T00:00:00.000Z",
|
||||
membershipType: "pro",
|
||||
individualUsage: { plan: { totalPercentUsed: 9.5 } },
|
||||
}),
|
||||
};
|
||||
};
|
||||
const cursor = async () => (await quotas()).tools.find((t) => t.id === "cursor");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await cursor(), undefined); // not logged in: no row
|
||||
const token = jwt({ sub: "auth0|user_1", exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
fs.writeFileSync(auth, JSON.stringify({ accessToken: token, refreshToken: "r" }));
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await cursor(), {
|
||||
id: "cursor",
|
||||
active: true,
|
||||
account: "c@d.e",
|
||||
plan: "pro",
|
||||
expiresAt: new Date(
|
||||
JSON.parse(Buffer.from(token.split(".")[1], "base64url")).exp * 1000
|
||||
).toISOString(),
|
||||
windows: [{ label: "mo", usedPercent: 9.5, resetsAt: "2099-01-01T00:00:00.000Z" }],
|
||||
});
|
||||
assert.deepStrictEqual(asked, [
|
||||
`https://cursor.com/api/usage-summary WorkosCursorSessionToken=user_1::${token}`,
|
||||
]);
|
||||
fs.writeFileSync(auth, JSON.stringify({ accessToken: jwt({ sub: "auth0|user_1", exp: 1 }) }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await cursor()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(asked.length, 1);
|
||||
} finally {
|
||||
for (const [k, v] of [
|
||||
["XDG_CONFIG_HOME", prev.xdg],
|
||||
["AGENTHUB_CURSOR_DIR", prev.dir],
|
||||
]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Antigravity: `agy --print /usage` in an empty dir, one row per pool window, never on an old agy", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "quota-agy-"));
|
||||
const log = path.join(bin, "calls");
|
||||
const version = path.join(bin, "version");
|
||||
fs.writeFileSync(version, "1.2.17");
|
||||
const bucket = (id, window, rf) => ({
|
||||
id,
|
||||
window,
|
||||
remaining_fraction: rf,
|
||||
reset_time: "2099-01-01T00:00:00Z",
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "out.json"),
|
||||
JSON.stringify({
|
||||
status: "SUCCESS",
|
||||
num_turns: 0,
|
||||
command: {
|
||||
name: "usage",
|
||||
data: {
|
||||
groups: [
|
||||
{
|
||||
name: "Gemini Models",
|
||||
buckets: [bucket("g-w", "weekly", 0.75), bucket("g-5", "5h", 1)],
|
||||
},
|
||||
{ name: "Claude and GPT models", buckets: [bucket("c-5", "5h", 0.5)] },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "agy"),
|
||||
`#!/bin/sh\necho "$PWD $*" >> '${log}'\n[ "$1" = --version ] && { cat '${version}'; exit 0; }\ncat '${path.join(bin, "out.json")}'\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
const prevPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}:${prevPath}`;
|
||||
delete process.env.AGENTHUB_AGY_USAGE;
|
||||
const agy = async () => (await quotas()).tools.find((t) => t.id === "antigravity");
|
||||
try {
|
||||
aiTools.refresh();
|
||||
__resetCache();
|
||||
const row = await agy();
|
||||
assert.strictEqual(row.active, true);
|
||||
assert.deepStrictEqual(row.windows, [
|
||||
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: "2099-01-01T00:00:00.000Z" },
|
||||
{
|
||||
group: "Gemini Models",
|
||||
label: "wk",
|
||||
usedPercent: 25,
|
||||
resetsAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
group: "Claude and GPT models",
|
||||
label: "5h",
|
||||
usedPercent: 50,
|
||||
resetsAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const calls = fs.readFileSync(log, "utf8").trim().split("\n");
|
||||
assert.strictEqual(calls[0].split(" ").slice(1).join(" "), "--version");
|
||||
const [cwd, ...args] = calls[1].split(" ");
|
||||
assert.deepStrictEqual(args, ["--print", "/usage", "--output-format", "json"]);
|
||||
assert.notStrictEqual(cwd, process.cwd());
|
||||
assert.strictEqual(fs.existsSync(cwd), false); // the private dir is gone again
|
||||
// An agy older than 1.1.11 may bill /usage as a model request: never asked.
|
||||
fs.writeFileSync(version, "1.1.10");
|
||||
fs.writeFileSync(log, "");
|
||||
__resetCache();
|
||||
assert.strictEqual(await agy(), undefined);
|
||||
assert.match(fs.readFileSync(log, "utf8"), /^\S+ --version\n$/);
|
||||
} finally {
|
||||
process.env.PATH = prevPath;
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
aiTools.refresh();
|
||||
__resetCache();
|
||||
fs.rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/** A fetch stub answering per host, recording every `url auth` it was asked. */
|
||||
function stubFetch(answers) {
|
||||
const asked = [];
|
||||
const prev = global.fetch;
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
asked.push(`${url} ${opts.headers?.Authorization || ""}`.trim());
|
||||
const hit = Object.entries(answers).find(([k]) => url.startsWith(k));
|
||||
if (!hit) return { ok: false, status: 404, json: async () => ({}) };
|
||||
const [status, body] = hit[1];
|
||||
return { ok: status < 300, status, json: async () => body };
|
||||
};
|
||||
return { asked, restore: () => (global.fetch = prev) };
|
||||
}
|
||||
|
||||
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const auth = path.join(process.env.GROK_HOME, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
|
||||
200,
|
||||
{
|
||||
config: {
|
||||
creditUsagePercent: 12,
|
||||
currentPeriod: { end: "2099-01-08T00:00:00Z" },
|
||||
subscriptionTier: "SuperGrok",
|
||||
monthlyLimit: { val: "200" },
|
||||
used: { val: "50" },
|
||||
billingPeriodEnd: "2099-02-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await grok(), undefined);
|
||||
const later = new Date(Date.now() + 3600_000).toISOString();
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({
|
||||
"https://other.example": { key: "tok-other", expires_at: later },
|
||||
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await grok(), {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: "g@x.ai",
|
||||
plan: "SuperGrok",
|
||||
expiresAt: later,
|
||||
windows: [
|
||||
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
|
||||
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("grok.com")),
|
||||
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
|
||||
);
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
|
||||
);
|
||||
__resetCache();
|
||||
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(auth, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "kimi-code.json");
|
||||
const f = stubFetch({
|
||||
"https://api.kimi.com/coding/v1/usages": [
|
||||
200,
|
||||
{
|
||||
usages: {
|
||||
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
|
||||
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
|
||||
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
|
||||
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await kimi(), undefined);
|
||||
const exp = Math.floor(Date.now() / 1000) + 600;
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
|
||||
);
|
||||
__resetCache();
|
||||
const row = await kimi();
|
||||
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
|
||||
assert.deepStrictEqual(
|
||||
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
|
||||
[
|
||||
["5h", 30],
|
||||
["wk", 20],
|
||||
["mo", 40],
|
||||
]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("kimi")),
|
||||
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
|
||||
);
|
||||
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://opencode.ai/zen/go/v1/usage": [
|
||||
200,
|
||||
{
|
||||
usage: {
|
||||
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
|
||||
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
|
||||
try {
|
||||
__resetCache();
|
||||
// A login with no Go key (Copilot through opencode, say) is no row.
|
||||
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
|
||||
assert.strictEqual(await go(), undefined);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
"opencode-go": { type: "api", key: "sk-go" },
|
||||
other: { type: "api", key: "x" },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await go(), {
|
||||
id: "opencode",
|
||||
active: true,
|
||||
plan: "Go",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
|
||||
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("opencode")),
|
||||
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
|
||||
);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user