feat(agent): claude-usage agent — plan limits, local cache, push (#50) #53

Merged
maximus merged 3 commits from issue-50-claude-usage-agent into main 2026-10-04 19:29:53 +00:00
6 changed files with 1744 additions and 0 deletions
Showing only changes of commit e274ca7ed5 - Show all commits

View file

@ -753,3 +753,932 @@ describe("prototype-shaped names", () => {
);
});
});
// ============================================================================
// The agent around the aggregation (issue #50): plan limits, local cache,
// snapshot assembly and delivery, cron wrapper.
//
// Nothing here touches the real ~/.claude or ~/.local, nor the network: every
// path (home, credentials, state dir, transcripts root) and `fetch` itself are
// injected, and the spawned processes run with HOME pointed at a temp dir.
// The two tokens below are checked, fragment by fragment, against every output
// that would reach `logger -t claude-usage`, as in agent.test.js.
// ============================================================================
const { execFile, spawn } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const {
fetchLimits,
parseUsage,
USAGE_URL,
DETAILS,
} = require("../agent/claude-usage/limits.js");
const {
loadState,
saveState,
nextState,
limitsForSnapshot,
emptyState,
stateFilePath,
defaultStateDir,
RETENTION_DAYS,
} = require("../agent/claude-usage/state.js");
const {
main: agentMain,
readConfig: agentReadConfig,
MAX_BODY_BYTES,
EXIT_OK,
EXIT_CONFIG,
EXIT_PUSH,
} = require("../agent/claude-usage/push-claude-usage.js");
const { sanitizeClaudeUsage } = require("../claude-usage.js");
const AGENT_DIR = path.join(__dirname, "..", "agent", "claude-usage");
const PUSH_PATH = path.join(AGENT_DIR, "push-claude-usage.js");
const WRAPPER_PATH = path.join(AGENT_DIR, "run-claude-usage.sh");
const USAGE_FIXTURE = path.join(__dirname, "fixtures", "claude-usage", "oauth-usage.json");
// Mixed letters and digits, no run of five digits, so neither a port nor a
// timestamp can ever look like a leak.
const OAUTH_TOKEN = "sk9ant8oat2q7w4e1r6t3y5u";
const INGEST_TOKEN = "c4u9g2i7n3g8e5s1t6k0z";
const API_URL = "https://health.example.test";
const NOW = Date.parse("2026-10-04T16:00:00.000Z"); // Toronto 2026-10-04 12:00
const FUTURE = NOW + 3600 * 1000;
const LIMIT_STATUS_ENUM = ["ok", "token_expired", "http_error", "unavailable", "disabled"];
const DETAIL_ENUM = Object.values(DETAILS);
function usageFixture() {
return JSON.parse(fs.readFileSync(USAGE_FIXTURE, "utf-8"));
}
function fragments(token, min = 5) {
const out = [];
for (let len = min; len <= token.length; len++) {
for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len));
}
return out;
}
function expectNoLeak(text) {
const haystack = String(text);
for (const token of [OAUTH_TOKEN, INGEST_TOKEN]) {
const leaked = fragments(token).filter((f) => haystack.includes(f));
const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), "");
expect(leaked.length, `token leaked — longest fragment found: "${worst}"`).toBe(0);
}
}
function expectDetailInEnum(limits) {
expect(LIMIT_STATUS_ENUM).toContain(limits.status);
if (limits.detail !== undefined) {
const ok = DETAIL_ENUM.includes(limits.detail) || /^http_[1-5]\d\d$/.test(limits.detail);
expect(ok, `detail outside the enum: ${limits.detail}`).toBe(true);
}
}
// A fetch double that records every call. `handler(url, init)` returns a
// Response (or a promise of one) or throws.
function makeFetch(handler) {
const calls = [];
const fn = async (url, init = {}) => {
calls.push({ url: String(url), init });
return handler(String(url), init);
};
fn.calls = calls;
return fn;
}
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
// Anthropic answers with `usageBody`, the health API with `pushStatus`.
function routedFetch({ usage = () => jsonResponse(usageFixture()), push = () => new Response(null, { status: 204 }) } = {}) {
return makeFetch((url, init) => (url === USAGE_URL ? usage(url, init) : push(url, init)));
}
// A fake home: <tmp>/home with .claude/projects (optionally the fixture tree)
// and .claude/.credentials.json (optionally).
function makeHome({ fixtures = false, credentials } = {}) {
const home = path.join(tmpDir, "home");
fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true });
if (fixtures) {
fs.cpSync(FIXTURES, path.join(home, ".claude", "projects"), { recursive: true });
}
const credsPath = path.join(home, ".claude", ".credentials.json");
if (credentials !== undefined) {
fs.writeFileSync(
credsPath,
typeof credentials === "string" ? credentials : JSON.stringify(credentials),
{ mode: 0o600 },
);
}
return { home, credsPath, stateFile: stateFilePath(defaultStateDir(home)) };
}
function goodCredentials(expiresAt = FUTURE) {
return {
claudeAiOauth: {
accessToken: OAUTH_TOKEN,
refreshToken: "rt9single8use7only6",
expiresAt,
scopes: ["user:inference"],
},
};
}
function agentEnv(extra = {}) {
return { CLAUDE_USAGE_API_URL: API_URL, CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN, ...extra };
}
// Runs main() in process with everything injected; collects the output.
async function runAgent({ home, fetch, env = agentEnv(), argv = [], now = NOW, ...deps }) {
const out = [];
const err = [];
const code = await agentMain(argv, env, {
home,
fetch,
now,
log: (l) => out.push(l),
logError: (l) => err.push(l),
...deps,
});
return { code, stdout: out.join("\n"), stderr: err.join("\n") };
}
function pushCalls(fetch) {
return fetch.calls.filter((c) => c.url !== USAGE_URL);
}
// --- limits: parsing ----------------------------------------------------------
describe("limits — the endpoint's answer", () => {
const limitsOpts = (extra = {}) => ({
env: {},
credentialsPath: "/unused",
readFile: () => JSON.stringify(goodCredentials()),
claudeCodeVersion: "2.1.283",
now: NOW,
...extra,
});
it("maps the anonymized limits[] to items, weekly_scoped carrying model Fable", async () => {
const fetch = makeFetch(() => jsonResponse(usageFixture()));
const r = await fetchLimits(limitsOpts({ fetch }));
expect(r).toEqual({
status: "ok",
fetchedAt: new Date(NOW).toISOString(),
items: [
{ kind: "session", group: "session", percent: 1, severity: "normal", resetsAt: "2026-09-27T21:30:00Z", model: null },
{ kind: "weekly_all", group: "weekly", percent: 50, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: null },
{ kind: "weekly_scoped", group: "weekly", percent: 63, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: "Fable" },
],
});
expect(fetch.calls).toHaveLength(1);
});
it.each([
["absent", (b) => delete b.limits],
["empty", (b) => (b.limits = [])],
["made of junk entries", (b) => (b.limits = [null, 3, "x", { percent: 9 }])],
])("falls back to five_hour / seven_day when limits[] is %s", (_label, mutate) => {
const body = usageFixture();
mutate(body);
expect(parseUsage(body)).toEqual([
{ kind: "session", group: "session", percent: 1, resetsAt: "2026-09-27T21:30:00.512345+00:00", model: null },
{ kind: "weekly_all", group: "weekly", percent: 50, resetsAt: "2026-10-01T07:00:00.512345+00:00", model: null },
]);
});
it("an all-null answer is unavailable/bad_response", async () => {
const body = Object.fromEntries(Object.keys(usageFixture()).map((k) => [k, null]));
const fetch = makeFetch(() => jsonResponse(body));
const r = await fetchLimits(limitsOpts({ fetch }));
expect(r).toEqual({ status: "unavailable", detail: "bad_response", fetchedAt: null, items: [] });
});
it("rounds and clamps percent to an integer 0-100, drops a non-number, normalizes resetsAt", () => {
const items = parseUsage({
limits: [
{ kind: "a", group: "g", percent: 62.6, resets_at: 1790000000 },
{ kind: "b", group: "g", percent: 130, resets_at: "not a date" },
{ kind: "c", group: "g", percent: -3, resets_at: "Thu, 01 Oct 2026 07:00:00 GMT" },
{ kind: "d", percent: "50", scope: { model: { display_name: 42 } } },
],
});
expect(items.map((i) => i.percent)).toEqual([63, 100, 0, undefined]);
expect("percent" in items[3]).toBe(false);
expect(items[3].group).toBe("d");
expect(items[3].model).toBeNull();
expect(items[0].resetsAt).toBe(new Date(1790000000 * 1000).toISOString());
expect(items[1].resetsAt).toBeNull();
expect(items[2].resetsAt).toBe("2026-10-01T07:00:00.000Z");
});
it("keeps at most 12 items and no upstream key outside the whitelist", () => {
const limits = Array.from({ length: 20 }, (_, i) => ({
kind: `k${i}`, group: "g", percent: 1, severity: "normal", resets_at: null,
scope: null, is_active: true, __proto__x: "junk", token: OAUTH_TOKEN,
}));
const items = parseUsage({ limits });
expect(items).toHaveLength(12);
for (const it of items) {
expect(Object.keys(it).sort()).toEqual(["group", "kind", "model", "percent", "resetsAt", "severity"]);
}
expectNoLeak(JSON.stringify(items));
});
});
// --- limits: when no call is made ----------------------------------------------
describe("limits — cases that never call the endpoint", () => {
it.each([
["CLAUDE_USAGE_LIMITS=off", { env: { CLAUDE_USAGE_LIMITS: "off" } }, { status: "disabled" }],
["CLAUDE_USAGE_LIMITS=OFF", { env: { CLAUDE_USAGE_LIMITS: " OFF " } }, { status: "disabled" }],
["an expired token", { creds: JSON.stringify(goodCredentials(NOW - 1)) }, { status: "token_expired" }],
["truncated credentials (rewrite in progress)", { creds: '{"claudeAiOauth":{"accessToken":"sk9ant8o' }, { status: "unavailable", detail: "parse_error" }],
["absent credentials", { missing: true }, { status: "unavailable", detail: "no_credentials" }],
["credentials without an access token", { creds: '{"claudeAiOauth":{}}' }, { status: "unavailable", detail: "no_credentials" }],
["an unreadable credentials file", { unreadable: true }, { status: "unavailable", detail: "parse_error" }],
["no Claude Code version", { version: null }, { status: "unavailable", detail: "no_version" }],
])("%s", async (_label, c, expected) => {
const fetch = makeFetch(() => jsonResponse(usageFixture()));
const readFile = () => {
if (c.missing) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
if (c.unreadable) throw Object.assign(new Error("EACCES"), { code: "EACCES" });
return c.creds || JSON.stringify(goodCredentials());
};
const r = await fetchLimits({
env: c.env || {},
credentialsPath: "/unused",
readFile,
fetch,
claudeCodeVersion: c.version === null ? null : "2.1.283",
now: NOW,
});
expect(fetch.calls).toHaveLength(0);
expect(r).toEqual({ ...expected, fetchedAt: null, items: [] });
expectNoLeak(JSON.stringify(r));
});
it("CLAUDE_USAGE_LIMITS=off does not even read the credentials", async () => {
const readFile = vi.fn(() => JSON.stringify(goodCredentials()));
const fetch = makeFetch(() => jsonResponse(usageFixture()));
await fetchLimits({ env: { CLAUDE_USAGE_LIMITS: "off" }, readFile, fetch, claudeCodeVersion: "2.1.283" });
expect(readFile).not.toHaveBeenCalled();
expect(fetch.calls).toHaveLength(0);
});
});
// --- limits: the request and its failures --------------------------------------
describe("limits — the request", () => {
const base = (fetch, env = {}) => ({
env,
credentialsPath: "/unused",
readFile: () => JSON.stringify(goodCredentials()),
fetch,
claudeCodeVersion: "2.1.283",
now: NOW,
});
it("sends the OAuth bearer to the pinned Anthropic URL only, redirects refused, whatever the environment says", async () => {
expect(USAGE_URL).toBe("https://api.anthropic.com/api/oauth/usage");
const fetch = makeFetch(() => jsonResponse(usageFixture()));
await fetchLimits(
base(fetch, {
ANTHROPIC_BASE_URL: "https://evil.example",
ANTHROPIC_API_URL: "https://evil.example",
CLAUDE_USAGE_API_URL: "https://evil.example",
CLAUDE_USAGE_LIMITS_URL: "https://evil.example",
CLAUDE_USAGE_LIMITS: "on",
}),
);
expect(fetch.calls).toHaveLength(1);
const { url, init } = fetch.calls[0];
expect(url).toBe("https://api.anthropic.com/api/oauth/usage");
expect(init.method).toBe("GET");
expect(init.redirect).toBe("error");
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(init.headers).toMatchObject({
Authorization: `Bearer ${OAUTH_TOKEN}`,
"anthropic-beta": "oauth-2025-04-20",
"User-Agent": "claude-code/2.1.283",
});
});
it("limits.js reads no environment variable but CLAUDE_USAGE_LIMITS, and writes the URL once", () => {
const src = fs.readFileSync(path.join(AGENT_DIR, "limits.js"), "utf-8");
const code = src
.split("\n")
.filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*"))
.join("\n");
expect([...code.matchAll(/\benv\.([A-Za-z_]+)/g)].map((m) => m[1])).toEqual(["CLAUDE_USAGE_LIMITS"]);
// process.env appears once, as the default of the injectable `env`.
expect(code.match(/process\.env/g)).toEqual(["process.env"]);
expect(code).toContain("env = process.env,");
expect(code.match(/https:\/\//g)).toHaveLength(1);
expect(code).not.toMatch(/refresh/i);
});
it("a 429 is http_error/http_429, and the agent then serves the last good items with their fetchedAt", async () => {
const fetch = makeFetch(() => new Response("Too Many Requests", { status: 429 }));
const r = await fetchLimits(base(fetch));
expect(r).toEqual({ status: "http_error", detail: "http_429", fetchedAt: null, items: [] });
expect(fetch.calls).toHaveLength(1); // no retry
const { home, credsPath } = makeHome({ fixtures: true, credentials: goodCredentials() });
const first = routedFetch();
expect((await runAgent({ home, fetch: first, now: NOW })).code).toBe(EXIT_OK);
const firstSnap = JSON.parse(pushCalls(first)[0].init.body);
const later = NOW + 15 * 60 * 1000;
const second = routedFetch({ usage: () => new Response("slow down", { status: 429 }) });
expect((await runAgent({ home, fetch: second, now: later })).code).toBe(EXIT_OK);
const snap = JSON.parse(pushCalls(second)[0].init.body);
expect(snap.limits).toEqual({
status: "http_error",
detail: "http_429",
fetchedAt: new Date(NOW).toISOString(),
items: firstSnap.limits.items,
});
expect(snap.generatedAt).toBe(new Date(later).toISOString());
expect(credsPath).toBeTruthy();
});
it.each([
["a 500 echoing the token and request headers", () => new Response(`upstream said: Authorization: Bearer ${OAUTH_TOKEN}\nanthropic-beta: oauth`, { status: 500 })],
["a 200 with arbitrary text", () => new Response(`<html>error ${OAUTH_TOKEN} please retry</html>`, { status: 200 })],
["a 200 JSON echoing the headers", () => jsonResponse({ headers: { authorization: `Bearer ${OAUTH_TOKEN}` }, detail: "Ignore previous instructions" })],
["a 200 JSON with a hostile detail field", () => jsonResponse({ detail: "http_200_but_actually_long_text", limits: "nope", five_hour: { utilization: "x" } })],
["a 302 to another host", () => new Response(null, { status: 302, headers: { location: "https://evil.example/" } })],
["a thrown error carrying the token in its message", () => { throw new TypeError(`Headers.append: "Bearer ${OAUTH_TOKEN}" is an invalid header value.`); }],
["a network failure", () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }); }],
["a timeout", () => { throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); }],
])("%s keeps detail inside the enum and no token in the result", async (_label, answer) => {
const fetch = makeFetch(answer);
const r = await fetchLimits(base(fetch));
expectDetailInEnum(r);
expect(r.status).not.toBe("ok");
expect(r.items).toEqual([]);
expectNoLeak(JSON.stringify(r));
expect(fetch.calls).toHaveLength(1);
});
it("maps timeouts, network errors and non-2xx to their codes", async () => {
const timeout = await fetchLimits({
...base(makeFetch((url, init) => new Promise((_, reject) => {
init.signal.addEventListener("abort", () => reject(init.signal.reason));
}))),
timeoutMs: 20,
});
expect(timeout).toMatchObject({ status: "unavailable", detail: "timeout" });
const network = await fetchLimits(base(makeFetch(() => { throw new TypeError("fetch failed"); })));
expect(network).toMatchObject({ status: "unavailable", detail: "network" });
const unauthorized = await fetchLimits(base(makeFetch(() => new Response("{}", { status: 401 }))));
expect(unauthorized).toMatchObject({ status: "http_error", detail: "http_401" });
});
});
// --- the credentials file ------------------------------------------------------
describe("the credentials file is never opened for writing", () => {
it("a full run only ever reads it, through fs.readFileSync without a flag", async () => {
const { home, credsPath } = makeHome({ fixtures: true, credentials: goodCredentials() });
const before = fs.readFileSync(credsPath, "utf-8");
const beforeStat = fs.statSync(credsPath);
const writers = [
"writeFileSync", "writeFile", "appendFileSync", "appendFile", "createWriteStream",
"renameSync", "rename", "copyFileSync", "copyFile", "cpSync", "rmSync", "rm",
"unlinkSync", "unlink", "truncateSync", "truncate", "chmodSync", "chmod",
"chownSync", "utimesSync", "symlinkSync", "linkSync", "openSync", "open",
];
const spies = writers.map((name) => vi.spyOn(fs, name));
const promiseWriters = ["writeFile", "appendFile", "open", "rename", "copyFile", "rm", "unlink", "truncate", "chmod"];
const promiseSpies = promiseWriters.map((name) => vi.spyOn(fs.promises, name));
const readSpy = vi.spyOn(fs, "readFileSync");
const fetch = routedFetch();
const { code } = await runAgent({ home, fetch });
expect(code).toBe(EXIT_OK);
expect(fetch.calls.filter((c) => c.url === USAGE_URL)).toHaveLength(1);
const credReads = readSpy.mock.calls.filter((c) => c[0] === credsPath);
expect(credReads).toHaveLength(1);
expect(credReads[0][1]).toBe("utf-8");
for (const spy of [...spies, ...promiseSpies]) {
for (const args of spy.mock.calls) {
const touches = args.some((a) => String(a) === credsPath || String(a).startsWith(`${credsPath}.`));
expect(touches, `${spy.getMockName()} touched the credentials file`).toBe(false);
}
}
// The state file, on the other hand, was written: the spies were live.
expect(spies[0].mock.calls.length + spies[6].mock.calls.length).toBeGreaterThan(0);
expect(fs.readFileSync(credsPath, "utf-8")).toBe(before);
expect(fs.statSync(credsPath).mtimeMs).toBe(beforeStat.mtimeMs);
});
});
// --- state -----------------------------------------------------------------------
describe("state — local cache", () => {
it.each([
["missing", null],
["truncated", '{"version":1,"frozenDays":{"2026-09-'],
["not an object", "[1,2,3]"],
["binary junk", "\u0000\u0001ÿ"],
])("a %s state.json gives the empty state", (_label, content) => {
const file = path.join(tmpDir, "state.json");
if (content !== null) fs.writeFileSync(file, content);
expect(loadState(file)).toEqual(emptyState());
});
it("keeps only well-formed frozen days and last good limits on reload", () => {
const file = path.join(tmpDir, "state.json");
fs.writeFileSync(
file,
JSON.stringify({
frozenDays: {
"2026-10-01": { models: { m: { input: 1 } }, projects: {} },
"2026-02-30": { models: {}, projects: {} },
"not-a-day": { models: {}, projects: {} },
"2026-09-30": "junk",
"2026-09-29": { models: [], projects: null },
},
lastGoodLimits: { fetchedAt: "garbage", items: [] },
}),
);
const s = loadState(file);
expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-09-29", "2026-10-01"]);
expect(s.frozenDays["2026-09-29"]).toEqual({ models: {}, projects: {} });
expect(s.lastGoodLimits).toBeNull();
});
it("freezes every open day strictly before yesterday, empty ones included; yesterday and today stay open", () => {
const openDays = {
"2026-09-05": { models: {}, projects: {} },
"2026-10-01": { models: { m: { input: 1 } }, projects: { p: { work: 1 } } },
"2026-10-02": { models: {}, projects: {} },
"2026-10-03": { models: { m: { input: 2 } }, projects: {} },
"2026-10-04": { models: {}, projects: {} },
};
const s = nextState(emptyState(), { openDays, today: "2026-10-04", limits: { status: "disabled" } });
expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-09-05", "2026-10-01", "2026-10-02"]);
expect(s.frozenDays["2026-10-02"]).toEqual({ models: {}, projects: {} });
expect(s.frozenDays["2026-10-01"]).toEqual(openDays["2026-10-01"]);
});
it(`prunes frozen days older than ${RETENTION_DAYS} days`, () => {
const state = {
...emptyState(),
frozenDays: {
"2026-08-29": { models: {}, projects: {} }, // today - 36
"2026-08-30": { models: {}, projects: {} }, // today - 35
"2026-09-20": { models: {}, projects: {} },
},
};
const s = nextState(state, { openDays: {}, today: "2026-10-04", limits: { status: "disabled" } });
expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-08-30", "2026-09-20"]);
});
it("updates lastGoodLimits on `ok` only", () => {
const ok = { status: "ok", fetchedAt: "2026-10-04T16:00:00.000Z", items: [{ kind: "session" }] };
const s1 = nextState(emptyState(), { openDays: {}, today: "2026-10-04", limits: ok });
expect(s1.lastGoodLimits).toEqual({ fetchedAt: ok.fetchedAt, items: ok.items });
for (const failed of [
{ status: "http_error", detail: "http_429", fetchedAt: null, items: [] },
{ status: "token_expired", fetchedAt: null, items: [] },
{ status: "unavailable", detail: "timeout", fetchedAt: null, items: [] },
{ status: "disabled", fetchedAt: null, items: [] },
]) {
const s2 = nextState(s1, { openDays: {}, today: "2026-10-04", limits: failed });
expect(s2.lastGoodLimits).toEqual(s1.lastGoodLimits);
expect(limitsForSnapshot(failed, s2)).toEqual({
status: failed.status,
...(failed.detail ? { detail: failed.detail } : {}),
fetchedAt: ok.fetchedAt,
items: ok.items,
});
}
expect(limitsForSnapshot({ status: "token_expired", fetchedAt: null, items: [] }, emptyState())).toEqual({
status: "token_expired",
fetchedAt: null,
items: [],
});
});
it("writes atomically: mode 600, temp file suffixed with the pid in the same directory, then rename", () => {
const file = path.join(tmpDir, "deep", "state", "state.json");
const writeSpy = vi.spyOn(fs, "writeFileSync");
const renameSpy = vi.spyOn(fs, "renameSync");
saveState(file, { ...emptyState(), frozenDays: { "2026-10-01": { models: {}, projects: {} } } });
const tmp = `${file}.${process.pid}.tmp`;
expect(writeSpy.mock.calls.map((c) => c[0])).toEqual([tmp]);
expect(renameSpy.mock.calls).toEqual([[tmp, file]]);
expect(fs.existsSync(tmp)).toBe(false);
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
expect(loadState(file).frozenDays).toEqual({ "2026-10-01": { models: {}, projects: {} } });
});
it("the default state dir is ~/.local/state/maximus-claude-usage", () => {
expect(stateFilePath(defaultStateDir("/home/u"))).toBe("/home/u/.local/state/maximus-claude-usage/state.json");
});
});
// --- push-claude-usage: one beat ----------------------------------------------
describe("push-claude-usage — one beat", () => {
it("two consecutive beats around an empty day: the empty day is frozen, the second beat opens no file before the threshold", async () => {
const { home, stateFile } = makeHome({ credentials: undefined });
const root = path.join(home, ".claude", "projects");
const projectDir = path.join(root, "-home-tester-claude-code-alpha");
fs.mkdirSync(projectDir, { recursive: true });
const write = (name, lines, mtimeIso) => {
const file = path.join(projectDir, name);
fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
setMtime(file, mtimeIso);
return file;
};
// Lines on Oct 1 (Toronto 23:00), file last touched Oct 2 00:30 Toronto:
// Oct 2 itself has no line at all. Unfrozen, that empty day would hold the
// threshold on Oct 2 and this file would be re-read on every beat.
const old = write("old.jsonl", [line({ ts: "2026-10-02T03:00:00Z", output: 40 })], "2026-10-02T04:30:00Z");
const recent = write(
"recent.jsonl",
[line({ ts: "2026-10-03T15:00:00Z", output: 7 }), line({ ts: "2026-10-04T15:00:00Z", output: 9 })],
"2026-10-04T15:30:00Z",
);
const env = agentEnv({ CLAUDE_USAGE_LIMITS: "off" });
const firstOpened = [];
const fetch1 = routedFetch();
const r1 = await runAgent({
home, fetch: fetch1, env,
openFile: (f, o) => { firstOpened.push(f); return fs.createReadStream(f, o); },
});
expect(r1.code).toBe(EXIT_OK);
expect(firstOpened.sort()).toEqual([old, recent].sort());
const frozen = JSON.parse(fs.readFileSync(stateFile, "utf-8")).frozenDays;
expect(frozen["2026-10-02"]).toEqual({ models: {}, projects: {} });
expect(frozen["2026-10-01"].models["claude-opus-5-5"].output).toBe(40);
expect(frozen["2026-10-03"]).toBeUndefined(); // yesterday stays open
expect(frozen["2026-10-04"]).toBeUndefined();
expect(Object.keys(frozen)).toHaveLength(28); // the window minus yesterday and today
const secondOpened = [];
const fetch2 = routedFetch();
const r2 = await runAgent({
home, fetch: fetch2, env, now: NOW + 15 * 60 * 1000,
openFile: (f, o) => { secondOpened.push(f); return fs.createReadStream(f, o); },
});
expect(r2.code).toBe(EXIT_OK);
expect(secondOpened).toEqual([recent]);
const snap1 = JSON.parse(pushCalls(fetch1)[0].init.body);
const snap2 = JSON.parse(pushCalls(fetch2)[0].init.body);
expect(snap2.days).toEqual(snap1.days); // frozen days served as they were counted
expect(snap2.projects).toEqual(snap1.projects);
});
it("a corrupt state.json gives a full beat without failing, and is rewritten", async () => {
const { home, stateFile } = makeHome({ fixtures: true });
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, '{"frozenDays":{"2026-10-0');
const opened = [];
const fetch = routedFetch();
const r = await runAgent({
home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }),
openFile: (f, o) => { opened.push(f); return fs.createReadStream(f, o); },
});
expect(r.code).toBe(EXIT_OK);
expect(opened).toHaveLength(2); // the whole fixture tree
expect(Object.keys(loadState(stateFile).frozenDays).length).toBeGreaterThan(0);
expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600);
});
it("writes the cache before the push: a failed push still leaves the frozen days on disk", async () => {
const { home, stateFile } = makeHome({ fixtures: true });
const fetch = routedFetch({ push: () => new Response("nope", { status: 503 }) });
const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) });
expect(r.code).toBe(EXIT_PUSH);
expect(r.stderr).toContain("push failed: HTTP 503");
expect(Object.keys(loadState(stateFile).frozenDays).length).toBe(28);
});
it("the snapshot built on the fixtures goes through sanitizeClaudeUsage unchanged", async () => {
const { home } = makeHome({ fixtures: true, credentials: goodCredentials() });
const fetch = routedFetch();
const r = await runAgent({ home, fetch });
expect(r.code).toBe(EXIT_OK);
const sent = JSON.parse(pushCalls(fetch)[0].init.body);
expect(sent.schemaVersion).toBe(1);
expect(sent.timeZone).toBe("America/Toronto");
expect(sent.claudeCodeVersion).toBe("2.1.283");
expect(sent.days).toHaveLength(30);
expect(sent.days[29].date).toBe("2026-10-04");
expect(sent.limits.status).toBe("ok");
expect(sent.limits.items.find((i) => i.kind === "weekly_scoped").model).toBe("Fable");
expect(sent.projects.length).toBeGreaterThan(0);
const { snapshot, error } = sanitizeClaudeUsage(sent);
expect(error).toBeUndefined();
expect(snapshot).toEqual(sent);
});
it.each([
["ok limits", { credentials: goodCredentials() }, {}],
["expired token", { credentials: goodCredentials(NOW - 1) }, {}],
["no credentials", {}, {}],
["limits off", { credentials: goodCredentials() }, { CLAUDE_USAGE_LIMITS: "off" }],
])("with %s, the snapshot still round-trips through the sanitizer", async (_label, homeOpts, envExtra) => {
const { home } = makeHome({ fixtures: true, ...homeOpts });
const fetch = routedFetch();
expect((await runAgent({ home, fetch, env: agentEnv(envExtra) })).code).toBe(EXIT_OK);
const sent = JSON.parse(pushCalls(fetch)[0].init.body);
expect(sanitizeClaudeUsage(sent).snapshot).toEqual(sent);
});
it("sends the OAuth bearer only to api.anthropic.com and the ingest token only to the health API", async () => {
const { home } = makeHome({ fixtures: true, credentials: goodCredentials() });
const fetch = routedFetch();
await runAgent({ home, fetch });
expect(fetch.calls.map((c) => c.url)).toEqual([USAGE_URL, `${API_URL}/claude-usage`]);
const [usage, push] = fetch.calls;
expect(usage.init.headers.Authorization).toBe(`Bearer ${OAUTH_TOKEN}`);
expect(JSON.stringify(usage)).not.toContain(INGEST_TOKEN);
expect(push.init.headers.Authorization).toBe(`Bearer ${INGEST_TOKEN}`);
expect(push.init.method).toBe("POST");
expect(push.init.redirect).toBe("error");
expect(push.init.signal).toBeInstanceOf(AbortSignal);
expect(JSON.stringify(push.init.headers)).not.toContain(OAUTH_TOKEN);
expect(push.init.body).not.toContain(OAUTH_TOKEN);
});
it("neither token appears in the snapshot nor in the output, on success or on hostile failures", async () => {
const scenarios = [
routedFetch(),
routedFetch({ push: () => new Response(`denied ${INGEST_TOKEN} ${OAUTH_TOKEN}`, { status: 401 }) }),
routedFetch({ push: () => { throw new TypeError(`Headers.append: "Bearer ${INGEST_TOKEN}" is an invalid header value.`); } }),
routedFetch({ usage: () => new Response(`Authorization: Bearer ${OAUTH_TOKEN}`, { status: 500 }) }),
routedFetch({ push: () => { throw Object.assign(new Error(`connect to ${INGEST_TOKEN}`), { code: `E${INGEST_TOKEN}` }); } }),
];
for (const fetch of scenarios) {
fs.rmSync(path.join(tmpDir, "home"), { recursive: true, force: true });
const { home } = makeHome({ fixtures: true, credentials: goodCredentials() });
const r = await runAgent({ home, fetch });
expectNoLeak(`${r.stdout}\n${r.stderr}`);
expectNoLeak(pushCalls(fetch)[0].init.body);
expectNoLeak(fs.readFileSync(stateFilePath(defaultStateDir(home)), "utf-8"));
}
});
it("the real process output contains no fragment of either token", async () => {
const { home } = makeHome({ fixtures: true, credentials: goodCredentials(Date.now() - 1000) });
// Expired credentials (no Anthropic call), and a push target that refuses
// the connection: a real failing run, nothing leaves the machine.
const result = await execFileAsync(process.execPath, [PUSH_PATH], {
env: {
PATH: process.env.PATH,
HOME: home,
CLAUDE_USAGE_API_URL: "https://127.0.0.1:1",
CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN,
NO_PROXY: "*",
no_proxy: "*",
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toMatch(/claude-usage: push failed: request error \(code=[A-Za-z0-9_]+\) limits=token_expired/);
expectNoLeak(`${result.stdout}${result.stderr}`);
});
it.each([
["http:", { CLAUDE_USAGE_API_URL: "http://health.example.test" }, "must be https://"],
["unset URL", { CLAUDE_USAGE_API_URL: "" }, "CLAUDE_USAGE_API_URL is not set"],
["garbage URL", { CLAUDE_USAGE_API_URL: "::not a url::" }, "not a valid URL"],
["unset token", { CLAUDE_USAGE_INGEST_TOKEN: "" }, "CLAUDE_USAGE_INGEST_TOKEN is not set"],
["token with a trailing newline", { CLAUDE_USAGE_INGEST_TOKEN: `${INGEST_TOKEN}\n` }, "invalid character"],
])("configuration error (%s) exits 1 before any scan or call", async (_label, envExtra, message) => {
const { home, stateFile } = makeHome({ fixtures: true, credentials: goodCredentials() });
const fetch = routedFetch();
const openFile = vi.fn();
const r = await runAgent({ home, fetch, env: agentEnv(envExtra), openFile });
expect(r.code).toBe(EXIT_CONFIG);
expect(r.stderr).toContain(message);
expect(fetch.calls).toHaveLength(0);
expect(openFile).not.toHaveBeenCalled();
expect(fs.existsSync(stateFile)).toBe(false);
expectNoLeak(`${r.stdout}${r.stderr}`);
});
it("readConfig appends /claude-usage to the base URL", () => {
expect(agentReadConfig(agentEnv({ CLAUDE_USAGE_API_URL: "https://h.example/" })).config.target).toBe(
"https://h.example/claude-usage",
);
});
it(`a snapshot over ${MAX_BODY_BYTES} bytes logs payload_too_large, exits 2, sends nothing`, async () => {
const { home } = makeHome();
const projectDir = path.join(home, ".claude", "projects", "big");
fs.mkdirSync(projectDir, { recursive: true });
// 30 days x 12 models with 64-character names and maximal counters: about
// 70 KB once serialized, every value still inside the contract.
const big = Number.MAX_SAFE_INTEGER;
const lines = [];
for (const date of windowDays("2026-10-04")) {
for (let m = 0; m < 12; m++) {
lines.push(
line({
ts: `${date}T15:00:00Z`,
model: `m${String(m).padStart(2, "0")}-${"x".repeat(60)}`,
input: big, output: big, cacheCreation: big, cacheRead: big,
}),
);
}
}
const file = path.join(projectDir, "s.jsonl");
fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
setMtime(file, "2026-10-04T15:30:00Z");
const fetch = routedFetch();
const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) });
expect(r.code).toBe(EXIT_PUSH);
expect(r.stderr).toMatch(/payload_too_large bytes=\d+ max=65536/);
expect(Number(/bytes=(\d+)/.exec(r.stderr)[1])).toBeGreaterThan(MAX_BODY_BYTES);
expect(fetch.calls).toHaveLength(0);
});
it("one push attempt only: a failure is not retried", async () => {
const { home } = makeHome({ fixtures: true });
const fetch = routedFetch({ push: () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }); } });
const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) });
expect(r.code).toBe(EXIT_PUSH);
expect(r.stderr).toContain("push failed: request error (code=ECONNRESET)");
expect(pushCalls(fetch)).toHaveLength(1);
});
it("a 2xx answer exits 0 with a one-line summary", async () => {
const { home } = makeHome({ fixtures: true, credentials: goodCredentials() });
const r = await runAgent({ home, fetch: routedFetch() });
expect(r.code).toBe(EXIT_OK);
expect(r.stdout).toMatch(/^claude-usage: ok status=204 limits=ok bytes=\d+$/);
expect(r.stderr).toBe("");
});
it("--dry-run prints the snapshot, needs no configuration, pushes nothing and leaves the cache alone", async () => {
const { home, stateFile } = makeHome({ fixtures: true });
const fetch = routedFetch();
const r = await runAgent({ home, fetch, env: { CLAUDE_USAGE_LIMITS: "off" }, argv: ["--dry-run"] });
expect(r.code).toBe(EXIT_OK);
const snap = JSON.parse(r.stdout);
expect(snap.days).toHaveLength(30);
expect(snap.limits).toEqual({ status: "disabled", fetchedAt: null, items: [] });
expect(fetch.calls).toHaveLength(0);
expect(fs.existsSync(stateFile)).toBe(false);
});
it("the agent modules load nothing but node built-ins and their siblings", () => {
for (const name of ["limits.js", "state.js", "push-claude-usage.js"]) {
const src = fs.readFileSync(path.join(AGENT_DIR, name), "utf-8");
for (const [, mod] of src.matchAll(/require\("([^"]+)"\)/g)) {
expect(mod.startsWith("node:") || mod.startsWith("./"), `${name} requires ${mod}`).toBe(true);
}
}
});
});
// --- run-claude-usage.sh ----------------------------------------------------------
describe("run-claude-usage.sh", () => {
function writeEnvFile(home, lines, mode = 0o600) {
const file = path.join(home, ".config", "maximus-claude-usage.env");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode });
fs.chmodSync(file, mode);
return file;
}
// A stand-in for node: records that it ran, then holds the lock a while.
function writeStubNode(home, seconds) {
const stub = path.join(home, "stub-node");
fs.writeFileSync(stub, `#!/bin/sh\necho run >> "$HOME/runs"\nsleep ${seconds}\n`, { mode: 0o755 });
return stub;
}
function runs(home) {
const file = path.join(home, "runs");
return fs.existsSync(file) ? fs.readFileSync(file, "utf-8").trim().split("\n").length : 0;
}
function wrapperEnv(home) {
return { PATH: process.env.PATH, HOME: home };
}
test("two runs in parallel: the second exits 0 without working", async () => {
const { home } = makeHome();
const stub = writeStubNode(home, 2);
writeEnvFile(home, [
`CLAUDE_USAGE_API_URL=${API_URL}`,
`CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`,
`NODE_BIN=${stub}`,
]);
const first = spawn(WRAPPER_PATH, [], { env: wrapperEnv(home), stdio: "ignore" });
const firstDone = new Promise((resolve) => first.on("exit", (code) => resolve(code)));
const deadline = Date.now() + 5000;
while (runs(home) === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25));
expect(runs(home)).toBe(1);
const started = Date.now();
const second = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) });
expect(Date.now() - started).toBeLessThan(1500);
expect(second.stdout).toContain("claude-usage: a run is already in progress, skipping");
expect(second.stderr).toBe("");
expect(runs(home)).toBe(1);
expect(await firstDone).toBe(0);
// The lock is released with the first run: the next beat works again.
const stub0 = writeStubNode(home, 0);
expect(stub0).toBe(stub);
await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) });
expect(runs(home)).toBe(2);
expect(fs.existsSync(path.join(home, ".local", "state", "maximus-claude-usage", "run.lock"))).toBe(true);
}, 15000);
test("a dry run through the wrapper prints a valid snapshot and no token", async () => {
const { home } = makeHome({ fixtures: true });
writeEnvFile(home, [
`CLAUDE_USAGE_API_URL=${API_URL}`,
`CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`,
"CLAUDE_USAGE_LIMITS=off",
`NODE_BIN=${process.execPath}`,
]);
const result = await execFileAsync(WRAPPER_PATH, ["--dry-run"], { env: wrapperEnv(home) });
const snap = JSON.parse(result.stdout);
expect(snap.limits.status).toBe("disabled"); // CLAUDE_USAGE_LIMITS was exported
expect(sanitizeClaudeUsage(snap).snapshot).toEqual(snap);
expect(result.stderr).toBe("");
expectNoLeak(`${result.stdout}${result.stderr}`);
});
test.each([
[["CLAUDE_USAGE_INGEST_TOKEN"], "CLAUDE_USAGE_API_URL is not set"],
[["CLAUDE_USAGE_API_URL"], "CLAUDE_USAGE_INGEST_TOKEN is not set"],
])("stops when a variable is missing (%j)", async (present, message) => {
const { home } = makeHome();
const values = { CLAUDE_USAGE_API_URL: API_URL, CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN };
writeEnvFile(home, present.map((k) => `${k}=${values[k]}`));
const result = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }).catch((e) => e);
expect(result.code).toBe(1);
expect(result.stderr).toContain(message);
expectNoLeak(`${result.stdout}${result.stderr}`);
});
test("stops when the env file is absent, and warns on a mode other than 600", async () => {
const { home } = makeHome();
const missing = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }).catch((e) => e);
expect(missing.code).toBe(1);
expect(missing.stderr).toContain("claude-usage: env file not found:");
const stub = writeStubNode(home, 0);
writeEnvFile(
home,
[`CLAUDE_USAGE_API_URL=${API_URL}`, `CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`, `NODE_BIN=${stub}`],
0o644,
);
const loose = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) });
expect(loose.stderr).toContain("is mode 644, expected 600");
expect(runs(home)).toBe(1);
expectNoLeak(`${loose.stdout}${loose.stderr}`);
});
test("never enables shell tracing — `set -x` would echo the token into syslog", () => {
const script = fs.readFileSync(WRAPPER_PATH, "utf-8");
const traced = script
.split("\n")
.filter((l) => !l.trim().startsWith("#"))
.filter((l) => /\bset\b[^#\n]*-[a-z]*x/.test(l));
expect(traced).toEqual([]);
});
test("is executable, and takes a non-blocking flock under ~/.local/state", () => {
expect(fs.statSync(WRAPPER_PATH).mode & 0o111).toBeGreaterThan(0);
const script = fs.readFileSync(WRAPPER_PATH, "utf-8");
expect(script).toContain('STATE_DIR="$HOME/.local/state/maximus-claude-usage"');
expect(script).toMatch(/flock -n 9/);
});
});

