diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b12ea8..b639dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to tokenmaxxing are documented here. Versions are anchored t ## Unreleased +### Added + +- Added Amp, Codebuff, Droid, Goose, Grok Build CLI, Kilo Code, Kimi, OpenClaw, Qwen Code, and + Supercharge as supported sources. Supercharge is collected natively from its own session store + under the Supercharge data home (`SUPERCHARGE_HOME`, default `~/.supercharge`), independently of + ccusage. +- Carried custom agent data-directory environment variables (`GROK_HOME`, `SUPERCHARGE_HOME`, + `AMP_DATA_DIR`, `QWEN_DATA_DIR`, `KIMI_DATA_DIR`, `KILO_DATA_DIR`, `GOOSE_PATH_ROOT`, + `DROID_SESSIONS_DIR`, `CODEBUFF_DATA_DIR`, `OPENCLAW_DIR`) into scheduled syncs. + +### Changed + +- Required ccusage 20.0.20 or newer, which adds the Grok Build CLI adapter. + ## 0.6.0 - 2026-08-05 ### Added diff --git a/README.md b/README.md index d5be32e..a0f132e 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ installs automatic syncing, and opens your public profile. ## How it works -tokenmaxxing uses [ccusage](https://ccusage.com/) to read local coding-agent -usage, turn it into daily token and API-equivalent spend totals, and sync those -aggregates to your public profile. The leaderboard lets you compare spend or +tokenmaxxing uses [ccusage](https://ccusage.com/) to read local coding-agent usage +and collects Supercharge from its own local session store. It turns that usage into daily token +and API-equivalent spend totals, then syncs those aggregates to your public profile. The leaderboard lets you compare spend or tokens over the last 7 days, 30 days, or all time. Sync is idempotent and profiles aggregate across devices, so you can run @@ -64,6 +64,16 @@ Sync is idempotent and profiles aggregate across devices, so you can run - GitHub Copilot CLI - Hermes - Pi +- Supercharge +- Grok Build CLI +- Amp +- Qwen Code +- Kimi +- Kilo Code +- Goose +- Droid +- Codebuff +- OpenClaw ## Usage diff --git a/apps/cli/README.md b/apps/cli/README.md index d78f2eb..e5f4650 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,8 +2,9 @@ CLI for [tokenmaxxing](https://tokenmaxxing.sh) — the social leaderboard for LLM token usage. Parses your local agent usage (Claude Code, Codex, -OpenCode, Gemini CLI, Copilot CLI, Hermes, Pi) via -[ccusage](https://github.com/ryoppippi/ccusage) and pushes daily aggregates +OpenCode, Gemini CLI, Copilot CLI, Hermes, Pi, Grok Build CLI, Amp, Qwen Code, Kimi, +Kilo Code, Goose, Droid, Codebuff, OpenClaw) via [ccusage](https://github.com/ryoppippi/ccusage). +Supercharge is collected natively from its own local session store. The CLI pushes daily aggregates to your public profile. ## Usage diff --git a/apps/cli/src/ccusage/runner.test.ts b/apps/cli/src/ccusage/runner.test.ts index 6923739..789d310 100644 --- a/apps/cli/src/ccusage/runner.test.ts +++ b/apps/cli/src/ccusage/runner.test.ts @@ -33,7 +33,7 @@ async function ccusageErrorFor(effect: Effect.Effect) { describe("ccusage commands", () => { it("uses the minimum v20 release with the Codex replay fix", () => { expect(dailyCcusageCommand(codex)).toEqual([ - "ccusage@^20.0.19", + "ccusage@^20.0.20", "codex", "daily", "--json", @@ -42,7 +42,7 @@ describe("ccusage commands", () => { "calculate", ]); expect(sessionCcusageCommand(codex)).toEqual([ - "ccusage@^20.0.19", + "ccusage@^20.0.20", "codex", "session", "--json", @@ -53,7 +53,7 @@ describe("ccusage commands", () => { it("builds focused Pi daily and session commands", () => { expect(dailyCcusageCommand(pi)).toEqual([ - "ccusage@^20.0.19", + "ccusage@^20.0.20", "pi", "daily", "--json", @@ -62,7 +62,7 @@ describe("ccusage commands", () => { "calculate", ]); expect(sessionCcusageCommand(pi)).toEqual([ - "ccusage@^20.0.19", + "ccusage@^20.0.20", "pi", "session", "--json", @@ -73,7 +73,7 @@ describe("ccusage commands", () => { it("builds focused Hermes daily and session commands", () => { expect(dailyCcusageCommand(hermes)).toEqual([ - "ccusage@^20.0.19", + "ccusage@^20.0.20", "hermes", "daily", "--json", @@ -82,7 +82,7 @@ describe("ccusage commands", () => { "calculate", ]); expect(sessionCcusageCommand(hermes)).toEqual([ - "ccusage@^20.0.19", + "ccusage@^20.0.20", "hermes", "session", "--json", @@ -95,15 +95,15 @@ describe("ccusage commands", () => { describe("ccusageCommandInvocations", () => { it("selects the Windows npm command shim", () => { expect(ccusageCommandInvocations(["codex", "daily"], "win32")).toEqual([ - { args: ["x", "ccusage@^20.0.19", "codex", "daily"], command: "bun" }, - { args: ["-y", "ccusage@^20.0.19", "codex", "daily"], command: "npx.cmd" }, + { args: ["x", "ccusage@^20.0.20", "codex", "daily"], command: "bun" }, + { args: ["-y", "ccusage@^20.0.20", "codex", "daily"], command: "npx.cmd" }, ]); }); it("keeps the POSIX npm fallback", () => { expect(ccusageCommandInvocations(["codex", "daily"], "linux")).toEqual([ - { args: ["x", "ccusage@^20.0.19", "codex", "daily"], command: "bun" }, - { args: ["-y", "ccusage@^20.0.19", "codex", "daily"], command: "npx" }, + { args: ["x", "ccusage@^20.0.20", "codex", "daily"], command: "bun" }, + { args: ["-y", "ccusage@^20.0.20", "codex", "daily"], command: "npx" }, ]); }); }); @@ -118,7 +118,7 @@ describe("execCcusage", () => { ), ).resolves.toBe('{"daily":[]}'); expect(run).toHaveBeenCalledOnce(); - expect(run).toHaveBeenCalledWith("bun", ["x", "ccusage@^20.0.19", "codex", "daily"]); + expect(run).toHaveBeenCalledWith("bun", ["x", "ccusage@^20.0.20", "codex", "daily"]); }); it("falls back to npx.cmd when Bun is missing on Windows", async () => { @@ -138,8 +138,8 @@ describe("execCcusage", () => { execCcusage(["codex", "daily"], "codex", "daily", { platform: "win32", run }), ), ).resolves.toBe('{"daily":[]}'); - expect(run).toHaveBeenNthCalledWith(1, "bun", ["x", "ccusage@^20.0.19", "codex", "daily"]); - expect(run).toHaveBeenNthCalledWith(2, "npx.cmd", ["-y", "ccusage@^20.0.19", "codex", "daily"]); + expect(run).toHaveBeenNthCalledWith(1, "bun", ["x", "ccusage@^20.0.20", "codex", "daily"]); + expect(run).toHaveBeenNthCalledWith(2, "npx.cmd", ["-y", "ccusage@^20.0.20", "codex", "daily"]); }); it("does not mask a Bun execution failure with the npm fallback", async () => { diff --git a/apps/cli/src/ccusage/runner.ts b/apps/cli/src/ccusage/runner.ts index ed5cc62..6865248 100644 --- a/apps/cli/src/ccusage/runner.ts +++ b/apps/cli/src/ccusage/runner.ts @@ -7,12 +7,12 @@ import { decodeDailyReport, decodeSessionReport } from "./schema"; import type { CcusageSource } from "./sources"; /** - * Shells out to `bun x ccusage@^20.0.19 daily --json --breakdown` (npx + * Shells out to `bun x ccusage@^20.0.20 daily --json --breakdown` (npx * fallback only when bun itself is missing). Runner and report failures stay * typed so the sync layer can distinguish them from valid empty reports. */ -const CCUSAGE_SPEC = "ccusage@^20.0.19"; +const CCUSAGE_SPEC = "ccusage@^20.0.20"; const RUN_TIMEOUT_MS = 180_000; class CcusageRunError extends Data.TaggedError("CcusageRunError")<{ @@ -124,7 +124,7 @@ function dailyCcusageArgs(source: CcusageSource, options: RunOptions = {}): stri args.push("--since", options.since.replaceAll("-", "")); } - return args; + return args.filter((arg): arg is string => arg !== undefined); } function sessionCcusageArgs(source: CcusageSource, options: RunOptions = {}): string[] { @@ -133,7 +133,7 @@ function sessionCcusageArgs(source: CcusageSource, options: RunOptions = {}): st args.push("--since", options.since.replaceAll("-", "")); } - return args; + return args.filter((arg): arg is string => arg !== undefined); } function execCcusage( diff --git a/apps/cli/src/ccusage/sources.test.ts b/apps/cli/src/ccusage/sources.test.ts index 567a5df..46973cd 100644 --- a/apps/cli/src/ccusage/sources.test.ts +++ b/apps/cli/src/ccusage/sources.test.ts @@ -24,6 +24,21 @@ describe("resolveSources", () => { }); }); + it("resolves Grok to the focused ccusage subcommand", () => { + expect(resolveSources(["grok"])).toEqual({ + invalid: [], + sources: [{ source: "grok", subcommand: "grok" }], + }); + }); + + it("resolves Supercharge as a native (subcommand-less) source", () => { + const { invalid, sources } = resolveSources(["supercharge"]); + + expect(invalid).toEqual([]); + expect(sources[0]?.source).toBe("supercharge"); + expect(sources[0]?.subcommand).toBeUndefined(); + }); + it("rejects unknown sources", () => { expect(resolveSources(["bogus"]).invalid).toEqual(["bogus"]); }); diff --git a/apps/cli/src/ccusage/sources.ts b/apps/cli/src/ccusage/sources.ts index 4485bfd..f8e7c1a 100644 --- a/apps/cli/src/ccusage/sources.ts +++ b/apps/cli/src/ccusage/sources.ts @@ -6,8 +6,8 @@ */ interface CcusageSource { - /** ccusage subcommand. */ - subcommand: string; + /** ccusage subcommand; absent for sources collected natively (supercharge). */ + subcommand?: string; /** The source tag stored server-side and shown on profiles. */ source: string; } @@ -20,6 +20,16 @@ const CCUSAGE_SOURCES: readonly CcusageSource[] = [ { source: "copilot", subcommand: "copilot" }, { source: "hermes", subcommand: "hermes" }, { source: "pi", subcommand: "pi" }, + { source: "supercharge" }, + { source: "grok", subcommand: "grok" }, + { source: "amp", subcommand: "amp" }, + { source: "qwen", subcommand: "qwen" }, + { source: "kimi", subcommand: "kimi" }, + { source: "kilo", subcommand: "kilo" }, + { source: "goose", subcommand: "goose" }, + { source: "droid", subcommand: "droid" }, + { source: "codebuff", subcommand: "codebuff" }, + { source: "openclaw", subcommand: "openclaw" }, ]; const DEFAULT_SOURCE_NAMES = CCUSAGE_SOURCES.map((entry) => entry.source); diff --git a/apps/cli/src/ccusage/supercharge.test.ts b/apps/cli/src/ccusage/supercharge.test.ts new file mode 100644 index 0000000..f73db3e --- /dev/null +++ b/apps/cli/src/ccusage/supercharge.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { collectSuperchargeDays, superchargeDailyReport } from "./supercharge"; + +function turn( + sessionId: string, + eventId: string, + timestampSec: number, + model: string, + tokens: Record, +): string { + return JSON.stringify({ + timestamp: timestampSec, + method: "_x.ai/session/update", + params: { + sessionId, + update: { + sessionUpdate: "turn_completed", + usage: { + inputTokens: tokens.input ?? 0, + outputTokens: tokens.output ?? 0, + cachedReadTokens: tokens.cachedRead ?? 0, + cacheCreationTokens: tokens.cacheCreation ?? 0, + reasoningTokens: tokens.reasoning ?? 0, + costUsdTicks: Math.round((tokens.costUsd ?? 0) * 1e10), + modelUsage: { + [model]: { + inputTokens: tokens.input ?? 0, + outputTokens: tokens.output ?? 0, + totalTokens: (tokens.input ?? 0) + (tokens.output ?? 0), + cachedReadTokens: tokens.cachedRead ?? 0, + cacheCreationTokens: tokens.cacheCreation ?? 0, + reasoningTokens: tokens.reasoning ?? 0, + costUsdTicks: Math.round((tokens.costUsd ?? 0) * 1e10), + }, + }, + }, + }, + }, + _meta: { eventId }, + }); +} + +describe("collectSuperchargeDays", () => { + it("parses turn_completed records across sessions into per-model days", async () => { + const listSessionFiles = () => + Effect.succeed([ + "/root/sessions/proj-a/s1/updates.jsonl", + "/root/sessions/proj-b/s2/updates.jsonl", + ]); + const readUpdates = (path: string) => + Effect.succeed( + path.includes("proj-a/s1") + ? [ + turn("s1", "e1", 1750000000, "gpt-5.6-luna", { + input: 100, + output: 20, + costUsd: 0.01, + }), + turn("s1", "e2", 1750000000, "gpt-5.6-luna", { + input: 50, + output: 5, + costUsd: 0.005, + }), + ].join("\n") + : turn("s2", "e3", 1750086400, "grok-4.6-build", { + input: 200, + output: 30, + cachedRead: 100, + costUsd: 0.02, + }), + ); + + const result = await Effect.runPromise( + collectSuperchargeDays({ listSessionFiles, readUpdates }), + ); + + expect(result.sessions).toBe(2); + expect(result.days).toHaveLength(3); + expect(result.days[0]?.model).toBe("gpt-5.6-luna"); + expect(result.days[0]?.usage.costUsd).toBeCloseTo(0.01); + }); + + it("skips records with no usage and dedupes repeated turns", async () => { + const line = turn("s1", "e1", 1750000000, "gpt-5.6-luna", { + input: 100, + output: 20, + costUsd: 0.01, + }); + const otherTurn = turn("s1", "e2", 1750000000, "gpt-5.6-luna", { + input: 100, + output: 20, + costUsd: 0.01, + }); + const cacheOnly = turn("s1", "e3", 1750000000, "gpt-5.6-luna", { + cacheCreation: 10, + }); + const listSessionFiles = () => Effect.succeed(["/root/sessions/p/s1/updates.jsonl"]); + const readUpdates = () => Effect.succeed([line, line, otherTurn, cacheOnly].join("\n")); + + const result = await Effect.runPromise( + collectSuperchargeDays({ listSessionFiles, readUpdates }), + ); + expect(result.days).toHaveLength(3); + expect(result.days.reduce((total, day) => total + day.usage.cacheCreationTokens, 0)).toBe(10); + }); + + it("treats a missing sessions directory as empty", async () => { + const root = await mkdtemp(`${tmpdir()}/tokenmaxxing-supercharge-`); + const previous = process.env.SUPERCHARGE_HOME; + process.env.SUPERCHARGE_HOME = root; + try { + const result = await Effect.runPromise(collectSuperchargeDays()); + expect(result).toEqual({ days: [], sessions: 0 }); + } finally { + if (previous === undefined) delete process.env.SUPERCHARGE_HOME; + else process.env.SUPERCHARGE_HOME = previous; + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("superchargeDailyReport", () => { + it("aggregates days by date+model", () => { + const report = superchargeDailyReport([ + { + date: "2026-08-06", + model: "gpt-5.6-luna", + usage: { + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 0, + costUsd: 0.01, + reasoningTokens: 0, + }, + }, + { + date: "2026-08-06", + model: "gpt-5.6-luna", + usage: { + inputTokens: 50, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + costUsd: 0.005, + reasoningTokens: 0, + }, + }, + { + date: "2026-08-07", + model: "grok-4.6-build", + usage: { + inputTokens: 200, + outputTokens: 30, + cacheCreationTokens: 0, + cacheReadTokens: 100, + costUsd: 0.02, + reasoningTokens: 0, + }, + }, + ]); + + expect(report.daily).toHaveLength(2); + const luna = report.daily.find((d) => d.date === "2026-08-06"); + expect(luna?.inputTokens).toBe(150); + expect(luna?.outputTokens).toBe(25); + expect(luna?.totalCost).toBeCloseTo(0.015); + expect(luna?.totalTokens).toBe(175); + expect(luna?.modelBreakdowns?.[0]?.modelName).toBe("gpt-5.6-luna"); + expect(luna?.modelBreakdowns?.[0]?.cost).toBeCloseTo(0.015); + }); +}); diff --git a/apps/cli/src/ccusage/supercharge.ts b/apps/cli/src/ccusage/supercharge.ts new file mode 100644 index 0000000..57472ef --- /dev/null +++ b/apps/cli/src/ccusage/supercharge.ts @@ -0,0 +1,260 @@ +import { readdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { Data, Effect } from "effect"; + +import type { CcusageDay } from "./schema"; + +/** + * Native collector for Supercharge sessions. Supercharge records completed + * turns as `turn_completed` entries in `sessions///updates.jsonl` + * under the Supercharge data home, with per-model usage in `modelUsage`. + * + * Only records carrying usage are counted; everything is aggregated into the + * shared ccusage daily-report shape so the rest of the sync pipeline is + * unchanged. This source is collected directly, not through ccusage. + */ + +const COST_USD_TICKS_PER_USD = 1e10; + +export interface SuperchargeModelUsage { + cacheCreationTokens: number; + cacheReadTokens: number; + costUsd: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; +} + +export class SuperchargeCollectError extends Data.TaggedError("SuperchargeCollectError")<{ + readonly cause: unknown; +}> {} + +interface SuperchargeOptions { + /** Injectable for tests. */ + listSessionFiles?: () => Effect.Effect; + readUpdates?: (path: string) => Effect.Effect; +} + +function superchargeDataHome(env: NodeJS.ProcessEnv = process.env): string { + const override = env.SUPERCHARGE_HOME?.trim(); + return override !== undefined && override !== "" ? override : join(homedir(), ".supercharge"); +} + +/** Recursively collect every `updates.jsonl` under the sessions root. */ +function sessionFiles(root: string): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => new SuperchargeCollectError({ cause }), + try: async () => { + const files: string[] = []; + const walk = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + } else if (entry.isFile() && entry.name === "updates.jsonl") { + files.push(path); + } + } + }; + try { + await walk(root); + } catch (error) { + if (!isEnoent(error)) throw error; + } + return files; + }, + }); +} + +function isEnoent(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} + +function readUpdates(path: string): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => new SuperchargeCollectError({ cause }), + try: () => readFile(path, "utf8"), + }); +} + +interface ParsedDay { + date: string; + model: string; + usage: SuperchargeModelUsage; +} + +interface CollectResult { + days: ParsedDay[]; + sessions: number; +} + +function parseUpdates(content: string): ParsedDay[] { + const days: ParsedDay[] = []; + const seen = new Set(); + for (const line of content.split("\n")) { + if (!line.includes('"turn_completed"')) continue; + let record: any; + try { + record = JSON.parse(line); + } catch { + continue; + } + const params = record?.params; + const update = params?.update; + const usage = update?.usage; + if (update?.sessionUpdate !== "turn_completed" || usage === undefined) continue; + + const timestampMs = + typeof params?._meta?.agentTimestampMs === "number" && params._meta.agentTimestampMs > 0 + ? params._meta.agentTimestampMs + : typeof record?.timestamp === "number" && record.timestamp > 0 + ? record.timestamp * 1000 + : 0; + if (timestampMs === 0) continue; + const dateObj = new Date(timestampMs); + const date = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}-${String(dateObj.getDate()).padStart(2, "0")}`; + + for (const entry of modelUsageRows(usage)) { + const dedupeKey = [ + params?.sessionId ?? "", + params?._meta?.eventId ?? record?._meta?.eventId ?? "", + timestampMs, + entry.model, + entry.usage.inputTokens, + entry.usage.outputTokens, + ].join("|"); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + days.push({ date, model: entry.model, usage: entry.usage }); + } + } + return days; +} + +function modelUsageRows(usage: any): { model: string; usage: SuperchargeModelUsage }[] { + const map = usage?.modelUsage; + if (map === undefined || map === null || typeof map !== "object") return []; + return Object.entries(map) + .filter(([model, entry]: [string, any]) => model.trim() !== "" && hasTokens(entry)) + .map(([model, entry]: [string, any]) => ({ + model, + usage: { + cacheCreationTokens: entry.cacheCreationTokens ?? 0, + cacheReadTokens: entry.cachedReadTokens ?? 0, + costUsd: (entry.costUsdTicks ?? 0) / COST_USD_TICKS_PER_USD, + inputTokens: entry.inputTokens ?? 0, + outputTokens: entry.outputTokens ?? 0, + reasoningTokens: entry.reasoningTokens ?? 0, + }, + })); +} + +function hasTokens(entry: any): boolean { + return ( + (entry?.inputTokens ?? 0) > 0 || + (entry?.outputTokens ?? 0) > 0 || + (entry?.cachedReadTokens ?? 0) > 0 || + (entry?.cacheCreationTokens ?? 0) > 0 + ); +} + +function collectSuperchargeDays( + options: SuperchargeOptions = {}, +): Effect.Effect { + const root = superchargeDataHome(); + const sessionsRoot = join(root, "sessions"); + const listSessionFiles = options.listSessionFiles ?? (() => sessionFiles(sessionsRoot)); + const readUpdatesFor = options.readUpdates ?? readUpdates; + + return listSessionFiles().pipe( + Effect.flatMap((files) => Effect.forEach(files, (file) => readUpdatesFor(file))), + Effect.map((contents) => { + const days = contents.flatMap(parseUpdates); + const sessions = new Set(); + for (const content of contents) { + for (const line of content.split("\n")) { + if (!line.includes('"turn_completed"')) continue; + try { + const sessionId = JSON.parse(line)?.params?.sessionId; + if (sessionId) sessions.add(sessionId); + } catch { + // skip malformed lines + } + } + } + return { days, sessions: sessions.size }; + }), + ); +} + +/** Aggregate parsed turns into the ccusage daily-report shape (one row per date+model). */ +function superchargeDailyReport(days: readonly ParsedDay[]): { daily: CcusageDay[] } { + const byKey = new Map(); + for (const { date, model, usage } of days) { + const key = `${date}|${model}`; + const existing = byKey.get(key); + if (existing === undefined) { + byKey.set(key, { + cacheCreationTokens: usage.cacheCreationTokens, + cacheReadTokens: usage.cacheReadTokens, + costUSD: usage.costUsd, + date, + inputTokens: usage.inputTokens, + modelBreakdowns: [ + { + cacheCreationTokens: usage.cacheCreationTokens, + cacheReadTokens: usage.cacheReadTokens, + cost: usage.costUsd, + inputTokens: usage.inputTokens, + modelName: model, + outputTokens: usage.outputTokens, + }, + ], + modelsUsed: [model], + outputTokens: usage.outputTokens, + totalCost: usage.costUsd, + totalTokens: + usage.inputTokens + + usage.outputTokens + + usage.cacheCreationTokens + + usage.cacheReadTokens, + }); + continue; + } + const prev = existing.modelBreakdowns?.[0]; + byKey.set(key, { + ...existing, + inputTokens: (existing.inputTokens ?? 0) + usage.inputTokens, + outputTokens: (existing.outputTokens ?? 0) + usage.outputTokens, + cacheCreationTokens: (existing.cacheCreationTokens ?? 0) + usage.cacheCreationTokens, + cacheReadTokens: (existing.cacheReadTokens ?? 0) + usage.cacheReadTokens, + totalCost: (existing.totalCost ?? 0) + usage.costUsd, + costUSD: (existing.costUSD ?? 0) + usage.costUsd, + totalTokens: + (existing.totalTokens ?? 0) + + usage.inputTokens + + usage.outputTokens + + usage.cacheCreationTokens + + usage.cacheReadTokens, + modelBreakdowns: + prev === undefined + ? undefined + : [ + { + cacheCreationTokens: (prev.cacheCreationTokens ?? 0) + usage.cacheCreationTokens, + cacheReadTokens: (prev.cacheReadTokens ?? 0) + usage.cacheReadTokens, + cost: (prev.cost ?? 0) + usage.costUsd, + inputTokens: (prev.inputTokens ?? 0) + usage.inputTokens, + modelName: prev.modelName, + outputTokens: (prev.outputTokens ?? 0) + usage.outputTokens, + }, + ], + }); + } + return { daily: [...byKey.values()].sort((a, b) => a.date.localeCompare(b.date)) }; +} + +export { collectSuperchargeDays, superchargeDailyReport }; +export type { ParsedDay }; diff --git a/apps/cli/src/commands/service.ts b/apps/cli/src/commands/service.ts index 84cce4e..b19041e 100644 --- a/apps/cli/src/commands/service.ts +++ b/apps/cli/src/commands/service.ts @@ -4205,6 +4205,16 @@ function capturedServiceEnv( "LOCALAPPDATA", "APPDATA", "HERMES_HOME", + "GROK_HOME", + "SUPERCHARGE_HOME", + "AMP_DATA_DIR", + "QWEN_DATA_DIR", + "KIMI_DATA_DIR", + "KILO_DATA_DIR", + "GOOSE_PATH_ROOT", + "DROID_SESSIONS_DIR", + "CODEBUFF_DATA_DIR", + "OPENCLAW_DIR", "TOKENMAXXING_CONFIG_DIR", "TOKENMAXXING_ENV", "TOKENMAXXING_API_URL", diff --git a/apps/cli/src/commands/sync.ts b/apps/cli/src/commands/sync.ts index 61423f3..44ba276 100644 --- a/apps/cli/src/commands/sync.ts +++ b/apps/cli/src/commands/sync.ts @@ -11,6 +11,7 @@ import type { import packageJson from "../../package.json"; import { aggregateDays, summarize, type SourceSummary } from "../ccusage/aggregate"; +import type { CcusageDailyReport } from "../ccusage/schema"; import { type CcusageReportKind, CcusageRunError, @@ -20,6 +21,7 @@ import { runCcusageSessionReport, } from "../ccusage/runner"; import { DEFAULT_SOURCE_NAMES, resolveSources } from "../ccusage/sources"; +import { collectSuperchargeDays, superchargeDailyReport } from "../ccusage/supercharge"; import { ApiClientService, BrowserService, @@ -126,6 +128,7 @@ interface SyncProgramOptions extends SyncOptions { interface SyncProgramRuntime { runDailyReport?: typeof runCcusageDailyReport | undefined; runSessionReport?: typeof runCcusageSessionReport | undefined; + runSuperchargeDaily?: typeof collectSuperchargeDays | undefined; } interface ResolveSyncAuthOptions { @@ -157,6 +160,10 @@ type SyncSourceResult = | { source: string; status: "skipped"; summary: null; reason: "no_data" } | { source: string; status: "synced"; summary: SyncSourceSummary }; +type SyncDailyOutcome = + | { error: CcusageRunError; _tag: "failure" } + | { _tag: "success"; report: CcusageDailyReport; sessions: number | null }; + type SyncStatus = "error" | "ok" | "partial"; interface SyncResult { @@ -255,6 +262,7 @@ function syncProgram(options: SyncProgramOptions, runtime: SyncProgramRuntime = return Effect.gen(function* () { const runDailyReport = runtime.runDailyReport ?? runCcusageDailyReport; const runSessionReport = runtime.runSessionReport ?? runCcusageSessionReport; + const runSuperchargeDaily = runtime.runSuperchargeDaily ?? collectSuperchargeDays; const requested = options.sources?.split(",") ?? DEFAULT_SOURCE_NAMES; const { invalid, sources } = resolveSources(requested); if (invalid.length > 0) { @@ -272,16 +280,39 @@ function syncProgram(options: SyncProgramOptions, runtime: SyncProgramRuntime = const renderInlineResults = shouldRenderInlineSync(options); for (const source of sources) { const spinner = yield* humanSpinner(`Syncing ${source.source}`, options); - const dailyResult = yield* runDailyReport(source, { since: options.since }).pipe( - Effect.match({ - onFailure: (error) => ({ error, _tag: "failure" as const }), - onSuccess: (report) => ({ report, _tag: "success" as const }), - }), - ); - - if (dailyResult._tag === "failure") { + const isSupercharge = source.source === "supercharge"; + // supercharge is collected natively (its sessions are not read through ccusage); + // every other source shells out to ccusage's focused subcommand. + const dailyOutcome: SyncDailyOutcome = isSupercharge + ? yield* runSuperchargeDaily().pipe( + Effect.map((collected) => ({ + report: superchargeDailyReport(collected.days), + sessions: collected.sessions, + })), + Effect.mapError( + (error) => + new CcusageRunError({ + cause: error, + code: "command_failed", + report: "daily", + source: source.source, + }), + ), + Effect.match({ + onFailure: (error) => ({ error, _tag: "failure" as const }), + onSuccess: (value) => ({ ...value, _tag: "success" as const }), + }), + ) + : yield* runDailyReport(source, { since: options.since }).pipe( + Effect.match({ + onFailure: (error) => ({ error, _tag: "failure" as const }), + onSuccess: (report) => ({ report, sessions: null, _tag: "success" as const }), + }), + ); + + if (dailyOutcome._tag === "failure") { const result = { - issue: syncSourceIssue(dailyResult.error), + issue: syncSourceIssue(dailyOutcome.error), source: source.source, status: "failed" as const, summary: null, @@ -294,7 +325,7 @@ function syncProgram(options: SyncProgramOptions, runtime: SyncProgramRuntime = continue; } - const dailyReport = dailyResult.report; + const dailyReport = dailyOutcome.report; if (dailyReport.daily.length === 0) { const result = { reason: "no_data" as const, @@ -310,30 +341,41 @@ function syncProgram(options: SyncProgramOptions, runtime: SyncProgramRuntime = const sourceRows = aggregateDays(source.source, dailyReport.daily); rawReports.push({ - command: dailyCcusageCommand(source, { since: options.since }), + command: isSupercharge + ? ["supercharge-native-collector"] + : dailyCcusageCommand(source, { since: options.since }), payload: dailyReport, reportKind: "daily", source: source.source, }); - const sessionResult = yield* runSessionReport(source, { since: options.since }).pipe( - Effect.match({ - onFailure: (error) => ({ error, _tag: "failure" as const }), - onSuccess: (report) => ({ report, _tag: "success" as const }), - }), - ); - const sessionCount = - sessionResult._tag === "success" ? sessionResult.report.sessions.length : null; - const summary = { ...summarize(sourceRows), sessions: sessionCount }; + // Native supercharge collection reports a real session count; ccusage + // sources report a session count from the focused session report. + const sessionOutcome = + dailyOutcome.sessions !== null + ? { sessionCount: dailyOutcome.sessions, issue: null as SyncSourceIssue | null } + : yield* runSessionReport(source, { since: options.since }).pipe( + Effect.match({ + onFailure: (error) => ({ + issue: syncSourceIssue(error), + sessionCount: null as number | null, + }), + onSuccess: (report) => ({ + issue: null as SyncSourceIssue | null, + sessionCount: report.sessions.length, + }), + }), + ); + const summary = { ...summarize(sourceRows), sessions: sessionOutcome.sessionCount }; const result: SyncSourceResult = - sessionResult._tag === "failure" - ? { - issue: syncSourceIssue(sessionResult.error), + sessionOutcome.issue === null + ? { source: source.source, status: "synced", summary } + : { + issue: sessionOutcome.issue, source: source.source, status: "partial", summary, - } - : { source: source.source, status: "synced", summary }; + }; sourceSummaries[source.source] = summary; sourceResults.push(result); rows.push(...sourceRows); diff --git a/apps/www/src/routes/index.tsx b/apps/www/src/routes/index.tsx index 6f8a18d..a5ac625 100644 --- a/apps/www/src/routes/index.tsx +++ b/apps/www/src/routes/index.tsx @@ -148,11 +148,13 @@ const FAQ_ITEMS: { answer: ReactNode; answerText: string; question: string }[] = > ccusage {" "} - to parse local usage from Claude Code, Codex, OpenCode, Gemini CLI, and Copilot CLI. + to parse local usage from Claude Code, Codex, OpenCode, Gemini CLI, Copilot CLI, Hermes, Pi, + Grok Build CLI, Amp, Qwen Code, Kimi, Kilo Code, Goose, Droid, Codebuff, and OpenClaw. + Supercharge is collected natively from its own local session store. ), answerText: - "tokenmaxxing uses ccusage to parse local usage from Claude Code, Codex, OpenCode, Gemini CLI, and Copilot CLI.", + "tokenmaxxing uses ccusage to parse local usage from Claude Code, Codex, OpenCode, Gemini CLI, Copilot CLI, Hermes, Pi, Grok Build CLI, Amp, Qwen Code, Kimi, Kilo Code, Goose, Droid, Codebuff, and OpenClaw. Supercharge is collected natively from its own local session store.", }, { question: "What data gets uploaded?", diff --git a/apps/www/src/routes/privacy.tsx b/apps/www/src/routes/privacy.tsx index 3c2870c..6ee53b4 100644 --- a/apps/www/src/routes/privacy.tsx +++ b/apps/www/src/routes/privacy.tsx @@ -49,10 +49,12 @@ function PrivacyPage() {
The CLI uses ccusage to parse usage - locally from supported coding agents (Claude Code, Codex, OpenCode, Gemini CLI, and - Copilot CLI). It only reads usage data that still exists on your computer; if an agent has - already cleaned up its local logs, that data cannot be recovered or uploaded. You can - preview exactly what would be sent with tokenmaxxing sync --dry-run. + locally from supported coding agents (Claude Code, Codex, OpenCode, Gemini CLI, Copilot + CLI, Hermes, Pi, Grok Build CLI, Amp, Qwen Code, Kimi, Kilo Code, Goose, Droid, Codebuff, + and OpenClaw). Supercharge is collected natively from its own local session store. It only + reads usage data that still exists on your computer; if an agent has already cleaned up + its local logs, that data cannot be recovered or uploaded. You can preview exactly what + would be sent with tokenmaxxing sync --dry-run.