From 6dd1491c75e257c682b2ba5e28521c43f778c9ea Mon Sep 17 00:00:00 2001 From: NIHAL NIHALANI Date: Sat, 4 Apr 2026 15:50:36 -0700 Subject: [PATCH 1/2] feat: add Gemini CLI usage tracking via gemistat Integrate Gemini CLI as a third usage source alongside Claude Code (ccusage) and Codex (@ccusage/codex). Uses gemistat by ryoppippi (same author as ccusage) as a subprocess, following the identical pattern established by the Codex integration. - Create gemini.ts with gemistat subprocess runner and JSON parser - Extend mergeEntries() to 3-way merge (Claude + Codex + Gemini) - Run all three sources in parallel via Promise.all - Add Gemini model colors (Google brand palette) to CLI theme - Add Gemini prettifyModel rules to all 5 web/CLI copies - Fix preview/experimental suffix stripping (-preview-05-06, -exp-03-25) - Add Gemini colors to web modelColor() with 8 model entries - Add 20 new tests (gemini parser, merge, push integration) - Silent failure: users without Gemini CLI are unaffected - Pin gemistat@1 for version stability - Convert YYYYMMDD dates to YYYY-MM-DD (gemistat's expected format) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/__tests__/unit/prettify-model.test.ts | 35 +- apps/web/components/app/feed/ActivityCard.tsx | 21 + apps/web/lib/open-stats.ts | 12 + apps/web/lib/share-assets/post-card-image.tsx | 12 + apps/web/lib/utils/post-share.ts | 12 + docs/CHANGELOG.md | 6 + docs/DECISIONS.md | 14 + .../2026-04-04-gemini-cli-integration.md | 961 ++++++++++++++++++ packages/cli/__tests__/commands/push.test.ts | 106 +- .../cli/__tests__/flows/cli-sync-flow.test.ts | 36 +- packages/cli/__tests__/gemini.test.ts | 174 ++++ packages/cli/__tests__/merge.test.ts | 107 ++ packages/cli/src/commands/push.ts | 62 +- packages/cli/src/components/ModelPalette.tsx | 14 + packages/cli/src/components/theme.ts | 10 +- packages/cli/src/lib/ccusage.ts | 2 +- packages/cli/src/lib/gemini.ts | 198 ++++ packages/cli/src/lib/token-normalization.ts | 2 +- 18 files changed, 1750 insertions(+), 34 deletions(-) create mode 100644 docs/plans/2026-04-04-gemini-cli-integration.md create mode 100644 packages/cli/__tests__/gemini.test.ts create mode 100644 packages/cli/__tests__/merge.test.ts create mode 100644 packages/cli/src/lib/gemini.ts diff --git a/apps/web/__tests__/unit/prettify-model.test.ts b/apps/web/__tests__/unit/prettify-model.test.ts index de32aaf2..85ca5b06 100644 --- a/apps/web/__tests__/unit/prettify-model.test.ts +++ b/apps/web/__tests__/unit/prettify-model.test.ts @@ -65,9 +65,42 @@ describe("prettifyModel", () => { }); }); + describe("Gemini models", () => { + it("prettifies gemini-3.1-pro-preview", () => { + expect(prettifyModel("gemini-3.1-pro-preview")).toBe("Gemini 3.1 Pro"); + }); + + it("prettifies gemini-3.1-flash-lite-preview", () => { + expect(prettifyModel("gemini-3.1-flash-lite-preview")).toBe("Gemini 3.1 Flash Lite"); + }); + + it("prettifies gemini-3-flash-preview", () => { + expect(prettifyModel("gemini-3-flash-preview")).toBe("Gemini 3 Flash"); + }); + + it("prettifies gemini-2.5-pro", () => { + expect(prettifyModel("gemini-2.5-pro")).toBe("Gemini 2.5 Pro"); + }); + + it("prettifies gemini-2.5-flash", () => { + expect(prettifyModel("gemini-2.5-flash")).toBe("Gemini 2.5 Flash"); + }); + + it("prettifies gemini-2.5-flash-lite", () => { + expect(prettifyModel("gemini-2.5-flash-lite")).toBe("Gemini 2.5 Flash Lite"); + }); + + it("prettifies gemini-2.0-flash", () => { + expect(prettifyModel("gemini-2.0-flash")).toBe("Gemini 2.0 Flash"); + }); + + it("prettifies gemini-2.0-flash-lite", () => { + expect(prettifyModel("gemini-2.0-flash-lite")).toBe("Gemini 2.0 Flash Lite"); + }); + }); + describe("unknown models", () => { it("returns the model name as-is for unrecognized models", () => { - expect(prettifyModel("gemini-2.0-flash")).toBe("gemini-2.0-flash"); expect(prettifyModel("qwen-2.5-coder")).toBe("qwen-2.5-coder"); expect(prettifyModel("mistral-large")).toBe("mistral-large"); }); diff --git a/apps/web/components/app/feed/ActivityCard.tsx b/apps/web/components/app/feed/ActivityCard.tsx index d145a832..22a0253f 100644 --- a/apps/web/components/app/feed/ActivityCard.tsx +++ b/apps/web/components/app/feed/ActivityCard.tsx @@ -67,6 +67,18 @@ export function prettifyModel(model: string): string { } if (/^o4/i.test(normalized)) return "o4"; if (/^o3/i.test(normalized)) return "o3"; + // Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro" + if (/^gemini-/i.test(normalized)) { + return normalized + .replace(/^gemini-/i, "Gemini ") + .replace(/-preview.*$/, "") + .replace(/-exp.*$/, "") + .replace(/-/g, " ") + .replace(/\s+/g, " ") + .split(" ") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + } // Legacy: broader Claude matching if (normalized.includes("opus")) return "Claude Opus"; if (normalized.includes("sonnet")) return "Claude Sonnet"; @@ -171,6 +183,15 @@ function modelColor(name: string): string { if (/GPT-4o/.test(name)) return "#4C78A8"; if (/^o3/i.test(name)) return "#3B82F6"; if (/^o4/i.test(name)) return "#6366F1"; + if (/Gemini 3\.1 Pro/.test(name)) return "#4285F4"; + if (/Gemini 3\.1 Flash Lite/.test(name)) return "#00ACC1"; + if (/Gemini 3 Flash/.test(name)) return "#009688"; + if (/Gemini 2\.5 Pro/.test(name)) return "#3F51B5"; + if (/Gemini 2\.5 Flash Lite/.test(name)) return "#8BC34A"; + if (/Gemini 2\.5 Flash/.test(name)) return "#34A853"; + if (/Gemini 2\.0 Flash Lite/.test(name)) return "#FF8F00"; + if (/Gemini 2\.0 Flash/.test(name)) return "#FBBC05"; + if (/Gemini/i.test(name)) return "#3F51B5"; // Google indigo fallback const palette = ["#EF4444", "#F59E0B", "#10B981", "#06B6D4", "#8B5CF6", "#EC4899"]; return palette[hashString(name) % palette.length]!; diff --git a/apps/web/lib/open-stats.ts b/apps/web/lib/open-stats.ts index e40646be..931082c4 100644 --- a/apps/web/lib/open-stats.ts +++ b/apps/web/lib/open-stats.ts @@ -173,6 +173,18 @@ export function prettifyModel(model: string): string { } if (/^o4/i.test(normalized)) return "o4"; if (/^o3/i.test(normalized)) return "o3"; + // Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro" + if (/^gemini-/i.test(normalized)) { + return normalized + .replace(/^gemini-/i, "Gemini ") + .replace(/-preview.*$/, "") + .replace(/-exp.*$/, "") + .replace(/-/g, " ") + .replace(/\s+/g, " ") + .split(" ") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + } if (normalized.includes("opus")) return "Claude Opus"; if (normalized.includes("sonnet")) return "Claude Sonnet"; if (normalized.includes("haiku")) return "Claude Haiku"; diff --git a/apps/web/lib/share-assets/post-card-image.tsx b/apps/web/lib/share-assets/post-card-image.tsx index fb484458..a6f44199 100644 --- a/apps/web/lib/share-assets/post-card-image.tsx +++ b/apps/web/lib/share-assets/post-card-image.tsx @@ -51,6 +51,18 @@ function prettifyModel(model: string): string { } if (/^o4/i.test(normalized)) return "o4"; if (/^o3/i.test(normalized)) return "o3"; + // Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro" + if (/^gemini-/i.test(normalized)) { + return normalized + .replace(/^gemini-/i, "Gemini ") + .replace(/-preview.*$/, "") + .replace(/-exp.*$/, "") + .replace(/-/g, " ") + .replace(/\s+/g, " ") + .split(" ") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + } return normalized; } diff --git a/apps/web/lib/utils/post-share.ts b/apps/web/lib/utils/post-share.ts index 2a30c3a7..59e19503 100644 --- a/apps/web/lib/utils/post-share.ts +++ b/apps/web/lib/utils/post-share.ts @@ -23,6 +23,18 @@ function prettifyModel(model: string): string { if (/^o4/i.test(normalized)) return "o4"; if (/^o3/i.test(normalized)) return "o3"; + // Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro" + if (/^gemini-/i.test(normalized)) { + return normalized + .replace(/^gemini-/i, "Gemini ") + .replace(/-preview.*$/, "") + .replace(/-exp.*$/, "") + .replace(/-/g, " ") + .replace(/\s+/g, " ") + .split(" ") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + } return normalized; } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 785b6b6e..237c2e2b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,8 +2,14 @@ ## Unreleased +### Fixed + +- **Gemini model display across all prettifyModel copies.** Three copies of `prettifyModel` (in `post-share.ts`, `post-card-image.tsx`, `open-stats.ts`) were missing Gemini support entirely. Added the Gemini block to all three. Also improved suffix stripping in all 5 copies: `-preview` and `-exp` suffixes (including dated variants like `-preview-05-06` and `-exp-03-25`) are now stripped so model IDs like `gemini-2.5-pro-exp-03-25` display as "Gemini 2.5 Pro". +- **Web modelColor() now includes Gemini colors.** Added 8 Gemini model color entries and a family fallback (#3F51B5 Google indigo) to the `modelColor()` function in `ActivityCard.tsx`, matching the CLI theme palette. + ### Added +- **Gemini CLI usage tracking via gemistat.** The CLI now collects Gemini CLI usage data alongside Claude Code and Codex. Runs `gemistat daily --json` in parallel with ccusage and @ccusage/codex, merges all three sources by date, and submits combined stats. Silent failure — users without Gemini CLI or gemistat are unaffected. Gemini models (Gemini 3.1 Pro, 2.5 Pro/Flash, etc.) get dedicated colors in the CLI scorecard palette and pretty names in feed cards. - **User Signups bar chart on admin dashboard.** Shows daily new user signups with 7D/30D/All time range selector. Reuses the existing `admin_growth_metrics` RPC — no new migration needed. - **GitHub README scorecard embed.** New SVG endpoint at `/api/embed//svg` renders a scorecard matching the `/stats` profile card design — warm gradient, 365-day heatmap with month/day labels and legend, streak, output tokens, active days, and model. Supports `?theme=light|dark` and `?compact=1`. Uses the bolt logo. - **Cmd+Enter to send DMs.** The message composer in `/messages` now supports `Cmd+Enter` (Mac) / `Ctrl+Enter` to send. The send button shows the shortcut hint ("Send ⌘↵") matching the prompt submission widget style. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8b051594..9a5c9a42 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,5 +1,19 @@ # Architecture & Design Decisions +## Gemini CLI Integration: gemistat Subprocess, Not Direct Telemetry Parsing (2026-04-04) + +**Decision:** Integrate Gemini CLI usage tracking by running `gemistat` (by ryoppippi, same author as ccusage) as a subprocess, mirroring the existing ccusage/codex pattern. Silent failure for users without Gemini CLI. + +**Why:** +- **Same author, same conventions.** gemistat outputs `{ daily: [...], totals: {...} }` with field names matching ccusage (`modelsUsed`, `totalCost`, `cacheReadTokens`). This means the parser is trivial — no format translation needed. +- **Keeps the CLI thin.** gemistat handles telemetry parsing, pricing lookup (via LiteLLM), and daily aggregation. Replicating this in Straude would mean maintaining a Gemini pricing table and OpenTelemetry parser. +- **Proven pattern.** ccusage (fatal errors) and codex (silent errors) are battle-tested. Gemini follows the codex pattern — silent failure, so existing users are never affected. + +**Alternatives considered:** +1. **Parse `~/.gemini/telemetry.log` directly** — Full control, no dependency, but requires maintaining a pricing table and OpenTelemetry parser. Telemetry must still be enabled manually. +2. **Parse chat session files (`~/.gemini/tmp/*/chats/*.json`)** — No telemetry enablement needed, but token data is less structured and harder to aggregate reliably. +3. **Hybrid (gemistat with fallback to direct parsing)** — More robust but doubles the code surface for marginal benefit. + ## Legacy-to-Device Usage Backfill Strategy (2026-03-28) **Decision:** When the multi-device push path encounters a `daily_usage` row with zero `device_usage` backing rows, automatically insert a sentinel `device_usage` row (device_id `00000000-...`, device_name "legacy") before aggregation. diff --git a/docs/plans/2026-04-04-gemini-cli-integration.md b/docs/plans/2026-04-04-gemini-cli-integration.md new file mode 100644 index 00000000..c785cdea --- /dev/null +++ b/docs/plans/2026-04-04-gemini-cli-integration.md @@ -0,0 +1,961 @@ +# Gemini CLI Integration via gemistat + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Gemini CLI usage tracking to Straude by integrating gemistat (the ccusage equivalent for Gemini CLI), so users who code with Gemini CLI see their Gemini spend merged into their daily Straude posts alongside Claude Code and Codex data. + +**Architecture:** Create `packages/cli/src/lib/gemini.ts` mirroring the existing `codex.ts` pattern — run `gemistat daily --json` as a subprocess, parse output into `CcusageDailyEntry[]`, merge with Claude+Codex data in `push.ts`. Silent failure like Codex (users without Gemini CLI are unaffected). Add Gemini model colors to the CLI theme and prettifyModel to both CLI and web. + +**Tech Stack:** TypeScript, Node child_process, Vitest, Ink (React CLI) + +--- + +### Task 1: Add `"gemini"` to token normalization source hints + +**Files:** +- Modify: `packages/cli/src/lib/token-normalization.ts:37` (TokenSourceHints) +- Modify: `packages/cli/src/lib/ccusage.ts:169` (NormalizationAnomaly source union) + +**Step 1: Update TokenSourceHints source type** + +In `packages/cli/src/lib/token-normalization.ts`, line 37, change: + +```typescript +source: "codex" | "ccusage" | "generic"; +``` + +to: + +```typescript +source: "codex" | "ccusage" | "gemini" | "generic"; +``` + +**Step 2: Update NormalizationAnomaly source type** + +In `packages/cli/src/lib/ccusage.ts`, line 169, change: + +```typescript +source: "ccusage" | "codex"; +``` + +to: + +```typescript +source: "ccusage" | "codex" | "gemini"; +``` + +**Step 3: Run existing tests to verify no regressions** + +Run: `cd packages/cli && bun run test` +Expected: All existing tests pass (these are type-only changes). + +**Step 4: Commit** + +```bash +git add packages/cli/src/lib/token-normalization.ts packages/cli/src/lib/ccusage.ts +git commit -m "feat(cli): add gemini to token normalization source hints" +``` + +--- + +### Task 2: Create `gemini.ts` — gemistat subprocess runner and parser + +**Files:** +- Create: `packages/cli/src/lib/gemini.ts` +- Test: `packages/cli/__tests__/gemini.test.ts` + +**Step 1: Write the failing tests** + +Create `packages/cli/__tests__/gemini.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { parseGeminiOutput, runGeminiRaw } from "../src/lib/gemini.js"; + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), + execFile: vi.fn(), +})); + +import { execFileSync } from "node:child_process"; +const mockExecFileSync = vi.mocked(execFileSync); + +/** Build a valid gemistat daily --json output string. */ +function validOutput() { + return JSON.stringify({ + daily: [ + { + date: "2026-04-01", + modelsUsed: ["gemini-2.5-pro"], + inputTokens: 5000, + outputTokens: 1200, + cacheCreationTokens: 0, + cacheReadTokens: 800, + totalCost: 0.45, + }, + ], + totals: { + inputTokens: 5000, + outputTokens: 1200, + cacheCreationTokens: 0, + cacheReadTokens: 800, + totalCost: 0.45, + }, + }); +} + +/** Multi-model output. */ +function multiModelOutput() { + return JSON.stringify({ + daily: [ + { + date: "2026-04-01", + modelsUsed: ["gemini-2.5-pro", "gemini-2.5-flash"], + inputTokens: 10000, + outputTokens: 3000, + cacheCreationTokens: 200, + cacheReadTokens: 1500, + totalCost: 1.25, + }, + ], + totals: { + inputTokens: 10000, + outputTokens: 3000, + cacheCreationTokens: 200, + cacheReadTokens: 1500, + totalCost: 1.25, + }, + }); +} + +// --------------------------------------------------------------------------- +// parseGeminiOutput +// --------------------------------------------------------------------------- + +describe("parseGeminiOutput", () => { + it("parses valid gemistat JSON and normalizes fields", () => { + const result = parseGeminiOutput(validOutput()); + expect(result.data).toHaveLength(1); + const entry = result.data[0]!; + expect(entry.date).toBe("2026-04-01"); + expect(entry.costUSD).toBe(0.45); + expect(entry.models).toEqual(["gemini-2.5-pro"]); + expect(entry.inputTokens).toBe(5000); + expect(entry.outputTokens).toBe(1200); + expect(entry.cacheReadTokens).toBe(800); + expect(entry.cacheCreationTokens).toBe(0); + // totalTokens computed: 5000 + 1200 + 0 + 800 = 7000 + expect(entry.totalTokens).toBe(7000); + }); + + it("parses multi-model output", () => { + const result = parseGeminiOutput(multiModelOutput()); + expect(result.data).toHaveLength(1); + const entry = result.data[0]!; + expect(entry.models).toEqual(["gemini-2.5-pro", "gemini-2.5-flash"]); + expect(entry.costUSD).toBe(1.25); + }); + + it("returns empty data for invalid JSON", () => { + const result = parseGeminiOutput("not json"); + expect(result.data).toEqual([]); + expect(result.anomalies).toHaveLength(1); + expect(result.anomalies?.[0]?.mode).toBe("unresolved"); + }); + + it("returns empty data for empty array", () => { + const result = parseGeminiOutput("[]"); + expect(result.data).toEqual([]); + }); + + it("returns empty data when daily is missing", () => { + const result = parseGeminiOutput(JSON.stringify({ something: "else" })); + expect(result.data).toEqual([]); + }); + + it("filters out entries with missing date", () => { + const raw = JSON.stringify({ + daily: [ + { totalCost: 1, modelsUsed: [], inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }, + { date: "2026-04-01", totalCost: 0.05, modelsUsed: ["gemini-2.5-flash"], inputTokens: 50, outputTokens: 50, cacheCreationTokens: 0, cacheReadTokens: 0 }, + ], + totals: {}, + }); + const result = parseGeminiOutput(raw); + expect(result.data).toHaveLength(1); + expect(result.data[0]!.date).toBe("2026-04-01"); + }); + + it("filters out entries with negative cost", () => { + const raw = JSON.stringify({ + daily: [ + { date: "2026-04-01", totalCost: -1, modelsUsed: [], inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }, + ], + totals: {}, + }); + const result = parseGeminiOutput(raw); + expect(result.data).toEqual([]); + }); + + it("handles entries without cache tokens", () => { + const raw = JSON.stringify({ + daily: [ + { + date: "2026-04-01", + modelsUsed: ["gemini-2.5-flash-lite"], + inputTokens: 500, + outputTokens: 200, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0.03, + }, + ], + totals: {}, + }); + const result = parseGeminiOutput(raw); + expect(result.data[0]!.cacheCreationTokens).toBe(0); + expect(result.data[0]!.cacheReadTokens).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// runGeminiRaw — silent failure +// --------------------------------------------------------------------------- + +describe("runGeminiRaw", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExecFileSync.mockReturnValue(validOutput() as never); + }); + + it("returns raw JSON string on success", () => { + const result = runGeminiRaw("20260401", "20260401"); + expect(result).toBe(validOutput()); + }); + + it("returns empty string on failure (silent)", () => { + mockExecFileSync.mockImplementation(() => { throw new Error("fail"); }); + const result = runGeminiRaw("20260401", "20260401"); + expect(result).toBe(""); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd packages/cli && bun run test -- gemini` +Expected: FAIL — `../src/lib/gemini.js` does not exist. + +**Step 3: Write the implementation** + +Create `packages/cli/src/lib/gemini.ts`: + +```typescript +import { execFileSync, execFile as execFileCb } from "node:child_process"; +import type { CcusageDailyEntry, NormalizationAnomaly } from "./ccusage.js"; +import { + normalizeTokenBuckets, + summarizeNormalization, + type NormalizationMeta, + type NormalizationSummary, +} from "./token-normalization.js"; +import { DEFAULT_SUBPROCESS_TIMEOUT_MS } from "../config.js"; + +export interface GeminiOutput { + data: CcusageDailyEntry[]; + anomalies?: NormalizationAnomaly[]; + normalizationSummary?: NormalizationSummary; + entryMeta?: Array<{ date: string; meta: NormalizationMeta }>; +} + +const GEMINI_PKG = "gemistat"; + +/** Returns the raw JSON string from gemistat (for hashing). Empty string on failure. */ +export function runGeminiRaw(sinceDate: string, untilDate: string, timeoutMs?: number): string { + try { + return execGemini(["daily", "--json", "--since", sinceDate, "--until", untilDate], timeoutMs); + } catch { + return ""; + } +} + +/** Async version — returns raw JSON string without blocking. Empty string on failure. */ +export async function runGeminiRawAsync(sinceDate: string, untilDate: string, timeoutMs?: number): Promise { + try { + return await execGeminiAsync(["daily", "--json", "--since", sinceDate, "--until", untilDate], timeoutMs); + } catch { + return ""; + } +} + +function execGemini(args: string[], timeoutMs?: number): string { + const cmd = process.versions.bun !== undefined ? "bunx" : "npx"; + const prefix = process.versions.bun !== undefined ? ["--bun"] : ["--yes"]; + + return execFileSync(cmd, [...prefix, GEMINI_PKG, ...args], { + encoding: "utf-8", + timeout: timeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024, + shell: process.platform === "win32", + }); +} + +function execGeminiAsync(args: string[], timeoutMs?: number): Promise { + const cmd = process.versions.bun !== undefined ? "bunx" : "npx"; + const prefix = process.versions.bun !== undefined ? ["--bun"] : ["--yes"]; + + return new Promise((resolve, reject) => { + execFileCb(cmd, [...prefix, GEMINI_PKG, ...args], { + encoding: "utf-8", + timeout: timeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024, + shell: process.platform === "win32", + }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout); + }); + }); +} + +/** + * Raw shape returned by gemistat (`daily --json`). + * + * gemistat follows the same conventions as ccusage: + * - Cost: `totalCost` + * - Date: ISO 8601 ("2026-04-01") + * - Models: `modelsUsed: string[]` + * - Cache: `cacheReadTokens` is separate from `inputTokens` + * + * Notable difference: no `totalTokens` field — we compute it. + */ +interface GeminiRawEntry { + date: string; + modelsUsed: string[]; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; +} + +interface GeminiDailyOutput { + daily: GeminiRawEntry[]; + totals?: { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + }; +} + +export function parseGeminiOutput(raw: string): GeminiOutput { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { + data: [], + anomalies: [{ + date: "unknown", + source: "gemini", + mode: "unresolved", + confidence: "low", + consistencyError: 0, + warnings: ["Failed to parse gemistat JSON output."], + }], + normalizationSummary: { + total: 1, + anomalies: 1, + byMode: { unresolved: 1 }, + byConfidence: { low: 1 }, + }, + }; + } + + // Empty array = no data + if (Array.isArray(parsed) && (parsed as unknown[]).length === 0) { + return { data: [], anomalies: [], normalizationSummary: summarizeNormalization([]), entryMeta: [] }; + } + + const output = parsed as GeminiDailyOutput; + if (!output.daily || !Array.isArray(output.daily)) { + return { data: [], anomalies: [], normalizationSummary: summarizeNormalization([]), entryMeta: [] }; + } + + const normalizedRows = output.daily + .filter((e) => { + return e.date && typeof e.totalCost === "number" && e.totalCost >= 0; + }) + .map((e) => { + // gemistat does not provide totalTokens — compute it + const computedTotal = (e.inputTokens || 0) + (e.outputTokens || 0) + + (e.cacheCreationTokens || 0) + (e.cacheReadTokens || 0); + + const normalized = normalizeTokenBuckets( + { + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + cacheCreationTokens: e.cacheCreationTokens, + cacheReadTokens: e.cacheReadTokens, + totalTokens: computedTotal, + }, + { source: "gemini", cacheSemantics: "separate" }, + ); + + return { + date: e.date, + meta: normalized.meta, + entry: { + date: e.date, + models: Array.isArray(e.modelsUsed) ? e.modelsUsed : [], + inputTokens: normalized.normalized.inputTokens, + outputTokens: normalized.normalized.outputTokens, + cacheCreationTokens: normalized.normalized.cacheCreationTokens, + cacheReadTokens: normalized.normalized.cacheReadTokens, + totalTokens: normalized.normalized.totalTokens, + costUSD: e.totalCost, + } satisfies CcusageDailyEntry, + }; + }); + + const anomalies: NormalizationAnomaly[] = normalizedRows + .filter((row) => row.meta.mode === "unresolved" || row.meta.confidence !== "high" || row.meta.warnings.length > 0) + .map((row) => ({ + date: row.date, + source: "gemini", + mode: row.meta.mode, + confidence: row.meta.confidence, + consistencyError: row.meta.consistencyError, + warnings: row.meta.warnings, + })); + + return { + data: normalizedRows.map((row) => row.entry), + anomalies, + normalizationSummary: summarizeNormalization(normalizedRows.map((row) => row.meta)), + entryMeta: normalizedRows.map((row) => ({ date: row.date, meta: row.meta })), + }; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd packages/cli && bun run test -- gemini` +Expected: All 9 tests PASS. + +**Step 5: Run the full test suite** + +Run: `cd packages/cli && bun run test` +Expected: All tests pass including existing ones. + +**Step 6: Commit** + +```bash +git add packages/cli/src/lib/gemini.ts packages/cli/__tests__/gemini.test.ts +git commit -m "feat(cli): add gemistat integration for Gemini CLI usage tracking" +``` + +--- + +### Task 3: Extend `mergeEntries()` to support 3-way merge and integrate into push flow + +**Files:** +- Modify: `packages/cli/src/commands/push.ts` +- Test: `packages/cli/__tests__/commands/push.test.ts` + +**Step 1: Write the failing test for 3-way merge** + +Add to `packages/cli/__tests__/commands/push.test.ts` (or create a new `packages/cli/__tests__/merge.test.ts` if the file doesn't exist): + +```typescript +import { describe, it, expect } from "vitest"; +import { mergeEntries } from "../src/commands/push.js"; + +describe("mergeEntries with gemini", () => { + it("merges claude + codex + gemini entries by date", () => { + const claude = [{ + date: "2026-04-01", + models: ["Claude Opus"], + inputTokens: 1000, outputTokens: 500, + cacheCreationTokens: 0, cacheReadTokens: 100, + totalTokens: 1600, costUSD: 2.00, + }]; + const codex = [{ + date: "2026-04-01", + models: ["GPT-5"], + inputTokens: 800, outputTokens: 300, + cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 1100, costUSD: 0.50, + }]; + const gemini = [{ + date: "2026-04-01", + models: ["gemini-2.5-pro"], + inputTokens: 5000, outputTokens: 1200, + cacheCreationTokens: 0, cacheReadTokens: 800, + totalTokens: 7000, costUSD: 0.45, + }]; + const merged = mergeEntries(claude, codex, gemini); + expect(merged).toHaveLength(1); + expect(merged[0]!.costUSD).toBe(2.95); + expect(merged[0]!.models).toEqual(["Claude Opus", "GPT-5", "gemini-2.5-pro"]); + expect(merged[0]!.inputTokens).toBe(6800); + expect(merged[0]!.totalTokens).toBe(9700); + }); + + it("handles gemini-only dates", () => { + const merged = mergeEntries([], [], [{ + date: "2026-04-02", + models: ["gemini-2.5-flash"], + inputTokens: 2000, outputTokens: 500, + cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 2500, costUSD: 0.10, + }]); + expect(merged).toHaveLength(1); + expect(merged[0]!.models).toEqual(["gemini-2.5-flash"]); + }); + + it("backward compatible — works without gemini argument", () => { + const claude = [{ + date: "2026-04-01", models: ["Claude Opus"], + inputTokens: 1000, outputTokens: 500, + cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 1500, costUSD: 2.00, + }]; + const merged = mergeEntries(claude, []); + expect(merged).toHaveLength(1); + expect(merged[0]!.costUSD).toBe(2.00); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd packages/cli && bun run test -- merge` +Expected: FAIL — `mergeEntries` does not accept 3rd argument (or test for 3-way behavior fails). + +**Step 3: Update `mergeEntries` signature and body** + +In `packages/cli/src/commands/push.ts`, update `mergeEntries` (lines 108-147): + +Change the function signature from: +```typescript +export function mergeEntries( + claudeEntries: CcusageDailyEntry[], + codexEntries: CcusageDailyEntry[], +): CcusageDailyEntry[] { + const byDate = new Map(); + + for (const e of claudeEntries) { + byDate.set(e.date, { ...byDate.get(e.date), claude: e }); + } + for (const e of codexEntries) { + byDate.set(e.date, { ...byDate.get(e.date), codex: e }); + } + + const merged: CcusageDailyEntry[] = []; + + for (const [date, { claude, codex }] of byDate) { + const claudeBreakdown = claude ? buildBreakdown(claude) : []; + const codexBreakdown = codex ? buildBreakdown(codex) : []; + const modelBreakdown = [...claudeBreakdown, ...codexBreakdown]; + + merged.push({ + date, + models: [ + ...(claude?.models ?? []), + ...(codex?.models ?? []), + ], + inputTokens: (claude?.inputTokens ?? 0) + (codex?.inputTokens ?? 0), + outputTokens: (claude?.outputTokens ?? 0) + (codex?.outputTokens ?? 0), + cacheCreationTokens: (claude?.cacheCreationTokens ?? 0) + (codex?.cacheCreationTokens ?? 0), + cacheReadTokens: (claude?.cacheReadTokens ?? 0) + (codex?.cacheReadTokens ?? 0), + totalTokens: (claude?.totalTokens ?? 0) + (codex?.totalTokens ?? 0), + costUSD: (claude?.costUSD ?? 0) + (codex?.costUSD ?? 0), + modelBreakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined, + }); + } + + // Sort by date ascending + merged.sort((a, b) => a.date.localeCompare(b.date)); + return merged; +} +``` + +To: +```typescript +export function mergeEntries( + claudeEntries: CcusageDailyEntry[], + codexEntries: CcusageDailyEntry[], + geminiEntries: CcusageDailyEntry[] = [], +): CcusageDailyEntry[] { + const byDate = new Map(); + + for (const e of claudeEntries) { + byDate.set(e.date, { ...byDate.get(e.date), claude: e }); + } + for (const e of codexEntries) { + byDate.set(e.date, { ...byDate.get(e.date), codex: e }); + } + for (const e of geminiEntries) { + byDate.set(e.date, { ...byDate.get(e.date), gemini: e }); + } + + const merged: CcusageDailyEntry[] = []; + + for (const [date, { claude, codex, gemini }] of byDate) { + const claudeBreakdown = claude ? buildBreakdown(claude) : []; + const codexBreakdown = codex ? buildBreakdown(codex) : []; + const geminiBreakdown = gemini ? buildBreakdown(gemini) : []; + const modelBreakdown = [...claudeBreakdown, ...codexBreakdown, ...geminiBreakdown]; + + merged.push({ + date, + models: [ + ...(claude?.models ?? []), + ...(codex?.models ?? []), + ...(gemini?.models ?? []), + ], + inputTokens: (claude?.inputTokens ?? 0) + (codex?.inputTokens ?? 0) + (gemini?.inputTokens ?? 0), + outputTokens: (claude?.outputTokens ?? 0) + (codex?.outputTokens ?? 0) + (gemini?.outputTokens ?? 0), + cacheCreationTokens: (claude?.cacheCreationTokens ?? 0) + (codex?.cacheCreationTokens ?? 0) + (gemini?.cacheCreationTokens ?? 0), + cacheReadTokens: (claude?.cacheReadTokens ?? 0) + (codex?.cacheReadTokens ?? 0) + (gemini?.cacheReadTokens ?? 0), + totalTokens: (claude?.totalTokens ?? 0) + (codex?.totalTokens ?? 0) + (gemini?.totalTokens ?? 0), + costUSD: (claude?.costUSD ?? 0) + (codex?.costUSD ?? 0) + (gemini?.costUSD ?? 0), + modelBreakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined, + }); + } + + merged.sort((a, b) => a.date.localeCompare(b.date)); + return merged; +} +``` + +**Step 4: Update the push command flow** + +In the same file, add the import at the top (after the codex import, line 9): + +```typescript +import { runGeminiRawAsync, parseGeminiOutput } from "../lib/gemini.js"; +``` + +Update the `Promise.all` call (lines 236-239) from: + +```typescript +const [claudeResult, codexRaw] = await Promise.all([ + runCcusageRawAsync(sinceStr, untilStr, options.timeoutMs).catch((err: Error) => err), + runCodexRawAsync(sinceStr, untilStr, options.timeoutMs), +]); +``` + +To: + +```typescript +const [claudeResult, codexRaw, geminiRaw] = await Promise.all([ + runCcusageRawAsync(sinceStr, untilStr, options.timeoutMs).catch((err: Error) => err), + runCodexRawAsync(sinceStr, untilStr, options.timeoutMs), + runGeminiRawAsync(sinceStr, untilStr, options.timeoutMs), +]); +``` + +After the codex parsing section (after line 293), add gemini parsing: + +```typescript +// Gemini data — silent on fetch failure, surface parser anomalies. +const geminiParsed = geminiRaw ? parseGeminiOutput(geminiRaw) : { data: [], anomalies: [], entryMeta: [] }; +const allAnomalies = [...claudeAnomalies, ...(codexParsed.anomalies ?? []), ...(geminiParsed.anomalies ?? [])]; +``` + +(Remove the existing `allAnomalies` line at 268 since we're replacing it.) + +After the codex blocked-dates logic, add gemini blocked-dates: + +```typescript +const geminiMetaByDate = new Map((geminiParsed.entryMeta ?? []).map((row) => [row.date, row.meta])); +const geminiBlockedDates = new Set(); + +for (const [date, meta] of geminiMetaByDate) { + if (meta.mode === "unresolved") { + geminiBlockedDates.add(date); + } +} + +if (geminiBlockedDates.size > 0) { + const blocked = [...geminiBlockedDates].sort(); + console.log(`Warning: skipping Gemini rows for ${blocked.length} date(s) due to unresolved normalization: ${blocked.join(", ")}`); +} + +const geminiEntries = geminiParsed.data.filter((entry) => !geminiBlockedDates.has(entry.date)); +``` + +Update the merge call (line 296) from: + +```typescript +const entries = mergeEntries(claudeEntries, codexEntries); +``` + +To: + +```typescript +const entries = mergeEntries(claudeEntries, codexEntries, geminiEntries); +``` + +Update the hash computation (line 331) from: + +```typescript +const hashInput = codexRaw ? claudeRaw + codexRaw : claudeRaw; +``` + +To: + +```typescript +const hashInput = claudeRaw + codexRaw + geminiRaw; +``` + +**Step 5: Run tests to verify they pass** + +Run: `cd packages/cli && bun run test` +Expected: All tests pass including the new merge tests. + +**Step 6: Commit** + +```bash +git add packages/cli/src/commands/push.ts packages/cli/__tests__/commands/push.test.ts +git commit -m "feat(cli): integrate gemini into push flow with 3-way merge" +``` + +--- + +### Task 4: Add Gemini model colors to CLI theme + +**Files:** +- Modify: `packages/cli/src/components/theme.ts:22-30` + +**Step 1: Add Gemini model colors** + +In `packages/cli/src/components/theme.ts`, add Gemini entries to the `modelColors` map (after line 30, before the closing `}`): + +```typescript +// Model color map — Claude = orange, OpenAI = purple, Gemini = Google brand colors +export const modelColors: Record = { + 'Claude Opus': '#DF561F', // brand orange + 'Claude Sonnet': '#F08A5D', // lighter orange + 'Claude Haiku': '#F7B267', // amber + 'GPT-5': '#8B5CF6', // purple + 'GPT-4o': '#A78BFA', // lighter purple + 'o3': '#7C3AED', // deeper purple + 'o4': '#6D28D9', // deep purple + 'Gemini 3.1 Pro': '#4285F4', // Google blue + 'Gemini 3.1 Flash Lite': '#00ACC1', // cyan + 'Gemini 3 Flash': '#009688', // teal + 'Gemini 2.5 Pro': '#3F51B5', // indigo + 'Gemini 2.5 Flash': '#34A853', // Google green + 'Gemini 2.5 Flash Lite': '#8BC34A', // light green + 'Gemini 2.0 Flash': '#FBBC05', // Google yellow + 'Gemini 2.0 Flash Lite': '#FF8F00', // amber +}; +``` + +**Step 2: Update `getModelColor` in ModelPalette.tsx** + +In `packages/cli/src/components/ModelPalette.tsx`, update the `getModelColor` function (lines 41-51) to add a Gemini family matcher: + +After line 47 (`if (/GPT/i.test(name)) return modelColors['GPT-5']!;`), add: + +```typescript +// Gemini family → blue/green shades +if (/Gemini/i.test(name)) return modelColors['Gemini 2.5 Pro'] ?? modelFallback[0]!; +``` + +**Step 3: Run existing tests** + +Run: `cd packages/cli && bun run test` +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add packages/cli/src/components/theme.ts packages/cli/src/components/ModelPalette.tsx +git commit -m "feat(cli): add Gemini model colors to CLI theme and palette" +``` + +--- + +### Task 5: Add Gemini models to `prettifyModel` (CLI + Web) + +**Files:** +- Modify: `packages/cli/src/components/ModelPalette.tsx:13-30` (CLI prettifyModel) +- Modify: `apps/web/components/app/feed/ActivityCard.tsx:57-75` (Web prettifyModel) +- Modify: `apps/web/__tests__/unit/prettify-model.test.ts` + +**Step 1: Add Gemini test cases** + +In `apps/web/__tests__/unit/prettify-model.test.ts`, add a new describe block after the "OpenAI models" block: + +```typescript +describe("Gemini models", () => { + it("prettifies gemini-3.1-pro-preview", () => { + expect(prettifyModel("gemini-3.1-pro-preview")).toBe("Gemini 3.1 Pro"); + }); + + it("prettifies gemini-3.1-flash-lite-preview", () => { + expect(prettifyModel("gemini-3.1-flash-lite-preview")).toBe("Gemini 3.1 Flash Lite"); + }); + + it("prettifies gemini-3-flash-preview", () => { + expect(prettifyModel("gemini-3-flash-preview")).toBe("Gemini 3 Flash"); + }); + + it("prettifies gemini-2.5-pro", () => { + expect(prettifyModel("gemini-2.5-pro")).toBe("Gemini 2.5 Pro"); + }); + + it("prettifies gemini-2.5-flash", () => { + expect(prettifyModel("gemini-2.5-flash")).toBe("Gemini 2.5 Flash"); + }); + + it("prettifies gemini-2.5-flash-lite", () => { + expect(prettifyModel("gemini-2.5-flash-lite")).toBe("Gemini 2.5 Flash Lite"); + }); + + it("prettifies gemini-2.0-flash", () => { + expect(prettifyModel("gemini-2.0-flash")).toBe("Gemini 2.0 Flash"); + }); + + it("prettifies gemini-2.0-flash-lite", () => { + expect(prettifyModel("gemini-2.0-flash-lite")).toBe("Gemini 2.0 Flash Lite"); + }); +}); +``` + +Also update the "unknown models" test that previously expected gemini models to pass through raw: + +```typescript +it("returns the model name as-is for unrecognized models", () => { + expect(prettifyModel("qwen-2.5-coder")).toBe("qwen-2.5-coder"); + expect(prettifyModel("mistral-large")).toBe("mistral-large"); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd apps/web && bun run test -- prettify-model` +Expected: FAIL — Gemini models return raw string instead of pretty name. + +**Step 3: Add Gemini matching to web prettifyModel** + +In `apps/web/components/app/feed/ActivityCard.tsx`, add Gemini rules after line 69 (the `if (/^o3/i...)` line), before the legacy Claude matching: + +```typescript +// Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro" +if (/^gemini-/i.test(normalized)) { + return normalized + .replace(/^gemini-/i, "Gemini ") + .replace(/-preview$/, "") + .replace(/-/g, " ") + .replace(/\s+/g, " ") + .split(" ") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} +``` + +**Step 4: Add same Gemini matching to CLI prettifyModel** + +In `packages/cli/src/components/ModelPalette.tsx`, add the same block after line 23 (the `if (/^o3/i...)` line): + +```typescript +// Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro" +if (/^gemini-/i.test(normalized)) { + return normalized + .replace(/^gemini-/i, "Gemini ") + .replace(/-preview$/, "") + .replace(/-/g, " ") + .replace(/\s+/g, " ") + .split(" ") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} +``` + +**Step 5: Run tests to verify they pass** + +Run: `cd apps/web && bun run test -- prettify-model` +Expected: All tests PASS. + +Run: `cd packages/cli && bun run test` +Expected: All tests PASS. + +**Step 6: Commit** + +```bash +git add apps/web/components/app/feed/ActivityCard.tsx packages/cli/src/components/ModelPalette.tsx apps/web/__tests__/unit/prettify-model.test.ts +git commit -m "feat: add Gemini model prettification to CLI and web" +``` + +--- + +### Task 6: Update documentation + +**Files:** +- Modify: `docs/CHANGELOG.md` +- Modify: `docs/DECISIONS.md` + +**Step 1: Add CHANGELOG entry** + +Add under `## Unreleased / ### Added`: + +```markdown +- **Gemini CLI usage tracking via gemistat.** The CLI now collects Gemini CLI usage data alongside Claude Code and Codex. Runs `gemistat daily --json` in parallel with ccusage and @ccusage/codex, merges all three sources by date, and submits combined stats. Silent failure — users without Gemini CLI or gemistat are unaffected. Gemini models (Gemini 3.1 Pro, 2.5 Pro/Flash, etc.) get dedicated colors in the CLI scorecard palette and pretty names in feed cards. +``` + +**Step 2: Add DECISIONS entry** + +Add to `docs/DECISIONS.md`: + +```markdown +## Gemini CLI Integration: gemistat Subprocess, Not Direct Telemetry Parsing (2026-04-04) + +**Decision:** Integrate Gemini CLI usage tracking by running `gemistat` (by ryoppippi, same author as ccusage) as a subprocess, mirroring the existing ccusage/codex pattern. Silent failure for users without Gemini CLI. + +**Why:** +- **Same author, same conventions.** gemistat outputs `{ daily: [...], totals: {...} }` with field names matching ccusage (`modelsUsed`, `totalCost`, `cacheReadTokens`). This means the parser is trivial. +- **Keeps the CLI thin.** gemistat handles telemetry parsing, pricing lookup (via LiteLLM), and daily aggregation. Replicating this in Straude would mean maintaining a Gemini pricing table and OpenTelemetry parser. +- **Proven pattern.** ccusage (fatal errors) and codex (silent errors) are battle-tested. Gemini follows the codex pattern — silent failure, so existing users are never affected. + +**Alternatives considered:** +1. **Parse `~/.gemini/telemetry.log` directly** — Full control, no dependency, but requires maintaining a pricing table and OpenTelemetry parser. Telemetry must still be enabled manually. +2. **Parse chat session files (`~/.gemini/tmp/*/chats/*.json`)** — No telemetry enablement needed, but token data is less structured and harder to aggregate reliably. +3. **Hybrid (gemistat with fallback to direct parsing)** — More robust but doubles the code surface for marginal benefit. +``` + +**Step 3: Commit** + +```bash +git add docs/CHANGELOG.md docs/DECISIONS.md +git commit -m "docs: add Gemini CLI integration changelog and decision record" +``` + +--- + +### Task 7: Run full test suite and typecheck + +**Step 1: Run CLI tests** + +Run: `cd packages/cli && bun run test` +Expected: All tests pass. + +**Step 2: Run web tests** + +Run: `cd apps/web && bun run test` +Expected: All tests pass. + +**Step 3: Run typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: No type errors. + +**Step 4: Run build** + +Run: `bun run build` +Expected: Build succeeds for both packages. diff --git a/packages/cli/__tests__/commands/push.test.ts b/packages/cli/__tests__/commands/push.test.ts index e6dac744..20957fd7 100755 --- a/packages/cli/__tests__/commands/push.test.ts +++ b/packages/cli/__tests__/commands/push.test.ts @@ -24,12 +24,18 @@ vi.mock("../../src/lib/codex.js", () => ({ parseCodexOutput: vi.fn(), })); +vi.mock("../../src/lib/gemini.js", () => ({ + runGeminiRawAsync: vi.fn(), + parseGeminiOutput: vi.fn(), +})); + import { pushCommand, mergeEntries } from "../../src/commands/push.js"; import { loadConfig, saveConfig } from "../../src/lib/auth.js"; import { loginCommand } from "../../src/commands/login.js"; import { apiRequest } from "../../src/lib/api.js"; import { runCcusageRawAsync, parseCcusageOutput } from "../../src/lib/ccusage.js"; import { runCodexRawAsync, parseCodexOutput } from "../../src/lib/codex.js"; +import { runGeminiRawAsync, parseGeminiOutput } from "../../src/lib/gemini.js"; const mockLoadConfig = vi.mocked(loadConfig); const mockLoginCommand = vi.mocked(loginCommand); @@ -39,6 +45,8 @@ const mockRunCcusageRawAsync = vi.mocked(runCcusageRawAsync); const mockParseCcusageOutput = vi.mocked(parseCcusageOutput); const mockRunCodexRawAsync = vi.mocked(runCodexRawAsync); const mockParseCodexOutput = vi.mocked(parseCodexOutput); +const mockRunGeminiRawAsync = vi.mocked(runGeminiRawAsync); +const mockParseGeminiOutput = vi.mocked(parseGeminiOutput); const fakeConfig = { token: "tok", username: "alice", api_url: "https://straude.com" }; @@ -65,6 +73,9 @@ beforeEach(() => { // Default: no Codex data mockRunCodexRawAsync.mockResolvedValue(""); mockParseCodexOutput.mockReturnValue({ data: [] }); + // Default: no Gemini data + mockRunGeminiRawAsync.mockResolvedValue(""); + mockParseGeminiOutput.mockReturnValue({ data: [], anomalies: [], entryMeta: [] }); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(process, "exit").mockImplementation((code) => { @@ -494,7 +505,7 @@ describe("pushCommand", () => { expect(mockParseCcusageOutput).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith( - "No Claude Code data found locally; syncing Codex usage only.", + "No Claude Code data found locally; syncing other sources only.", ); const submitCall = mockApiRequest.mock.calls[0]!; @@ -514,7 +525,7 @@ describe("pushCommand", () => { expect(mockApiRequest).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith( - "No Claude Code data found locally; syncing Codex usage only.", + "No Claude Code data found locally; syncing other sources only.", ); expect(console.log).toHaveBeenCalledWith( expect.stringContaining("No usage data found"), @@ -619,6 +630,97 @@ describe("pushCommand", () => { expect(body.entries[0]!.data.costUSD).toBe(0.05); }); + it("forwards --timeout to gemini subprocess", async () => { + mockRunCcusageRawAsync.mockResolvedValue("[]"); + mockParseCcusageOutput.mockReturnValue({ data: [] }); + + await pushCommand({ timeoutMs: 300_000 }); + + expect(mockRunGeminiRawAsync).toHaveBeenCalledWith( + expect.any(String), expect.any(String), 300_000, + ); + }); + + it("proceeds with Claude data when Gemini fails silently", async () => { + const today = todayStr(); + + mockRunCcusageRawAsync.mockResolvedValue("{}"); + mockParseCcusageOutput.mockReturnValue({ + data: [ + { + date: today, + models: ["claude-sonnet-4-5-20250929"], + inputTokens: 1000, + outputTokens: 500, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 1500, + costUSD: 0.05, + }, + ], + }); + + mockRunGeminiRawAsync.mockResolvedValue(""); + + mockApiRequest.mockResolvedValue({ + results: [ + { date: today, usage_id: "u-1", post_id: "p-1", post_url: "https://straude.com/post/p-1", action: "created" }, + ], + }); + + await pushCommand({}); + + // Should still submit Claude data + expect(mockApiRequest).toHaveBeenCalledWith( + expect.anything(), + "/api/usage/submit", + expect.anything(), + ); + }); + + it("submits Gemini-only data when Claude and Codex have none", async () => { + const today = todayStr(); + + // Claude returns empty + mockRunCcusageRawAsync.mockResolvedValue(JSON.stringify({ daily: [] })); + mockParseCcusageOutput.mockReturnValue({ data: [] }); + // Codex returns empty + mockRunCodexRawAsync.mockResolvedValue(""); + // Gemini has data + const geminiRaw = JSON.stringify({ daily: [{ date: today, modelsUsed: ["gemini-2.5-pro"], inputTokens: 5000, outputTokens: 1200, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0.45 }] }); + mockRunGeminiRawAsync.mockResolvedValue(geminiRaw); + mockParseGeminiOutput.mockReturnValue({ + data: [{ + date: today, + models: ["gemini-2.5-pro"], + inputTokens: 5000, + outputTokens: 1200, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 6200, + costUSD: 0.45, + }], + anomalies: [], + entryMeta: [], + }); + + mockApiRequest.mockResolvedValue({ + results: [ + { date: today, usage_id: "u-1", post_id: "p-1", post_url: "https://straude.com/post/p-1", action: "created" }, + ], + }); + + await pushCommand({}); + + const submitCall = mockApiRequest.mock.calls.find( + (c) => c[1] === "/api/usage/submit", + ); + expect(submitCall).toBeDefined(); + const body = JSON.parse(submitCall![2]!.body as string); + expect(body.entries[0].data.models).toEqual(["gemini-2.5-pro"]); + expect(body.entries[0].data.costUSD).toBe(0.45); + }); + }); // --------------------------------------------------------------------------- diff --git a/packages/cli/__tests__/flows/cli-sync-flow.test.ts b/packages/cli/__tests__/flows/cli-sync-flow.test.ts index a9b46777..0137b870 100644 --- a/packages/cli/__tests__/flows/cli-sync-flow.test.ts +++ b/packages/cli/__tests__/flows/cli-sync-flow.test.ts @@ -134,7 +134,7 @@ function ccusageJson(dates: string[]) { /** * Wraps mockExecFileSync to return ccusage JSON for ccusage calls and throw - * for @ccusage/codex calls (simulating codex not installed — the default). + * for @ccusage/codex and gemistat calls (simulating not installed — the default). */ function mockCcusageOnly(json: string) { mockExecFileSync.mockImplementation(((cmd: string, args: string[]) => { @@ -142,6 +142,10 @@ function mockCcusageOnly(json: string) { if (args?.some?.((a: string) => typeof a === "string" && a.includes("@ccusage/codex"))) { throw new Error("@ccusage/codex not found"); } + // gemistat calls should fail silently + if (args?.some?.((a: string) => typeof a === "string" && a.includes("gemistat"))) { + throw new Error("gemistat not found"); + } return json; }) as typeof execFileSync); } @@ -253,8 +257,8 @@ describe("sync flow", () => { // Re-syncs today's data (1 API call) expect(mockFetch).toHaveBeenCalledTimes(2); - // 2 calls: ccusage daily + codex attempt (no more --version probe) - expect(mockExecFileSync).toHaveBeenCalledTimes(2); + // 3 calls: ccusage daily + codex attempt + gemini attempt + expect(mockExecFileSync).toHaveBeenCalledTimes(3); const saved = readPersistedConfig(); expect(saved.last_push_date).toBe(today); }); @@ -269,8 +273,8 @@ describe("sync flow", () => { await pushCommand({}); - // 2 calls: ccusage daily + codex attempt (no more --version probe) - expect(mockExecFileSync).toHaveBeenCalledTimes(2); + // 3 calls: ccusage daily + codex attempt + gemini attempt + expect(mockExecFileSync).toHaveBeenCalledTimes(3); expect(mockFetch).toHaveBeenCalledTimes(2); const saved = readPersistedConfig(); @@ -286,8 +290,8 @@ describe("sync flow", () => { await pushCommand({}); - // 2 calls: ccusage daily + codex attempt (no more --version probe) - expect(mockExecFileSync).toHaveBeenCalledTimes(2); + // 3 calls: ccusage daily + codex attempt + gemini attempt + expect(mockExecFileSync).toHaveBeenCalledTimes(3); // calls[0] = actual ccusage daily const args = mockExecFileSync.mock.calls[0]!; // ccusage is called with: ["daily", "--json", "--since", ..., "--until", ...] @@ -332,6 +336,10 @@ describe("Codex integration", () => { if (args?.some?.((a: string) => typeof a === "string" && a.includes("@ccusage/codex"))) { return codexJsonStr; } + // gemistat calls should fail silently + if (args?.some?.((a: string) => typeof a === "string" && a.includes("gemistat"))) { + throw new Error("gemistat not found"); + } return ccJson; }) as typeof execFileSync); } @@ -378,9 +386,9 @@ describe("Codex integration", () => { const [, options] = mockFetch.mock.calls[0]!; const body = JSON.parse(options.body); - // Hash should be SHA-256 of concatenated raw JSONs + // Hash should be SHA-256 of concatenated raw JSONs (gemini returns "" when not installed) const { createHash } = await import("node:crypto"); - const expectedHash = createHash("sha256").update(ccRaw + codexRaw).digest("hex"); + const expectedHash = createHash("sha256").update(ccRaw + codexRaw + "").digest("hex"); expect(body.hash).toBe(expectedHash); }); @@ -388,11 +396,14 @@ describe("Codex integration", () => { seedConfig(); const today = todayStr(); - // ccusage returns empty, Codex has data + // ccusage returns empty, Codex has data, gemistat not installed mockExecFileSync.mockImplementation(((cmd: string, args: string[]) => { if (args?.some?.((a: string) => typeof a === "string" && a.includes("@ccusage/codex"))) { return codexJson([today]); } + if (args?.some?.((a: string) => typeof a === "string" && a.includes("gemistat"))) { + throw new Error("gemistat not found"); + } return "[]"; // ccusage: no data }) as typeof execFileSync); @@ -456,6 +467,9 @@ describe("Codex integration", () => { if (args?.some?.((a: string) => typeof a === "string" && a.includes("@ccusage/codex"))) { return codexJson([today]); } + if (args?.some?.((a: string) => typeof a === "string" && a.includes("gemistat"))) { + throw new Error("gemistat not found"); + } throw new Error(`No valid Claude data directories found. Please ensure at least one of the following exists: - /Users/test/.config/claude/projects @@ -468,7 +482,7 @@ describe("Codex integration", () => { expect(mockFetch).toHaveBeenCalledTimes(2); expect(console.log).toHaveBeenCalledWith( - "No Claude Code data found locally; syncing Codex usage only.", + "No Claude Code data found locally; syncing other sources only.", ); const [, options] = mockFetch.mock.calls[0]!; diff --git a/packages/cli/__tests__/gemini.test.ts b/packages/cli/__tests__/gemini.test.ts new file mode 100644 index 00000000..bd9b5a1c --- /dev/null +++ b/packages/cli/__tests__/gemini.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { parseGeminiOutput, runGeminiRaw } from "../src/lib/gemini.js"; + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), + execFile: vi.fn(), +})); + +import { execFileSync } from "node:child_process"; +const mockExecFileSync = vi.mocked(execFileSync); + +/** Build a valid gemistat JSON string. */ +function validOutput() { + return JSON.stringify({ + daily: [ + { + date: "2025-06-01", + modelsUsed: ["gemini-2.5-pro"], + inputTokens: 3000, + outputTokens: 1000, + cacheCreationTokens: 200, + cacheReadTokens: 500, + totalCost: 0.15, + }, + ], + totals: { + inputTokens: 3000, + outputTokens: 1000, + cacheCreationTokens: 200, + cacheReadTokens: 500, + totalCost: 0.15, + }, + }); +} + +// --------------------------------------------------------------------------- +// parseGeminiOutput +// --------------------------------------------------------------------------- + +describe("parseGeminiOutput", () => { + it("parses valid gemistat JSON and normalizes fields", () => { + const result = parseGeminiOutput(validOutput()); + expect(result.data).toHaveLength(1); + const entry = result.data[0]!; + expect(entry.date).toBe("2025-06-01"); + expect(entry.costUSD).toBe(0.15); + expect(entry.models).toEqual(["gemini-2.5-pro"]); + expect(entry.inputTokens).toBe(3000); + expect(entry.outputTokens).toBe(1000); + expect(entry.cacheCreationTokens).toBe(200); + expect(entry.cacheReadTokens).toBe(500); + // totalTokens = input + output + cacheCreation + cacheRead + expect(entry.totalTokens).toBe(4700); + }); + + it("parses multi-model output", () => { + const raw = JSON.stringify({ + daily: [ + { + date: "2025-06-01", + modelsUsed: ["gemini-2.5-pro", "gemini-2.5-flash"], + inputTokens: 5000, + outputTokens: 2000, + totalCost: 0.25, + }, + ], + totals: {}, + }); + const result = parseGeminiOutput(raw); + expect(result.data).toHaveLength(1); + expect(result.data[0]!.models).toEqual(["gemini-2.5-pro", "gemini-2.5-flash"]); + }); + + it("returns empty data for invalid JSON (with anomaly)", () => { + const result = parseGeminiOutput("not json"); + expect(result.data).toEqual([]); + expect(result.anomalies).toHaveLength(1); + expect(result.anomalies?.[0]?.mode).toBe("unresolved"); + expect(result.anomalies?.[0]?.source).toBe("gemini"); + expect(result.normalizationSummary?.anomalies).toBe(1); + }); + + it("returns empty data for empty array", () => { + const result = parseGeminiOutput("[]"); + expect(result.data).toEqual([]); + }); + + it("returns empty data when daily is missing", () => { + const result = parseGeminiOutput(JSON.stringify({ something: "else" })); + expect(result.data).toEqual([]); + }); + + it("filters out entries with missing date", () => { + const raw = JSON.stringify({ + daily: [ + { totalCost: 1, modelsUsed: [], inputTokens: 0, outputTokens: 0 }, + { date: "2025-06-01", totalCost: 0.05, modelsUsed: ["gemini-2.5-pro"], inputTokens: 50, outputTokens: 50 }, + ], + totals: {}, + }); + const result = parseGeminiOutput(raw); + expect(result.data).toHaveLength(1); + expect(result.data[0]!.date).toBe("2025-06-01"); + }); + + it("filters out entries with negative cost", () => { + const raw = JSON.stringify({ + daily: [ + { date: "2025-06-01", totalCost: -1, modelsUsed: [], inputTokens: 0, outputTokens: 0 }, + ], + totals: {}, + }); + const result = parseGeminiOutput(raw); + expect(result.data).toEqual([]); + }); + + it("handles entries without cache tokens", () => { + const raw = JSON.stringify({ + daily: [ + { + date: "2025-06-01", + modelsUsed: ["gemini-2.5-flash"], + inputTokens: 500, + outputTokens: 200, + totalCost: 0.03, + }, + ], + totals: {}, + }); + const result = parseGeminiOutput(raw); + expect(result.data[0]!.cacheCreationTokens).toBe(0); + expect(result.data[0]!.cacheReadTokens).toBe(0); + expect(result.data[0]!.totalTokens).toBe(700); + }); +}); + +// --------------------------------------------------------------------------- +// runGeminiRaw — silent failure +// --------------------------------------------------------------------------- + +describe("runGeminiRaw", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExecFileSync.mockReturnValue(validOutput() as never); + }); + + it("returns raw JSON string on success", () => { + const result = runGeminiRaw("20250601", "20250601"); + expect(result).toBe(validOutput()); + }); + + it("converts compact YYYYMMDD dates to ISO YYYY-MM-DD for gemistat", () => { + runGeminiRaw("20250601", "20250607"); + const args = mockExecFileSync.mock.calls[0]![1] as string[]; + // gemistat expects YYYY-MM-DD, not YYYYMMDD + expect(args).toContain("2025-06-01"); + expect(args).toContain("2025-06-07"); + expect(args).not.toContain("20250601"); + expect(args).not.toContain("20250607"); + }); + + it("passes through already-ISO dates unchanged", () => { + runGeminiRaw("2025-06-01", "2025-06-07"); + const args = mockExecFileSync.mock.calls[0]![1] as string[]; + expect(args).toContain("2025-06-01"); + expect(args).toContain("2025-06-07"); + }); + + it("returns empty string on failure (silent)", () => { + mockExecFileSync.mockImplementation(() => { throw new Error("fail"); }); + const result = runGeminiRaw("20250601", "20250601"); + expect(result).toBe(""); + }); +}); diff --git a/packages/cli/__tests__/merge.test.ts b/packages/cli/__tests__/merge.test.ts new file mode 100644 index 00000000..f1a3c02e --- /dev/null +++ b/packages/cli/__tests__/merge.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { mergeEntries } from "../src/commands/push.js"; +import type { CcusageDailyEntry } from "../src/lib/ccusage.js"; + +function makeEntry(overrides: Partial & { date: string }): CcusageDailyEntry { + return { + models: [], + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + ...overrides, + }; +} + +describe("mergeEntries", () => { + it("3-way merge sums costs, unions models, sums tokens", () => { + const claude = [makeEntry({ + date: "2025-06-01", + models: ["claude-sonnet-4-20250514"], + inputTokens: 1000, + outputTokens: 500, + cacheCreationTokens: 100, + cacheReadTokens: 200, + totalTokens: 1800, + costUSD: 0.10, + })]; + const codex = [makeEntry({ + date: "2025-06-01", + models: ["gpt-5-codex"], + inputTokens: 2000, + outputTokens: 800, + cacheCreationTokens: 50, + cacheReadTokens: 100, + totalTokens: 2950, + costUSD: 0.20, + })]; + const gemini = [makeEntry({ + date: "2025-06-01", + models: ["gemini-2.5-pro"], + inputTokens: 3000, + outputTokens: 1000, + cacheCreationTokens: 200, + cacheReadTokens: 500, + totalTokens: 4700, + costUSD: 0.15, + })]; + + const merged = mergeEntries(claude, codex, gemini); + expect(merged).toHaveLength(1); + const entry = merged[0]!; + + expect(entry.date).toBe("2025-06-01"); + expect(entry.models).toEqual(["claude-sonnet-4-20250514", "gpt-5-codex", "gemini-2.5-pro"]); + expect(entry.inputTokens).toBe(6000); + expect(entry.outputTokens).toBe(2300); + expect(entry.cacheCreationTokens).toBe(350); + expect(entry.cacheReadTokens).toBe(800); + expect(entry.totalTokens).toBe(9450); + expect(entry.costUSD).toBeCloseTo(0.45); + }); + + it("gemini-only dates work", () => { + const gemini = [makeEntry({ + date: "2025-06-02", + models: ["gemini-2.5-flash"], + inputTokens: 500, + outputTokens: 200, + totalTokens: 700, + costUSD: 0.03, + })]; + + const merged = mergeEntries([], [], gemini); + expect(merged).toHaveLength(1); + expect(merged[0]!.date).toBe("2025-06-02"); + expect(merged[0]!.models).toEqual(["gemini-2.5-flash"]); + expect(merged[0]!.costUSD).toBe(0.03); + }); + + it("backward compatible without gemini argument", () => { + const claude = [makeEntry({ + date: "2025-06-01", + models: ["claude-sonnet-4-20250514"], + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + costUSD: 0.10, + })]; + const codex = [makeEntry({ + date: "2025-06-01", + models: ["gpt-5-codex"], + inputTokens: 2000, + outputTokens: 800, + totalTokens: 2800, + costUSD: 0.20, + })]; + + // Call without 3rd argument — backward compatible + const merged = mergeEntries(claude, codex); + expect(merged).toHaveLength(1); + expect(merged[0]!.models).toEqual(["claude-sonnet-4-20250514", "gpt-5-codex"]); + expect(merged[0]!.costUSD).toBeCloseTo(0.30); + expect(merged[0]!.totalTokens).toBe(4300); + }); +}); diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index d00578e7..3c6b6296 100755 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -7,6 +7,7 @@ import { apiRequest } from "../lib/api.js"; import { runCcusageRawAsync, parseCcusageOutput } from "../lib/ccusage.js"; import type { CcusageDailyEntry, ModelBreakdownEntry } from "../lib/ccusage.js"; import { runCodexRawAsync, parseCodexOutput } from "../lib/codex.js"; +import { runGeminiRawAsync, parseGeminiOutput } from "../lib/gemini.js"; import { MAX_BACKFILL_DAYS, DEFAULT_SYNC_DAYS } from "../config.js"; import { Spinner } from "../lib/spinner.js"; import type { DashboardData as DashboardResponse } from "../components/PushSummary.js"; @@ -108,8 +109,9 @@ function buildBreakdown(entry: CcusageDailyEntry): ModelBreakdownEntry[] { export function mergeEntries( claudeEntries: CcusageDailyEntry[], codexEntries: CcusageDailyEntry[], + geminiEntries: CcusageDailyEntry[] = [], ): CcusageDailyEntry[] { - const byDate = new Map(); + const byDate = new Map(); for (const e of claudeEntries) { byDate.set(e.date, { ...byDate.get(e.date), claude: e }); @@ -117,26 +119,31 @@ export function mergeEntries( for (const e of codexEntries) { byDate.set(e.date, { ...byDate.get(e.date), codex: e }); } + for (const e of geminiEntries) { + byDate.set(e.date, { ...byDate.get(e.date), gemini: e }); + } const merged: CcusageDailyEntry[] = []; - for (const [date, { claude, codex }] of byDate) { + for (const [date, { claude, codex, gemini }] of byDate) { const claudeBreakdown = claude ? buildBreakdown(claude) : []; const codexBreakdown = codex ? buildBreakdown(codex) : []; - const modelBreakdown = [...claudeBreakdown, ...codexBreakdown]; + const geminiBreakdown = gemini ? buildBreakdown(gemini) : []; + const modelBreakdown = [...claudeBreakdown, ...codexBreakdown, ...geminiBreakdown]; merged.push({ date, models: [ ...(claude?.models ?? []), ...(codex?.models ?? []), + ...(gemini?.models ?? []), ], - inputTokens: (claude?.inputTokens ?? 0) + (codex?.inputTokens ?? 0), - outputTokens: (claude?.outputTokens ?? 0) + (codex?.outputTokens ?? 0), - cacheCreationTokens: (claude?.cacheCreationTokens ?? 0) + (codex?.cacheCreationTokens ?? 0), - cacheReadTokens: (claude?.cacheReadTokens ?? 0) + (codex?.cacheReadTokens ?? 0), - totalTokens: (claude?.totalTokens ?? 0) + (codex?.totalTokens ?? 0), - costUSD: (claude?.costUSD ?? 0) + (codex?.costUSD ?? 0), + inputTokens: (claude?.inputTokens ?? 0) + (codex?.inputTokens ?? 0) + (gemini?.inputTokens ?? 0), + outputTokens: (claude?.outputTokens ?? 0) + (codex?.outputTokens ?? 0) + (gemini?.outputTokens ?? 0), + cacheCreationTokens: (claude?.cacheCreationTokens ?? 0) + (codex?.cacheCreationTokens ?? 0) + (gemini?.cacheCreationTokens ?? 0), + cacheReadTokens: (claude?.cacheReadTokens ?? 0) + (codex?.cacheReadTokens ?? 0) + (gemini?.cacheReadTokens ?? 0), + totalTokens: (claude?.totalTokens ?? 0) + (codex?.totalTokens ?? 0) + (gemini?.totalTokens ?? 0), + costUSD: (claude?.costUSD ?? 0) + (codex?.costUSD ?? 0) + (gemini?.costUSD ?? 0), modelBreakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined, }); } @@ -230,12 +237,13 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) : `Pushing usage for ${formatDate(sinceDate)} to ${formatDate(untilDate)}...`, ); - // Run ccusage + codex in parallel — the single biggest perf win + // Run ccusage + codex + gemini in parallel — the single biggest perf win const scanSpinner = new Spinner("scan"); scanSpinner.start(); - const [claudeResult, codexRaw] = await Promise.all([ + const [claudeResult, codexRaw, geminiRaw] = await Promise.all([ runCcusageRawAsync(sinceStr, untilStr, options.timeoutMs).catch((err: Error) => err), runCodexRawAsync(sinceStr, untilStr, options.timeoutMs), + runGeminiRawAsync(sinceStr, untilStr, options.timeoutMs), ]); scanSpinner.stop(); @@ -246,7 +254,7 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) if (claudeResult instanceof Error) { // Codex-only users do not have local Claude data; keep other Claude failures fatal. if (isMissingClaudeDataError(claudeResult)) { - console.log("No Claude Code data found locally; syncing Codex usage only."); + console.log("No Claude Code data found locally; syncing other sources only."); } else { console.error(claudeResult.message); process.exit(1); @@ -265,7 +273,11 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) // Codex data — silent on fetch failure (empty string), but surface parser anomalies. const codexParsed = codexRaw ? parseCodexOutput(codexRaw) : { data: [], anomalies: [], entryMeta: [] }; - const allAnomalies = [...claudeAnomalies, ...(codexParsed.anomalies ?? [])]; + + // Gemini data — silent on fetch failure (empty string), but surface parser anomalies. + const geminiParsed = geminiRaw ? parseGeminiOutput(geminiRaw) : { data: [], anomalies: [], entryMeta: [] }; + + const allAnomalies = [...claudeAnomalies, ...(codexParsed.anomalies ?? []), ...(geminiParsed.anomalies ?? [])]; const mediumLowCount = allAnomalies.filter((a) => a.confidence !== "high").length; if (mediumLowCount > 0) { const lowCount = allAnomalies.filter((a) => a.confidence === "low").length; @@ -292,8 +304,25 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) const codexEntries = codexParsed.data.filter((entry) => !blockedDates.has(entry.date)); - // Merge Claude + Codex entries by date - const entries = mergeEntries(claudeEntries, codexEntries); + const geminiMetaByDate = new Map((geminiParsed.entryMeta ?? []).map((row) => [row.date, row.meta])); + const geminiBlockedDates = new Set(); + + for (const [date, meta] of geminiMetaByDate) { + if (meta.mode === "unresolved") { + geminiBlockedDates.add(date); + } + } + + if (geminiBlockedDates.size > 0) { + const blocked = [...geminiBlockedDates].sort(); + const reason = "unresolved gemini normalization"; + console.log(`Warning: skipping Gemini rows for ${blocked.length} date(s) due to ${reason}: ${blocked.join(", ")}`); + } + + const geminiEntries = geminiParsed.data.filter((entry) => !geminiBlockedDates.has(entry.date)); + + // Merge Claude + Codex + Gemini entries by date + const entries = mergeEntries(claudeEntries, codexEntries, geminiEntries); if (entries.length === 0) { console.log("No usage data found for the specified period."); @@ -328,8 +357,7 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) } // Compute SHA-256 hash of concatenated raw JSONs - const hashInput = codexRaw ? claudeRaw + codexRaw : claudeRaw; - const hash = createHash("sha256").update(hashInput).digest("hex"); + const hash = createHash("sha256").update(claudeRaw + codexRaw + geminiRaw).digest("hex"); const body: UsageSubmitRequest = { entries: entries.map((entry) => ({ diff --git a/packages/cli/src/components/ModelPalette.tsx b/packages/cli/src/components/ModelPalette.tsx index bb30d67f..2d6a5fca 100644 --- a/packages/cli/src/components/ModelPalette.tsx +++ b/packages/cli/src/components/ModelPalette.tsx @@ -22,6 +22,18 @@ function prettifyModel(model: string): string { } if (/^o4/i.test(normalized)) return 'o4'; if (/^o3/i.test(normalized)) return 'o3'; + // Gemini family: "gemini-3.1-pro-preview" → "Gemini 3.1 Pro" + if (/^gemini-/i.test(normalized)) { + return normalized + .replace(/^gemini-/i, 'Gemini ') + .replace(/-preview.*$/, '') + .replace(/-exp.*$/, '') + .replace(/-/g, ' ') + .replace(/\s+/g, ' ') + .split(' ') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + } // Legacy: broader Claude matching if (normalized.includes('opus')) return 'Claude Opus'; if (normalized.includes('sonnet')) return 'Claude Sonnet'; @@ -45,6 +57,8 @@ function getModelColor(name: string): string { if (/Claude/i.test(name)) return modelColors['Claude Sonnet']!; // OpenAI family → purple shades if (/GPT/i.test(name)) return modelColors['GPT-5']!; + // Gemini family → blue/green shades + if (/Gemini/i.test(name)) return modelColors['Gemini 2.5 Pro'] ?? modelFallback[0]!; if (/^o[34]/i.test(name)) return modelColors['o3']!; // Fallback: hash into palette return modelFallback[hashString(name) % modelFallback.length]!; diff --git a/packages/cli/src/components/theme.ts b/packages/cli/src/components/theme.ts index 40dc38f1..f8b4b2e7 100644 --- a/packages/cli/src/components/theme.ts +++ b/packages/cli/src/components/theme.ts @@ -18,7 +18,7 @@ export const theme = { export type Theme = typeof theme; -// Model color map — Claude = orange, OpenAI = purple +// Model color map — Claude = orange, OpenAI = purple, Gemini = Google brand colors export const modelColors: Record = { 'Claude Opus': '#DF561F', // brand orange 'Claude Sonnet': '#F08A5D', // lighter orange @@ -27,6 +27,14 @@ export const modelColors: Record = { 'GPT-4o': '#A78BFA', // lighter purple 'o3': '#7C3AED', // deeper purple 'o4': '#6D28D9', // deep purple + 'Gemini 3.1 Pro': '#4285F4', // Google blue + 'Gemini 3.1 Flash Lite': '#00ACC1', // cyan + 'Gemini 3 Flash': '#009688', // teal + 'Gemini 2.5 Pro': '#3F51B5', // indigo + 'Gemini 2.5 Flash': '#34A853', // Google green + 'Gemini 2.5 Flash Lite': '#8BC34A', // light green + 'Gemini 2.0 Flash': '#FBBC05', // Google yellow + 'Gemini 2.0 Flash Lite': '#FF8F00', // amber }; // Fallback palette for unknown models (hash-indexed) diff --git a/packages/cli/src/lib/ccusage.ts b/packages/cli/src/lib/ccusage.ts index 1be96d20..0029c009 100755 --- a/packages/cli/src/lib/ccusage.ts +++ b/packages/cli/src/lib/ccusage.ts @@ -166,7 +166,7 @@ export interface CcusageOutput { export interface NormalizationAnomaly { date: string; - source: "ccusage" | "codex"; + source: "ccusage" | "codex" | "gemini"; mode: TokenNormalizationMode; confidence: TokenNormalizationConfidence; consistencyError: number; diff --git a/packages/cli/src/lib/gemini.ts b/packages/cli/src/lib/gemini.ts new file mode 100644 index 00000000..19ae98a0 --- /dev/null +++ b/packages/cli/src/lib/gemini.ts @@ -0,0 +1,198 @@ +import { execFileSync, execFile as execFileCb } from "node:child_process"; +import type { CcusageDailyEntry, NormalizationAnomaly } from "./ccusage.js"; +import { + normalizeTokenBuckets, + summarizeNormalization, + type NormalizationMeta, + type NormalizationSummary, +} from "./token-normalization.js"; +import { DEFAULT_SUBPROCESS_TIMEOUT_MS } from "../config.js"; + +export interface GeminiOutput { + data: CcusageDailyEntry[]; + anomalies?: NormalizationAnomaly[]; + normalizationSummary?: NormalizationSummary; + entryMeta?: Array<{ date: string; meta: NormalizationMeta }>; +} + +// Pin to major version so bunx/npx can use the cached copy without a registry roundtrip. +const GEMISTAT_PKG = "gemistat@1"; + +/** + * Convert compact date (YYYYMMDD) to ISO date (YYYY-MM-DD). + * gemistat expects ISO dates, unlike ccusage which expects compact dates. + */ +function toIsoDate(compact: string): string { + if (compact.length === 8 && !compact.includes("-")) { + return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`; + } + return compact; +} + +/** Returns the raw JSON string from gemistat (for hashing). Empty string on failure. */ +export function runGeminiRaw(sinceDate: string, untilDate: string, timeoutMs?: number): string { + try { + return execGemistat(["daily", "--json", "--since", toIsoDate(sinceDate), "--until", toIsoDate(untilDate)], timeoutMs); + } catch { + return ""; + } +} + +/** Async version — returns raw JSON string without blocking. Empty string on failure. */ +export async function runGeminiRawAsync(sinceDate: string, untilDate: string, timeoutMs?: number): Promise { + try { + return await execGemistatAsync(["daily", "--json", "--since", toIsoDate(sinceDate), "--until", toIsoDate(untilDate)], timeoutMs); + } catch { + return ""; + } +} + +function execGemistat(args: string[], timeoutMs?: number): string { + const cmd = process.versions.bun !== undefined ? "bunx" : "npx"; + const prefix = process.versions.bun !== undefined ? ["--bun"] : ["--yes"]; + + return execFileSync(cmd, [...prefix, GEMISTAT_PKG, ...args], { + encoding: "utf-8", + timeout: timeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024, + shell: process.platform === "win32", + }); +} + +function execGemistatAsync(args: string[], timeoutMs?: number): Promise { + const cmd = process.versions.bun !== undefined ? "bunx" : "npx"; + const prefix = process.versions.bun !== undefined ? ["--bun"] : ["--yes"]; + + return new Promise((resolve, reject) => { + execFileCb(cmd, [...prefix, GEMISTAT_PKG, ...args], { + encoding: "utf-8", + timeout: timeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024, + shell: process.platform === "win32", + }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout); + }); + }); +} + +/** + * Raw shape returned by gemistat (`daily --json`). + * + * Format matches ccusage conventions: + * - Cost: `totalCost` + * - Date: ISO 8601 ("2025-06-01") + * - Models: `modelsUsed: string[]` + * - Cache: separate `cacheCreationTokens` and `cacheReadTokens` + * - No `totalTokens` — computed as input + output + cacheCreation + cacheRead + */ +interface GemistatRawEntry { + date: string; + modelsUsed?: string[]; + inputTokens: number; + outputTokens: number; + cacheCreationTokens?: number; + cacheReadTokens?: number; + totalCost: number; +} + +interface GemistatDailyOutput { + daily: GemistatRawEntry[]; + totals?: { + inputTokens: number; + outputTokens: number; + cacheCreationTokens?: number; + cacheReadTokens?: number; + totalCost: number; + }; +} + +export function parseGeminiOutput(raw: string): GeminiOutput { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { + data: [], + anomalies: [{ + date: "unknown", + source: "gemini", + mode: "unresolved", + confidence: "low", + consistencyError: 0, + warnings: ["Failed to parse gemistat JSON output."], + }], + normalizationSummary: { + total: 1, + anomalies: 1, + byMode: { unresolved: 1 }, + byConfidence: { low: 1 }, + }, + }; + } + + // Empty array = no data + if (Array.isArray(parsed) && (parsed as unknown[]).length === 0) { + return { data: [], anomalies: [], normalizationSummary: summarizeNormalization([]), entryMeta: [] }; + } + + const output = parsed as GemistatDailyOutput; + if (!output.daily || !Array.isArray(output.daily)) { + return { data: [], anomalies: [], normalizationSummary: summarizeNormalization([]), entryMeta: [] }; + } + + const normalizedRows = output.daily + .filter((e) => { + return e.date && typeof e.totalCost === "number" && e.totalCost >= 0; + }) + .map((e) => { + const cacheCreation = e.cacheCreationTokens ?? 0; + const cacheRead = e.cacheReadTokens ?? 0; + const computedTotal = (e.inputTokens ?? 0) + (e.outputTokens ?? 0) + cacheCreation + cacheRead; + + const normalized = normalizeTokenBuckets( + { + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + cacheCreationTokens: cacheCreation, + cacheReadTokens: cacheRead, + totalTokens: computedTotal, + }, + { source: "gemini", cacheSemantics: "separate" }, + ); + + return { + date: e.date, + meta: normalized.meta, + entry: { + date: e.date, + models: e.modelsUsed ?? [], + inputTokens: normalized.normalized.inputTokens, + outputTokens: normalized.normalized.outputTokens, + cacheCreationTokens: normalized.normalized.cacheCreationTokens, + cacheReadTokens: normalized.normalized.cacheReadTokens, + totalTokens: normalized.normalized.totalTokens, + costUSD: e.totalCost, + reasoningOutputTokens: normalized.normalized.reasoningOutputTokens, + } satisfies CcusageDailyEntry, + }; + }); + + const anomalies: NormalizationAnomaly[] = normalizedRows + .filter((row) => row.meta.mode === "unresolved" || row.meta.confidence !== "high" || row.meta.warnings.length > 0) + .map((row) => ({ + date: row.date, + source: "gemini", + mode: row.meta.mode, + confidence: row.meta.confidence, + consistencyError: row.meta.consistencyError, + warnings: row.meta.warnings, + })); + + return { + data: normalizedRows.map((row) => row.entry), + anomalies, + normalizationSummary: summarizeNormalization(normalizedRows.map((row) => row.meta)), + entryMeta: normalizedRows.map((row) => ({ date: row.date, meta: row.meta })), + }; +} diff --git a/packages/cli/src/lib/token-normalization.ts b/packages/cli/src/lib/token-normalization.ts index 60e0457e..f15ce31d 100644 --- a/packages/cli/src/lib/token-normalization.ts +++ b/packages/cli/src/lib/token-normalization.ts @@ -34,7 +34,7 @@ export interface RawTokenBuckets { } export interface TokenSourceHints { - source: "codex" | "ccusage" | "generic"; + source: "codex" | "ccusage" | "gemini" | "generic"; cacheSemantics?: "subset_of_input" | "separate" | "auto"; } From 925d96f112fb99d776653da932a9f5f4c845d704 Mon Sep 17 00:00:00 2001 From: NIHAL NIHALANI Date: Sat, 4 Apr 2026 16:06:51 -0700 Subject: [PATCH 2/2] fix: use correct gemistat@0 version pin (no v1.x exists on npm) The gemistat package only has 0.x releases (0.0.0, 0.1.0, 0.1.1). Pinning to @1 caused silent subprocess failures since bunx/npx could not resolve the non-existent major version. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/lib/gemini.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/gemini.ts b/packages/cli/src/lib/gemini.ts index 19ae98a0..8beca09a 100644 --- a/packages/cli/src/lib/gemini.ts +++ b/packages/cli/src/lib/gemini.ts @@ -16,7 +16,7 @@ export interface GeminiOutput { } // Pin to major version so bunx/npx can use the cached copy without a registry roundtrip. -const GEMISTAT_PKG = "gemistat@1"; +const GEMISTAT_PKG = "gemistat@0"; /** * Convert compact date (YYYYMMDD) to ISO date (YYYY-MM-DD).