limits.js reads the Claude Code OAuth credentials read-only and calls the pinned Anthropic usage endpoint once (redirect refused, 10 s timeout, no retry, never a refresh); detail codes come from a fixed enum only. state.js freezes every Toronto day before yesterday, empty ones included, prunes past 35 days, keeps the last good limits, and writes atomically in mode 600. push-claude-usage.js writes the cache before pushing, refuses a non-https URL (exit 1) and a body over 64 KiB (exit 2, nothing sent), and prints no token. run-claude-usage.sh takes a non-blocking flock so a beat that overlaps the previous one exits 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
8.1 KiB
JavaScript
Executable file
238 lines
8.1 KiB
JavaScript
Executable file
#!/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,
|
|
};
|