#!/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} 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, }); // An incomplete scan freezes nothing. Only a count is printed: a path // would put a project name in syslog for good. const complete = aggregated.readErrors === 0; if (!complete) { logError(`${PREFIX} warning: read_errors=${aggregated.readErrors}, no day frozen this beat`); } const updated = nextState(state, { openDays: aggregated.openDays, today, limits, freeze: complete }); // 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, };