View file

@ -0,0 +1,53 @@
{
"five_hour": { "utilization": 1.0, "resets_at": "2026-09-27T21:30:00.512345+00:00" },
"seven_day": { "utilization": 50.0, "resets_at": "2026-10-01T07:00:00.512345+00:00" },
"seven_day_oauth_apps": null,
"seven_day_opus": null,
"seven_day_sonnet": null,
"seven_day_fable": null,
"iguana_necktie": null,
"extra_usage": null,
"opaque_a": null,
"opaque_b": null,
"opaque_c": null,
"opaque_d": null,
"opaque_e": null,
"opaque_f": null,
"opaque_g": null,
"opaque_h": null,
"opaque_i": null,
"opaque_j": null,
"opaque_k": null,
"opaque_l": null,
"opaque_m": null,
"opaque_n": null,
"limits": [
{
"kind": "session",
"group": "session",
"percent": 1,
"severity": "normal",
"resets_at": "2026-09-27T21:30:00Z",
"scope": null,
"is_active": true
},
{
"kind": "weekly_all",
"group": "weekly",
"percent": 50,
"severity": "normal",
"resets_at": "2026-10-01T07:00:00Z",
"scope": null,
"is_active": true
},
{
"kind": "weekly_scoped",
"group": "weekly",
"percent": 63,
"severity": "normal",
"resets_at": "2026-10-01T07:00:00Z",
"scope": { "model": { "display_name": "Fable" } },
"is_active": true
}
]
}

View file

@ -0,0 +1,276 @@
// Claude plan limits for the claude-usage agent (issue #50).
//
// One GET to Anthropic's (undocumented) OAuth usage endpoint, with the access
// token Claude Code already stored in ~/.claude/.credentials.json. Returns the
// `limits` block of the POST /claude-usage contract (schemaVersion 1), minus
// the "last good values" fallback, which state.js owns.
//
// Properties that are load-bearing and easy to break:
//
// * THE CREDENTIALS FILE IS READ, NEVER WRITTEN. Refresh tokens are single
// use: refreshing here would log Claude Code out. An expired token means
// `token_expired` and no call at all. The file is only ever passed to
// fs.readFileSync (no flag), and __tests__/agent-claude-usage.test.js
// spies on every writing function of node:fs to prove it.
// * THE BEARER ONLY GOES TO USAGE_URL. The same process also holds a
// configurable push URL; the OAuth token must never follow it. The URL is
// a constant (no environment variable changes it) and redirects are
// refused (`redirect: "error"`), so the token cannot be bounced elsewhere.
// * `detail` IS AN ENUM. It comes from DETAILS below and nothing else, never
// from an error message or a response body: both can carry upstream text,
// and this value ends up in the admin dashboard.
// * ONE CALL, NO RETRY. The endpoint rate-limits hard and sends no
// Retry-After; a failed call waits for the next cron beat (15 min).
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
const ANTHROPIC_BETA = "oauth-2025-04-20";
const LIMITS_TIMEOUT_MS = 10000;
// Far beyond the ~2 KB the endpoint answers; past it the body is not parsed.
const MAX_RESPONSE_BYTES = 256 * 1024;
const MAX_ITEMS = 12;
const MAX_STRING = 128;
const DETAILS = {
NO_CREDENTIALS: "no_credentials",
PARSE_ERROR: "parse_error",
NO_VERSION: "no_version",
TIMEOUT: "timeout",
NETWORK: "network",
BAD_RESPONSE: "bad_response",
};
// Same shape as the server sanitizer's timestamp check: a value outside it
// would be dropped there, so it is normalized (or nulled) here instead.
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
function defaultCredentialsPath(home = os.homedir()) {
return path.join(home, ".claude", ".credentials.json");
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function failure(status, detail) {
const out = { status, fetchedAt: null, items: [] };
if (detail) out.detail = detail;
return out;
}
function cleanString(value) {
return typeof value === "string" ? value.slice(0, MAX_STRING) : null;
}
// The contract wants an integer 0-100. Upstream sends a number that can be
// fractional (utilization) and could overshoot 100 once a limit is exceeded.
function cleanPercent(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return Math.min(100, Math.max(0, Math.round(value)));
}
// ISO string kept as sent when it already has the contract's shape; any other
// parseable string, or an epoch number (seconds or milliseconds), is
// re-rendered with toISOString; anything else is null.
function cleanResetsAt(value) {
if (typeof value === "string") {
if (value.length <= MAX_STRING && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value))) {
return value;
}
const ms = Date.parse(value);
return Number.isNaN(ms) ? null : new Date(ms).toISOString();
}
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
const ms = value < 1e11 ? value * 1000 : value;
const date = new Date(ms);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
function item({ kind, group, percent, severity, resetsAt, model }) {
const out = { kind, group };
if (percent !== undefined) out.percent = percent;
if (typeof severity === "string") out.severity = severity;
out.resetsAt = resetsAt;
out.model = model;
return out;
}
// `limits[]` entries: { kind, group, percent, severity, resets_at,
// scope: { model: { display_name } } | null, is_active }. Built field by field,
// so no upstream key reaches the snapshot unless it is listed here.
function itemsFromLimits(list) {
const items = [];
for (const entry of list) {
if (items.length >= MAX_ITEMS) break;
if (!isPlainObject(entry) || typeof entry.kind !== "string" || !entry.kind) continue;
const scope = isPlainObject(entry.scope) ? entry.scope : null;
const model = scope && isPlainObject(scope.model) ? cleanString(scope.model.display_name) : null;
items.push(
item({
kind: entry.kind.slice(0, MAX_STRING),
group: typeof entry.group === "string" ? entry.group.slice(0, MAX_STRING) : entry.kind.slice(0, MAX_STRING),
percent: cleanPercent(entry.percent),
severity: cleanString(entry.severity),
resetsAt: cleanResetsAt(entry.resets_at),
model,
}),
);
}
return items;
}
// Older shape: five_hour / seven_day { utilization, resets_at }.
function itemsFromWindows(body) {
const items = [];
const windows = [
["five_hour", "session", "session"],
["seven_day", "weekly_all", "weekly"],
];
for (const [key, kind, group] of windows) {
const w = body[key];
if (!isPlainObject(w)) continue;
const percent = cleanPercent(w.utilization);
if (percent === undefined) continue;
items.push(item({ kind, group, percent, severity: null, resetsAt: cleanResetsAt(w.resets_at), model: null }));
}
return items;
}
function parseUsage(body) {
if (!isPlainObject(body)) return [];
if (Array.isArray(body.limits)) {
const items = itemsFromLimits(body.limits);
if (items.length) return items;
}
return itemsFromWindows(body);
}
// Read the OAuth credentials. Never throws, never logs the content.
function readCredentials(credentialsPath, readFile) {
let raw;
try {
raw = readFile(credentialsPath);
} catch (err) {
if (err && err.code === "ENOENT") return { detail: DETAILS.NO_CREDENTIALS };
return { detail: DETAILS.PARSE_ERROR };
}
let parsed;
try {
// Claude Code rewrites this file in place: a half-written JSON is a normal
// occurrence, and the next beat will read the finished one.
parsed = JSON.parse(String(raw));
} catch {
return { detail: DETAILS.PARSE_ERROR };
}
const oauth = isPlainObject(parsed) && isPlainObject(parsed.claudeAiOauth) ? parsed.claudeAiOauth : null;
if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) {
return { detail: DETAILS.NO_CREDENTIALS };
}
return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt };
}
async function readBody(res) {
const text = await res.text();
if (text.length > MAX_RESPONSE_BYTES) throw new Error("too large");
return JSON.parse(text);
}
function discardBody(res) {
try {
if (res && res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {});
} catch {
// nothing to release
}
}
/**
* Fetch the plan limits once.
*
* @param {object} opts
* @param {object} [opts.env] process environment (only CLAUDE_USAGE_LIMITS is read)
* @param {string} [opts.credentialsPath] ~/.claude/.credentials.json by default
* @param {Function} [opts.readFile] (path) => string, read-only — injectable for tests
* @param {Function} [opts.fetch] fetch implementation — injectable for tests
* @param {string|null} opts.claudeCodeVersion version for the User-Agent (from the transcripts)
* @param {number} [opts.now] epoch ms
* @param {number} [opts.timeoutMs]
* @returns {Promise<{status: string, detail?: string, fetchedAt: string|null, items: object[]}>}
*/
async function fetchLimits({
env = process.env,
credentialsPath = defaultCredentialsPath(),
readFile = (file) => fs.readFileSync(file, "utf-8"),
fetch: fetchImpl = globalThis.fetch,
claudeCodeVersion = null,
now = Date.now(),
timeoutMs = LIMITS_TIMEOUT_MS,
} = {}) {
if (String(env.CLAUDE_USAGE_LIMITS || "").trim().toLowerCase() === "off") {
return failure("disabled");
}
const creds = readCredentials(credentialsPath, readFile);
if (creds.detail) return failure("unavailable", creds.detail);
// Never refreshed: the refresh token is single use and belongs to Claude
// Code. A missing expiresAt is left to the server to judge (a 401 then).
if (typeof creds.expiresAt === "number" && Number.isFinite(creds.expiresAt) && creds.expiresAt <= now) {
return failure("token_expired");
}
if (typeof claudeCodeVersion !== "string" || !claudeCodeVersion) {
return failure("unavailable", DETAILS.NO_VERSION);
}
let res;
try {
res = await fetchImpl(USAGE_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${creds.accessToken}`,
"anthropic-beta": ANTHROPIC_BETA,
"User-Agent": `claude-code/${claudeCodeVersion}`,
Accept: "application/json",
},
redirect: "error",
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const name = err && err.name;
return failure("unavailable", name === "TimeoutError" || name === "AbortError" ? DETAILS.TIMEOUT : DETAILS.NETWORK);
}
const status = res && Number.isInteger(res.status) ? res.status : 0;
if (status < 200 || status > 299) {
discardBody(res);
return failure("http_error", status >= 100 && status <= 599 ? `http_${status}` : DETAILS.BAD_RESPONSE);
}
let body;
try {
body = await readBody(res);
} catch (err) {
const name = err && err.name;
if (name === "TimeoutError" || name === "AbortError") return failure("unavailable", DETAILS.TIMEOUT);
return failure("unavailable", DETAILS.BAD_RESPONSE);
}
const items = parseUsage(body);
if (!items.length) return failure("unavailable", DETAILS.BAD_RESPONSE);
return { status: "ok", fetchedAt: new Date(now).toISOString(), items };
}
module.exports = {
fetchLimits,
parseUsage,
defaultCredentialsPath,
USAGE_URL,
ANTHROPIC_BETA,
LIMITS_TIMEOUT_MS,
DETAILS,
};

View file

@ -0,0 +1,238 @@
#!/usr/bin/env node
// claude-usage agent entry point (issue #50). Runs from the ThinkPad's
// crontab every 15 minutes through run-claude-usage.sh:
//
// 1. aggregate the local transcripts (aggregate.js), frozen days excluded;
// 2. read the plan limits once (limits.js);
// 3. update and write the local cache (state.js) BEFORE pushing, so a failed
// push never costs the next beat a full rescan;
// 4. assemble the schemaVersion 1 snapshot, measure it, push it once.
//
// Zero runtime dependencies (Node 22 global fetch). Same two rules as
// agent/push-metrics.js, for the same reason — the output goes to
// `logger -t claude-usage`, so into syslog and journald for good:
//
// * NO QUEUE, NO REPLAY. One beat = one attempt; a failure exits non-zero.
// * THE OUTPUT NEVER CARRIES A SECRET. Neither the ingest token nor the
// OAuth token is printed. Failures are rebuilt from fixed templates plus an
// HTTP status or a sanitized error code — never an error object, a message
// from the HTTP stack (undici quotes an invalid header value in it), the
// request options or a response body.
const os = require("node:os");
const path = require("node:path");
const { aggregate, torontoDay, TIME_ZONE } = require("./aggregate.js");
const { fetchLimits, defaultCredentialsPath } = require("./limits.js");
const {
loadState,
saveState,
nextState,
limitsForSnapshot,
defaultStateDir,
stateFilePath,
} = require("./state.js");
const SCHEMA_VERSION = 1;
// Same ceiling as the server (CLAUDE_USAGE_MAX_BODY_BYTES): a larger body
// would be refused with a 413, so it is not even sent.
const MAX_BODY_BYTES = 65536;
const PUSH_TIMEOUT_MS = 10000;
const EXIT_OK = 0;
const EXIT_CONFIG = 1;
const EXIT_PUSH = 2;
const PREFIX = "claude-usage:";
// A token is printable ASCII without spaces. Checked up front: fetch rejects
// an invalid header value with a message that QUOTES it.
const TOKEN_RE = /^[\x21-\x7e]+$/;
// What may be printed of an error: a system or undici code (ECONNREFUSED,
// UND_ERR_CONNECT_TIMEOUT: upper case only) or an error class name
// (TimeoutError, TypeError). Anything else, whatever field it came from, is
// replaced by UNKNOWN rather than trusted.
const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{1,39}$/;
const ERROR_NAME_RE = /^[A-Z][A-Za-z]{0,30}Error$/;
function safeCode(err) {
const codes = [err && err.cause && err.cause.code, err && err.code];
for (const c of codes) {
if (typeof c === "string" && ERROR_CODE_RE.test(c)) return c;
}
const name = err && err.name;
return typeof name === "string" && ERROR_NAME_RE.test(name) ? name : "UNKNOWN";
}
function readConfig(env) {
const apiUrl = String(env.CLAUDE_USAGE_API_URL || "").trim();
const token = env.CLAUDE_USAGE_INGEST_TOKEN || "";
if (!apiUrl) return { error: "CLAUDE_USAGE_API_URL is not set" };
if (!token) return { error: "CLAUDE_USAGE_INGEST_TOKEN is not set" };
if (!TOKEN_RE.test(token)) {
return {
error:
"CLAUDE_USAGE_INGEST_TOKEN contains an invalid character (a trailing newline or space in the env file is the usual cause)",
};
}
let target;
try {
target = new URL(`${apiUrl.replace(/\/+$/, "")}/claude-usage`);
} catch {
return { error: "CLAUDE_USAGE_API_URL is not a valid URL" };
}
// The ingest token travels in this request: https only, no exception.
if (target.protocol !== "https:") return { error: "CLAUDE_USAGE_API_URL must be https://" };
return { config: { target: target.href, token } };
}
function buildSnapshot({ now, aggregated, limits }) {
const snapshot = {
schemaVersion: SCHEMA_VERSION,
generatedAt: new Date(now).toISOString(),
};
// Left out rather than null: the contract types it as a string, and the
// server drops a null there anyway.
if (aggregated.claudeCodeVersion) snapshot.claudeCodeVersion = aggregated.claudeCodeVersion;
snapshot.timeZone = TIME_ZONE;
snapshot.limits = limits;
snapshot.days = aggregated.days;
snapshot.projects = aggregated.projects;
return snapshot;
}
async function pushSnapshot({ target, token, body, fetchImpl }) {
let res;
try {
res = await fetchImpl(target, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body,
redirect: "error",
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
});
} catch (err) {
return { error: `push failed: request error (code=${safeCode(err)})` };
}
// The response body is never read, never logged.
try {
if (res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {});
} catch {
// nothing to release
}
const status = Number.isInteger(res.status) ? res.status : 0;
if (status >= 200 && status <= 299) return { status };
return { error: `push failed: HTTP ${status}` };
}
/**
* @param {string[]} argv
* @param {object} env
* @param {object} deps injectable for tests: home, fetch, now, readFile, openFile, log, logError
* @returns {Promise<number>} exit code
*/
async function main(argv = process.argv.slice(2), env = process.env, deps = {}) {
const home = deps.home || os.homedir();
const fetchImpl = deps.fetch || globalThis.fetch;
const now = deps.now !== undefined ? deps.now : Date.now();
const log = deps.log || ((line) => console.log(line));
const logError = deps.logError || ((line) => console.error(line));
const dryRun = argv.includes("--dry-run");
// Configuration first: a full scan costs seconds, no point spending them to
// then discover a typo in the env file. A dry run needs no configuration.
let config = null;
if (!dryRun) {
const result = readConfig(env);
if (result.error) {
logError(`${PREFIX} configuration error: ${result.error}`);
return EXIT_CONFIG;
}
config = result.config;
}
const stateFile = stateFilePath(deps.stateDir || defaultStateDir(home));
const state = loadState(stateFile);
const today = torontoDay(now);
const aggregated = await aggregate({
root: deps.transcriptsRoot || path.join(home, ".claude", "projects"),
frozenDays: state.frozenDays,
today,
homedir: home,
...(deps.openFile ? { openFile: deps.openFile } : {}),
});
const limits = await fetchLimits({
env,
credentialsPath: deps.credentialsPath || defaultCredentialsPath(home),
...(deps.readFile ? { readFile: deps.readFile } : {}),
fetch: fetchImpl,
claudeCodeVersion: aggregated.claudeCodeVersion,
now,
});
const updated = nextState(state, { openDays: aggregated.openDays, today, limits });
// Cache first. A dry run is read-only: it shows what would be sent and
// leaves the cache exactly as the cron left it.
if (!dryRun) {
try {
saveState(stateFile, updated);
} catch (err) {
// Not fatal: the next beat rescans, which is slow but correct.
logError(`${PREFIX} warning: state not saved (code=${safeCode(err)})`);
}
}
const snapshot = buildSnapshot({ now, aggregated, limits: limitsForSnapshot(limits, updated) });
const body = JSON.stringify(snapshot);
const bytes = Buffer.byteLength(body, "utf-8");
if (dryRun) log(JSON.stringify(snapshot, null, 2));
if (bytes > MAX_BODY_BYTES) {
logError(`${PREFIX} payload_too_large bytes=${bytes} max=${MAX_BODY_BYTES}`);
return EXIT_PUSH;
}
if (dryRun) return EXIT_OK;
const pushed = await pushSnapshot({ target: config.target, token: config.token, body, fetchImpl });
const summary = `limits=${snapshot.limits.status}${snapshot.limits.detail ? ` detail=${snapshot.limits.detail}` : ""} bytes=${bytes}`;
if (pushed.error) {
logError(`${PREFIX} ${pushed.error} ${summary}`);
return EXIT_PUSH;
}
log(`${PREFIX} ok status=${pushed.status} ${summary}`);
return EXIT_OK;
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
// Nothing should land here. The code only, never the object or its
// message: either could carry a request option.
console.error(`${PREFIX} unexpected failure (code=${safeCode(err)})`);
process.exitCode = EXIT_PUSH;
},
);
}
module.exports = {
main,
readConfig,
buildSnapshot,
pushSnapshot,
MAX_BODY_BYTES,
PUSH_TIMEOUT_MS,
EXIT_OK,
EXIT_CONFIG,
EXIT_PUSH,
};

View file

@ -0,0 +1,85 @@
#!/bin/sh
#
# Cron wrapper for push-claude-usage.js: take the lock, load the secrets, check
# the install, hand over to node. Every fifteen minutes, unattended.
#
# NEVER add `set -x`. This script is meant to be piped into `logger`, and the
# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight
# into /var/log/syslog and journald, where it would stay. Same reason nothing
# below ever echoes a variable that holds a secret: the checks print the NAME of
# what is missing, never its value.
#
# Suggested crontab line (see agent/README.md):
# */15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
AGENT="$SCRIPT_DIR/push-claude-usage.js"
# Overridable for testing only; cron uses the default.
ENV_FILE="${CLAUDE_USAGE_ENV_FILE:-$HOME/.config/maximus-claude-usage.env}"
STATE_DIR="$HOME/.local/state/maximus-claude-usage"
LOCK_FILE="$STATE_DIR/run.lock"
if ! command -v flock >/dev/null 2>&1; then
echo "claude-usage: flock not found (util-linux)" >&2
exit 1
fi
mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR"
# One run at a time. The first full scan of ~210 MB of transcripts can outlast
# the 15-minute beat; a second run would only redo the same work and race the
# state file. fd 9 stays open through `exec`, so node holds the lock until it
# exits. A beat that finds the lock taken is not an error: exit 0.
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "claude-usage: a run is already in progress, skipping"
exit 0
fi
if [ ! -f "$ENV_FILE" ]; then
echo "claude-usage: env file not found: $ENV_FILE" >&2
exit 1
fi
# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is
# not worth silencing the whole agent over.
FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "")
if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then
echo "claude-usage: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2
fi
# shellcheck source=/dev/null
. "$ENV_FILE"
if [ -z "${CLAUDE_USAGE_API_URL:-}" ]; then
echo "claude-usage: CLAUDE_USAGE_API_URL is not set in $ENV_FILE" >&2
exit 1
fi
if [ -z "${CLAUDE_USAGE_INGEST_TOKEN:-}" ]; then
echo "claude-usage: CLAUDE_USAGE_INGEST_TOKEN is not set in $ENV_FILE" >&2
exit 1
fi
if [ ! -f "$AGENT" ]; then
echo "claude-usage: agent not found: $AGENT" >&2
exit 1
fi
# cron's PATH is famously short (/usr/bin:/bin), and a node installed through
# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when
# `command -v node` comes up empty under cron but works in a login shell.
NODE_BIN="${NODE_BIN:-node}"
if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
echo "claude-usage: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2
exit 1
fi
# CLAUDE_USAGE_LIMITS is optional (`off` skips the Anthropic call). Exported
# too, otherwise setting it in the env file silently does nothing.
export CLAUDE_USAGE_API_URL CLAUDE_USAGE_INGEST_TOKEN CLAUDE_USAGE_LIMITS
exec "$NODE_BIN" "$AGENT" "$@"

163
agent/claude-usage/state.js Normal file
View file

@ -0,0 +1,163 @@
// Local cache of the claude-usage agent (issue #50):
// ~/.local/state/maximus-claude-usage/state.json.
//
// Two things live here, both rebuilt from scratch when the file is lost:
//
// * frozenDays — every Toronto day strictly before yesterday, empty days
// included, as { models, projects } (the shape of an aggregate() open
// day). A frozen day is never recomputed, and aggregate() skips every
// transcript last written before the day after the newest frozen day: this
// is what keeps a beat from re-reading the whole 30-day window. An empty
// day left unfrozen would hold that threshold back forever.
// Yesterday stays open: a session running past midnight still writes
// yesterday's lines into a transcript touched today.
// * lastGoodLimits — the last `ok` limits with their fetchedAt, served when
// the current call fails, so the dashboard shows the last known values and
// how old they are instead of nothing.
//
// The file holds no secret. It is still written mode 600, atomically (temp file
// suffixed with the pid in the same directory, then rename), so a concurrent
// reader sees the old state or the new one, never a truncated JSON.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { shiftDay } = require("./aggregate.js");
const STATE_VERSION = 1;
const RETENTION_DAYS = 35;
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
function defaultStateDir(home = os.homedir()) {
return path.join(home, ".local", "state", "maximus-claude-usage");
}
function stateFilePath(stateDir) {
return path.join(stateDir, "state.json");
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isDay(value) {
return typeof value === "string" && DAY_RE.test(value) && shiftDay(value, 0) === value;
}
function emptyState() {
return { version: STATE_VERSION, frozenDays: {}, lastGoodLimits: null };
}
// Any problem — missing file, unreadable, truncated JSON, wrong shape — gives
// the empty state: the beat then does a full scan and rebuilds the cache.
// Entries are kept only in their expected shape; their counters are re-read
// defensively by aggregate() anyway.
function loadState(file, { readFile = (f) => fs.readFileSync(f, "utf-8") } = {}) {
let parsed;
try {
parsed = JSON.parse(String(readFile(file)));
} catch {
return emptyState();
}
if (!isPlainObject(parsed)) return emptyState();
const state = emptyState();
if (isPlainObject(parsed.frozenDays)) {
for (const [day, value] of Object.entries(parsed.frozenDays)) {
if (!isDay(day) || !isPlainObject(value)) continue;
state.frozenDays[day] = {
models: isPlainObject(value.models) ? value.models : {},
projects: isPlainObject(value.projects) ? value.projects : {},
};
}
}
const last = parsed.lastGoodLimits;
if (
isPlainObject(last) &&
typeof last.fetchedAt === "string" &&
!Number.isNaN(Date.parse(last.fetchedAt)) &&
Array.isArray(last.items)
) {
state.lastGoodLimits = { fetchedAt: last.fetchedAt, items: last.items.filter(isPlainObject) };
}
return state;
}
/**
* Next state after one beat. Pure.
*
* @param {object} state current state (loadState)
* @param {object} opts
* @param {object} opts.openDays aggregate().openDays
* @param {string} opts.today Toronto "YYYY-MM-DD"
* @param {object} opts.limits fetchLimits() result
*/
function nextState(state, { openDays = {}, today, limits = null }) {
const yesterday = shiftDay(today, -1);
const oldest = shiftDay(today, -RETENTION_DAYS);
const frozenDays = {};
for (const [day, value] of Object.entries(state.frozenDays || {})) {
if (day >= oldest) frozenDays[day] = value;
}
for (const [day, value] of Object.entries(openDays || {})) {
if (!isDay(day) || day >= yesterday || day < oldest) continue;
frozenDays[day] = { models: value.models || {}, projects: value.projects || {} };
}
const lastGoodLimits =
limits && limits.status === "ok"
? { fetchedAt: limits.fetchedAt, items: limits.items }
: state.lastGoodLimits || null;
return { version: STATE_VERSION, frozenDays, lastGoodLimits };
}
// The `limits` block of the snapshot: the current result when it is `ok`,
// otherwise its status and detail with the last good items and their date.
function limitsForSnapshot(limits, state) {
if (limits.status === "ok") {
return { status: "ok", fetchedAt: limits.fetchedAt, items: limits.items };
}
const out = { status: limits.status };
if (limits.detail) out.detail = limits.detail;
const last = state && state.lastGoodLimits;
out.fetchedAt = last ? last.fetchedAt : null;
out.items = last ? last.items : [];
return out;
}
// Atomic write: temp file in the same directory (rename is only atomic within
// one file system), mode 600, then rename over the old file.
function saveState(file, state) {
const dir = path.dirname(file);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const tmp = `${file}.${process.pid}.tmp`;
try {
fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
// writeFileSync only applies `mode` when it creates the file: a temp file
// left behind by a killed run would keep its old bits otherwise.
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, file);
} catch (err) {
try {
fs.unlinkSync(tmp);
} catch {
// already gone
}
throw err;
}
}
module.exports = {
loadState,
saveState,
nextState,
limitsForSnapshot,
emptyState,
defaultStateDir,
stateFilePath,
RETENTION_DAYS,
STATE_VERSION,
};