From a374e230ff21e3b9ba003f5c830c8e0512d63c57 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Wed, 6 May 2026 17:41:34 -0700 Subject: [PATCH 1/7] feat(cli): add agentsview adapter alongside ccusage Introduces a new collector adapter for agentsview >= 0.26.1 that mirrors the ccusage v18 contract. The adapter probes the binary on PATH without spawning a subprocess, version-gates by parsing `agentsview version` (tolerates pre-release/build suffixes), and invokes `agentsview usage daily --json --breakdown --agent claude --offline --since/--until/--timezone` for the requested window. Reuses the shared parseDailyUsageOutput so the agentsview output path benefits from the same token-normalization and bounds checks as ccusage. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/__tests__/agentsview.test.ts | 174 ++++++++++++++++++++++ packages/cli/src/lib/agentsview.ts | 149 ++++++++++++++++++ packages/cli/src/lib/binary.ts | 11 ++ packages/cli/src/lib/ccusage.ts | 53 ++++--- 4 files changed, 359 insertions(+), 28 deletions(-) create mode 100644 packages/cli/__tests__/agentsview.test.ts create mode 100644 packages/cli/src/lib/agentsview.ts create mode 100644 packages/cli/src/lib/binary.ts diff --git a/packages/cli/__tests__/agentsview.test.ts b/packages/cli/__tests__/agentsview.test.ts new file mode 100644 index 0000000..657e44a --- /dev/null +++ b/packages/cli/__tests__/agentsview.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + _resetAgentsViewResolver, + hasAgentsView, + isSupportedAgentsViewVersion, + parseAgentsViewOutput, + parseAgentsViewVersion, + runAgentsViewRawAsync, +} from "../src/lib/agentsview.js"; + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: vi.fn(() => false) }; +}); + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +const mockExecFile = vi.mocked(execFile); +const mockExistsSync = vi.mocked(existsSync); + +function agentsViewOutput() { + return JSON.stringify({ + daily: [ + { + date: "2026-04-12", + inputTokens: 33410, + outputTokens: 142805, + cacheCreationTokens: 301223, + cacheReadTokens: 2984511, + totalCost: 9.6052, + modelsUsed: ["claude-opus-4-6", "gpt-5.1"], + modelBreakdowns: [ + { + modelName: "claude-opus-4-6", + inputTokens: 28102, + outputTokens: 124901, + cacheCreationTokens: 287441, + cacheReadTokens: 2812004, + cost: 8.4123, + }, + ], + }, + ], + totals: { + inputTokens: 33410, + outputTokens: 142805, + cacheCreationTokens: 301223, + cacheReadTokens: 2984511, + totalCost: 9.6052, + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + _resetAgentsViewResolver(); + mockExistsSync.mockReturnValue(true); +}); + +describe("parseAgentsViewOutput", () => { + it("parses agentsview daily JSON and derives totalTokens when omitted", () => { + const result = parseAgentsViewOutput(agentsViewOutput()); + + expect(result.data).toHaveLength(1); + expect(result.data[0]).toEqual({ + date: "2026-04-12", + models: ["claude-opus-4-6", "gpt-5.1"], + inputTokens: 33410, + outputTokens: 142805, + cacheCreationTokens: 301223, + cacheReadTokens: 2984511, + totalTokens: 3_461_949, + costUSD: 9.6052, + reasoningOutputTokens: 0, + modelBreakdown: [{ model: "claude-opus-4-6", cost_usd: 8.4123 }], + }); + }); + + it("labels parse errors as agentsview errors", () => { + expect(() => parseAgentsViewOutput("nope")).toThrow("Failed to parse agentsview output as JSON"); + expect(() => parseAgentsViewOutput(JSON.stringify({ daily: "nope" }))).toThrow( + "Unexpected agentsview output format", + ); + }); +}); + +describe("runAgentsViewRawAsync", () => { + it("runs agentsview usage daily with deterministic pricing and dashed dates", async () => { + mockExecFile.mockImplementation(((_cmd, _args, _options, callback) => { + callback(null, agentsViewOutput(), ""); + return {} as ReturnType; + }) as typeof execFile); + + const result = await runAgentsViewRawAsync( + "2026-04-01", + "2026-04-12", + 10_000, + { agent: "claude", timezone: "America/Vancouver" }, + ); + + expect(result).toBe(agentsViewOutput()); + expect(mockExecFile).toHaveBeenCalledWith( + "agentsview", + [ + "usage", + "daily", + "--json", + "--breakdown", + "--agent", + "claude", + "--offline", + "--since", + "2026-04-01", + "--until", + "2026-04-12", + "--timezone", + "America/Vancouver", + ], + expect.objectContaining({ timeout: 10_000 }), + expect.any(Function), + ); + }); + + it("parses and compares agentsview versions", () => { + expect(parseAgentsViewVersion("agentsview v0.26.1 (commit abc)")).toBe("0.26.1"); + expect(isSupportedAgentsViewVersion("0.26.1")).toBe(true); + expect(isSupportedAgentsViewVersion("0.27.0")).toBe(true); + expect(isSupportedAgentsViewVersion("0.25.0")).toBe(false); + expect(isSupportedAgentsViewVersion(null)).toBe(false); + }); + + it("captures pre-release and build suffixes and treats RCs as equal to the base version", () => { + // Pre-release: capture the full `0.26.1-rc.1` string, but compareVersions + // strips the suffix so it's treated as `0.26.1` (passes the gate). + expect(parseAgentsViewVersion("agentsview v0.26.1-rc.1")).toBe("0.26.1-rc.1"); + expect(isSupportedAgentsViewVersion("0.26.1-rc.1")).toBe(true); + + // Build metadata: same idea — captured then stripped for comparison. + expect(parseAgentsViewVersion("agentsview v0.27.0+build.1")).toBe("0.27.0+build.1"); + expect(isSupportedAgentsViewVersion("0.27.0+build.1")).toBe(true); + + // Pre-release of an unsupported version is still unsupported. + expect(isSupportedAgentsViewVersion("0.25.9-rc.1")).toBe(false); + }); + + it("captures only the X.Y.Z core when a fourth component is present", () => { + // Documents the deliberate behavior: `0.26.1.1` matches as `0.26.1` (the + // `\b` boundary stops capture before the `.1`). We accept this — a 4-part + // version is non-standard and treating it as the leading 3-part version + // is preferable to crashing. + expect(parseAgentsViewVersion("agentsview v0.26.1.1")).toBe("0.26.1"); + expect(isSupportedAgentsViewVersion("0.26.1")).toBe(true); + }); + + it("reports availability from PATH without spawning a process", () => { + mockExistsSync.mockReturnValueOnce(true); + + expect(hasAgentsView()).toBe(true); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it("throws a useful installation error when agentsview is missing", async () => { + mockExistsSync.mockReturnValue(false); + + await expect(runAgentsViewRawAsync("2026-04-01", "2026-04-12")).rejects.toThrow( + "agentsview is not installed or not on PATH", + ); + expect(mockExecFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/lib/agentsview.ts b/packages/cli/src/lib/agentsview.ts new file mode 100644 index 0000000..5ad0026 --- /dev/null +++ b/packages/cli/src/lib/agentsview.ts @@ -0,0 +1,149 @@ +import { execFile as execFileCb } from "node:child_process"; +import { DEFAULT_SUBPROCESS_TIMEOUT_MS } from "../config.js"; +import { isBinaryOnPath } from "./binary.js"; +import { parseDailyUsageOutput, type CcusageOutput } from "./ccusage.js"; + +interface ExecError extends Error { + stderr?: string; + signal?: string | null; + killed?: boolean; +} + +export const AGENTSVIEW_COLLECTOR = "agentsview-v1" as const; +export const MIN_AGENTSVIEW_VERSION = "0.26.1"; + +let _resolved: { cmd: string; args: string[] } | undefined; + +function resolveAgentsViewCommand(): { cmd: string; args: string[] } { + if (_resolved) return _resolved; + + if (isBinaryOnPath("agentsview")) { + _resolved = { cmd: "agentsview", args: [] }; + return _resolved; + } + + throw new Error( + "agentsview is not installed or not on PATH. Install it from https://www.agentsview.io/ or set STRAUDE_COLLECTOR=legacy.", + ); +} + +export function hasAgentsView(): boolean { + try { + resolveAgentsViewCommand(); + return true; + } catch { + return false; + } +} + +export function _resetAgentsViewResolver(): void { + _resolved = undefined; +} + +function execAgentsViewAsync(args: string[], timeoutMs?: number): Promise { + const { cmd, args: prefix } = resolveAgentsViewCommand(); + const cmdArgs = [...prefix, ...args]; + + return new Promise((resolve, reject) => { + execFileCb(cmd, cmdArgs, { + encoding: "utf-8", + timeout: timeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024, + shell: process.platform === "win32", + }, (err, stdout) => { + if (!err) { + resolve(stdout); + return; + } + const error = err as ExecError; + if (error.killed || error.signal === "SIGTERM") { + reject(new Error( + "agentsview timed out. Try running `agentsview usage daily --json --offline` directly to verify it works.", + )); + return; + } + const detail = error.stderr?.trim() || error.message || "unknown error"; + reject(new Error(`agentsview failed: ${detail}`)); + }); + }); +} + +export function parseAgentsViewVersion(raw: string): string | null { + // Capture `vX.Y.Z` plus optional `-prerelease` and `+build` suffixes so we + // tolerate values like `0.26.1-rc.1` or `0.27.0+build.1`. The captured + // string preserves the suffix; `compareVersions` strips it before comparing. + const match = raw.match(/\bv?(\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?)\b/); + return match ? match[1]! : null; +} + +function compareVersions(a: string, b: string): number { + // Strip pre-release (`-…`) and build (`+…`) suffixes — we treat a release + // candidate like `0.26.1-rc.1` as equivalent to `0.26.1` so RCs of a + // supported version pass the gate. Strict semver would order pre-releases + // before the final release, but for our gating purposes that's stricter + // than we want. + const stripSuffix = (v: string): string => v.split(/[-+]/)[0]!; + const aParts = stripSuffix(a).split(".").map((part) => Number(part)); + const bParts = stripSuffix(b).split(".").map((part) => Number(part)); + for (let i = 0; i < 3; i++) { + const delta = (aParts[i] ?? 0) - (bParts[i] ?? 0); + if (delta !== 0) return delta; + } + return 0; +} + +export function isSupportedAgentsViewVersion(version: string | null): boolean { + return version != null && compareVersions(version, MIN_AGENTSVIEW_VERSION) >= 0; +} + +export async function getAgentsViewVersion(timeoutMs?: number): Promise { + const raw = await execAgentsViewAsync(["version"], timeoutMs); + const version = parseAgentsViewVersion(raw); + if (!version) { + throw new Error("Unable to determine agentsview version from `agentsview version` output."); + } + return version; +} + +export async function isSupportedAgentsViewInstalled(timeoutMs?: number): Promise { + if (!hasAgentsView()) return false; + try { + return isSupportedAgentsViewVersion(await getAgentsViewVersion(timeoutMs)); + } catch { + return false; + } +} + +export async function runAgentsViewRawAsync( + sinceDate: string, + untilDate: string, + timeoutMs?: number, + options: { timezone?: string; agent?: string; offline?: boolean } = {}, +): Promise { + const args = [ + "usage", + "daily", + "--json", + "--breakdown", + ]; + if (options.agent) { + args.push("--agent", options.agent); + } + if (options.offline !== false) { + args.push("--offline"); + } + args.push( + "--since", + sinceDate, + "--until", + untilDate, + ); + if (options.timezone) { + args.push("--timezone", options.timezone); + } + return execAgentsViewAsync(args, timeoutMs); +} + +export function parseAgentsViewOutput(raw: string): CcusageOutput { + return parseDailyUsageOutput(raw, "agentsview"); +} diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts new file mode 100644 index 0000000..b139bb4 --- /dev/null +++ b/packages/cli/src/lib/binary.ts @@ -0,0 +1,11 @@ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +/** Check if a binary exists on PATH without spawning a subprocess. */ +export function isBinaryOnPath(binary: string): boolean { + const dirs = (process.env.PATH || "").split(delimiter); + const suffixes = process.platform === "win32" ? ["", ".cmd", ".exe"] : [""]; + return dirs.some((dir) => + suffixes.some((ext) => existsSync(join(dir, binary + ext))), + ); +} diff --git a/packages/cli/src/lib/ccusage.ts b/packages/cli/src/lib/ccusage.ts index 4fd2b56..c253ca1 100755 --- a/packages/cli/src/lib/ccusage.ts +++ b/packages/cli/src/lib/ccusage.ts @@ -1,6 +1,4 @@ import { execFileSync, execFile as execFileCb } from "node:child_process"; -import { existsSync } from "node:fs"; -import { delimiter, join } from "node:path"; import { normalizeTokenBuckets, summarizeNormalization, @@ -10,6 +8,7 @@ import { type TokenNormalizationMode, } from "./token-normalization.js"; import { DEFAULT_SUBPROCESS_TIMEOUT_MS } from "../config.js"; +import { isBinaryOnPath } from "./binary.js"; import { isInteractive, promptYesNo } from "./prompt.js"; import { posthog } from "./posthog.js"; import { getDistinctId } from "./machine-id.js"; @@ -27,15 +26,6 @@ interface ExecError extends Error { /** Resolved ccusage command. Cached after first resolution. */ let _resolved: { cmd: string; args: string[] } | undefined; -/** Check if a binary exists on PATH without spawning a subprocess. */ -function isOnPath(binary: string): boolean { - const dirs = (process.env.PATH || "").split(delimiter); - const suffixes = process.platform === "win32" ? ["", ".cmd", ".exe"] : [""]; - return dirs.some((dir) => - suffixes.some((ext) => existsSync(join(dir, binary + ext))), - ); -} - /** * Resolve how to run ccusage. * For security reasons we only execute an explicitly installed `ccusage` @@ -45,7 +35,7 @@ function resolveCcusageCommand(): { cmd: string; args: string[] } { if (_resolved) return _resolved; // Check if ccusage binary exists on PATH (pure fs, no subprocess) - if (isOnPath("ccusage")) { + if (isBinaryOnPath("ccusage")) { _resolved = { cmd: "ccusage", args: [] }; return _resolved; } @@ -62,7 +52,7 @@ export function _resetCcusageResolver(): void { /** Whether ccusage is currently resolvable on PATH. */ export function isCcusageInstalled(): boolean { - return isOnPath("ccusage"); + return isBinaryOnPath("ccusage"); } /** @@ -83,7 +73,7 @@ export function pickInstallCommand(env: { hasBun: boolean }): { cmd: string; arg * stdio is inherited so the user sees install progress. */ function installCcusage(): void { - const { cmd, args } = pickInstallCommand({ hasBun: isOnPath("bun") }); + const { cmd, args } = pickInstallCommand({ hasBun: isBinaryOnPath("bun") }); execFileSync(cmd, args, { stdio: "inherit", timeout: 5 * 60 * 1000, @@ -137,7 +127,7 @@ export async function ensureCcusageInstalled( posthog.capture({ distinctId, event: "ccusage_install_attempted", - properties: { manager: pickInstallCommand({ hasBun: isOnPath("bun") }).manager }, + properties: { manager: pickInstallCommand({ hasBun: isBinaryOnPath("bun") }).manager }, }); try { @@ -260,7 +250,7 @@ interface CcusageV18Entry { outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; - totalTokens: number; + totalTokens?: number; totalCost: number; modelBreakdowns?: Array<{ modelName: string; cost: number }>; } @@ -272,7 +262,7 @@ interface CcusageV18Output { outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; - totalTokens: number; + totalTokens?: number; totalCost: number; }; } @@ -285,7 +275,7 @@ export interface CcusageOutput { export interface NormalizationAnomaly { date: string; - source: "ccusage" | "codex"; + source: "ccusage" | "codex" | "agentsview"; mode: TokenNormalizationMode; confidence: TokenNormalizationConfidence; consistencyError: number; @@ -313,8 +303,10 @@ export function runCcusageRawAsync(sinceDate: string, untilDate: string, timeout return execCcusageAsync(args, timeoutMs); } -/** Normalize a v18 entry into our canonical format. */ -function normalizeEntry(raw: CcusageV18Entry): { entry: CcusageDailyEntry; meta: NormalizationMeta } { +type DailyUsageSource = "ccusage" | "agentsview"; + +/** Normalize a ccusage-compatible daily entry into our canonical format. */ +function normalizeEntry(raw: CcusageV18Entry, source: DailyUsageSource): { entry: CcusageDailyEntry; meta: NormalizationMeta } { const normalized = normalizeTokenBuckets( { inputTokens: raw.inputTokens, @@ -323,7 +315,7 @@ function normalizeEntry(raw: CcusageV18Entry): { entry: CcusageDailyEntry; meta: cacheReadTokens: raw.cacheReadTokens, totalTokens: raw.totalTokens, }, - { source: "ccusage", cacheSemantics: "separate" }, + { source: source === "ccusage" ? "ccusage" : "generic", cacheSemantics: "separate" }, ); return { @@ -343,12 +335,13 @@ function normalizeEntry(raw: CcusageV18Entry): { entry: CcusageDailyEntry; meta: }; } -export function parseCcusageOutput(raw: string): CcusageOutput { +export function parseDailyUsageOutput(raw: string, source: DailyUsageSource): CcusageOutput { + const label = source === "agentsview" ? "agentsview" : "ccusage"; let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { - throw new Error("Failed to parse ccusage output as JSON", { cause: err }); + throw new Error(`Failed to parse ${label} output as JSON`, { cause: err }); } // ccusage returns `[]` when there's no data for the period @@ -358,15 +351,15 @@ export function parseCcusageOutput(raw: string): CcusageOutput { const v18 = parsed as CcusageV18Output; if (!Array.isArray(v18.daily)) { - throw new Error("Unexpected ccusage output format (expected 'daily' array)"); + throw new Error(`Unexpected ${label} output format (expected 'daily' array)`); } - const normalizedRows = v18.daily.map(normalizeEntry); + const normalizedRows = v18.daily.map((entry) => normalizeEntry(entry, source)); const data = normalizedRows.map((row) => row.entry); for (const entry of data) { - if (!entry.date || typeof entry.costUSD !== "number") { - throw new Error(`Invalid entry in ccusage output for date: ${entry.date}`); + if (!entry.date || typeof entry.costUSD !== "number" || !Number.isFinite(entry.costUSD)) { + throw new Error(`Invalid entry in ${label} output for date: ${entry.date}`); } if (entry.costUSD < 0) { throw new Error(`Negative cost for date: ${entry.date}`); @@ -380,7 +373,7 @@ export function parseCcusageOutput(raw: string): CcusageOutput { .filter((row) => row.meta.mode === "unresolved" || row.meta.confidence !== "high" || row.meta.warnings.length > 0) .map((row) => ({ date: row.entry.date, - source: "ccusage", + source, mode: row.meta.mode, confidence: row.meta.confidence, consistencyError: row.meta.consistencyError, @@ -393,3 +386,7 @@ export function parseCcusageOutput(raw: string): CcusageOutput { normalizationSummary: summarizeNormalization(normalizedRows.map((row) => row.meta)), }; } + +export function parseCcusageOutput(raw: string): CcusageOutput { + return parseDailyUsageOutput(raw, "ccusage"); +} From 9c953f34024b380502ea31e2455e359cb6a5e71f Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Wed, 6 May 2026 17:41:48 -0700 Subject: [PATCH 2/7] feat(cli): wire agentsview path into push + collector telemetry Adds STRAUDE_COLLECTOR=auto|agentsview|legacy in straude push. - auto: prefer agentsview >= 0.26.1 for Claude when available; keep Straude's native Codex collector for Codex; fall back to legacy when agentsview is missing/outdated, or while the one-time native-Codex repair is pending. - agentsview: require agentsview >= 0.26.1; still keep native Codex. - legacy: ccusage + native Codex. A 3s probe gates the agentsview path so a stuck binary cannot make the command feel hung. Bumps packages/cli to 1.0.0. Mirrors the legacy "no Claude data" carve-out for the agentsview branch: agentsview errors that match a conservative pattern downgrade to Codex-only sync; genuine failures still exit fatally. Adds collector_mode/collector_preference/agentsview_available/ codex_repair_pending/agentsview_version to the usage_pushed PostHog event so we can dashboard rollout shape and detect parity drift. Telemetry is wrapped in try/catch. `straude push --debug` now also prints agentsview availability+version, the codex-native repair flag and timestamp, the IANA timezone passed to agentsview, and the offline-pricing mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 2 +- packages/cli/README.md | 21 +- packages/cli/__tests__/commands/push.test.ts | 155 ++++++++ packages/cli/package.json | 4 +- packages/cli/src/commands/push.ts | 397 ++++++++++++++----- packages/cli/src/index.ts | 3 +- 6 files changed, 464 insertions(+), 118 deletions(-) diff --git a/bun.lock b/bun.lock index 1cd3117..88a29fc 100644 --- a/bun.lock +++ b/bun.lock @@ -67,7 +67,7 @@ }, "packages/cli": { "name": "straude", - "version": "0.1.23", + "version": "1.0.0", "bin": { "straude": "dist/index.js", }, diff --git a/packages/cli/README.md b/packages/cli/README.md index 25b0628..adb2658 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # straude CLI -Push your Claude Code usage stats to [Straude](https://straude.com). +Push your AI coding usage stats to [Straude](https://straude.com). ## Quick start @@ -15,7 +15,8 @@ Running with no arguments performs a smart sync: logs you in if needed, then pus ## Requirements - Node 18+ -- [Claude Code](https://claude.ai/code) installed (provides the `ccusage` binary) +- Optional: [agentsview](https://www.agentsview.io/) >= 0.26.1 for Claude Code collection +- Fallback: `ccusage` on PATH for Claude Code collection ## Commands @@ -25,7 +26,7 @@ Running with no arguments performs a smart sync: logs you in if needed, then pus straude ``` -- First run: opens a browser tab to authenticate, then pushes today's usage. +- First run: opens a browser tab to authenticate, then pushes recent usage. - Subsequent runs: pushes all days since the last sync (up to 7 days). - Already synced today: prints today's stats and exits. @@ -47,10 +48,20 @@ Push usage data to Straude. | Flag | Description | |---|---| -| `--date YYYY-MM-DD` | Push a specific date (must be within the last 7 days) | -| `--days N` | Push the last N days (max 7) | +| `--date YYYY-MM-DD` | Push a specific date (must be within the last 30 days) | +| `--days N` | Push the last N days (max 30) | | `--dry-run` | Preview what would be submitted without posting | +## Collectors + +CLI 1.0 uses `STRAUDE_COLLECTOR=auto|agentsview|legacy`. + +- `auto` (default): use agentsview >= 0.26.1 for Claude Code when available; keep Straude's native Codex collector for Codex; fall back to legacy when agentsview is missing/outdated. +- `agentsview`: require agentsview >= 0.26.1 for Claude Code; still keep native Codex for Codex accuracy. +- `legacy`: force ccusage for Claude Code plus native Codex. + +Native Codex remains in place because it contains Straude's fork-heavy session repair for inflated Codex totals. + ### `status` ```sh diff --git a/packages/cli/__tests__/commands/push.test.ts b/packages/cli/__tests__/commands/push.test.ts index 62e6369..0421bd8 100755 --- a/packages/cli/__tests__/commands/push.test.ts +++ b/packages/cli/__tests__/commands/push.test.ts @@ -20,6 +20,15 @@ vi.mock("../../src/lib/ccusage.js", () => ({ ensureCcusageInstalled: vi.fn(() => Promise.resolve()), })); +vi.mock("../../src/lib/agentsview.js", () => ({ + AGENTSVIEW_COLLECTOR: "agentsview-v1", + MIN_AGENTSVIEW_VERSION: "0.26.1", + isSupportedAgentsViewInstalled: vi.fn(), + getAgentsViewVersion: vi.fn(), + runAgentsViewRawAsync: vi.fn(), + parseAgentsViewOutput: vi.fn(), +})); + vi.mock("../../src/lib/codex-native.js", () => ({ CODEX_NATIVE_COLLECTOR: "straude-codex-native-v1", collectCodexUsageAsync: vi.fn(), @@ -36,6 +45,7 @@ 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 { isSupportedAgentsViewInstalled, getAgentsViewVersion, runAgentsViewRawAsync, parseAgentsViewOutput } from "../../src/lib/agentsview.js"; import { collectCodexUsageAsync, containsSessionFile } from "../../src/lib/codex-native.js"; import { reportUsagePushFailed } from "../../src/lib/telemetry.js"; @@ -45,6 +55,10 @@ const mockSaveConfig = vi.mocked(saveConfig); const mockApiRequest = vi.mocked(apiRequest); const mockRunCcusageRawAsync = vi.mocked(runCcusageRawAsync); const mockParseCcusageOutput = vi.mocked(parseCcusageOutput); +const mockIsSupportedAgentsViewInstalled = vi.mocked(isSupportedAgentsViewInstalled); +const mockGetAgentsViewVersion = vi.mocked(getAgentsViewVersion); +const mockRunAgentsViewRawAsync = vi.mocked(runAgentsViewRawAsync); +const mockParseAgentsViewOutput = vi.mocked(parseAgentsViewOutput); const mockCollectCodexUsageAsync = vi.mocked(collectCodexUsageAsync); const mockHasCodexLogs = vi.mocked(containsSessionFile); const mockReportUsagePushFailed = vi.mocked(reportUsagePushFailed); @@ -77,6 +91,21 @@ function codexEntry(date: string, overrides: Record = {}) { }; } +function agentsViewClaudeEntry(date: string, overrides: Record = {}) { + return { + date, + models: ["claude-opus-4-20250505"], + inputTokens: 1000, + outputTokens: 500, + cacheCreationTokens: 100, + cacheReadTokens: 50, + totalTokens: 1650, + costUSD: 10.0, + modelBreakdown: [{ model: "claude-opus-4-20250505", cost_usd: 10.0 }], + ...overrides, + }; +} + class ExitError extends Error { code: number; constructor(code: number) { @@ -97,6 +126,8 @@ beforeEach(() => { vi.useFakeTimers({ now: new Date('2026-03-13T12:00:00Z'), toFake: ['Date'] }); vi.clearAllMocks(); mockLoadConfig.mockReturnValue(fakeConfig); + mockIsSupportedAgentsViewInstalled.mockResolvedValue(false); + mockGetAgentsViewVersion.mockResolvedValue("0.26.1"); // Default: no Codex data mockHasCodexLogs.mockResolvedValue(false); mockCollectCodexUsageAsync.mockResolvedValue(codexOutput()); @@ -434,6 +465,130 @@ describe("pushCommand", () => { }); }); + it("uses agentsview for Claude and native Codex for Codex when agentsview is supported", async () => { + const today = todayStr(); + mockIsSupportedAgentsViewInstalled.mockResolvedValue(true); + mockLoadConfig.mockReturnValue({ + ...fakeConfig, + codex_native_repair_completed_at: "2026-03-01T00:00:00.000Z", + }); + mockRunAgentsViewRawAsync.mockResolvedValue("agentsview-json"); + mockParseAgentsViewOutput.mockReturnValue({ + data: [agentsViewClaudeEntry(today)], + }); + mockCollectCodexUsageAsync.mockResolvedValue(codexOutput([codexEntry(today)])); + 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({}); + + expect(mockRunCcusageRawAsync).not.toHaveBeenCalled(); + expect(mockRunAgentsViewRawAsync).toHaveBeenCalledWith( + expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + undefined, + expect.objectContaining({ agent: "claude" }), + ); + + const submitCall = mockApiRequest.mock.calls[0]!; + const body = JSON.parse(submitCall[2]!.body as string); + expect(body.entries[0].data.costUSD).toBe(13.0); + expect(body.entries[0].data.models).toContain("claude-opus-4-20250505"); + expect(body.entries[0].data.models).toContain("gpt-5-codex"); + expect(body.collector).toEqual({ + claude: "agentsview-v1", + codex: "straude-codex-native-v1", + }); + }); + + it("proceeds with Codex data only when agentsview reports no Claude data", async () => { + const today = todayStr(); + mockIsSupportedAgentsViewInstalled.mockResolvedValue(true); + mockLoadConfig.mockReturnValue({ + ...fakeConfig, + codex_native_repair_completed_at: "2026-03-01T00:00:00.000Z", + }); + // Simulate agentsview erroring out because there are no Claude sessions. + // The exact phrasing is best-guess until we have real fixtures; the + // matcher in `isMissingAgentsViewClaudeDataError` is intentionally + // conservative. + mockRunAgentsViewRawAsync.mockRejectedValue( + new Error("agentsview failed: no claude sessions found in ~/.claude"), + ); + mockCollectCodexUsageAsync.mockResolvedValue(codexOutput([codexEntry(today)])); + 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({}); + + expect(mockParseAgentsViewOutput).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + "No Claude Code data found locally; syncing Codex usage only.", + ); + + const submitCall = mockApiRequest.mock.calls[0]!; + const body = JSON.parse(submitCall[2]!.body as string); + expect(body.entries).toHaveLength(1); + expect(body.entries[0]!.data.models).toEqual(["gpt-5-codex"]); + expect(body.entries[0]!.data.costUSD).toBe(3.0); + expect(body.collector).toEqual({ codex: "straude-codex-native-v1" }); + }); + + it("still fails fatally on generic agentsview errors", async () => { + const today = todayStr(); + mockIsSupportedAgentsViewInstalled.mockResolvedValue(true); + mockLoadConfig.mockReturnValue({ + ...fakeConfig, + codex_native_repair_completed_at: "2026-03-01T00:00:00.000Z", + }); + mockRunAgentsViewRawAsync.mockRejectedValue( + new Error("agentsview failed: unexpected runtime error"), + ); + mockCollectCodexUsageAsync.mockResolvedValue(codexOutput([codexEntry(today)])); + + await expect(pushCommand({})).rejects.toThrow(ExitError); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it("keeps auto on legacy while the one-time Codex repair is pending", async () => { + const today = todayStr(); + mockIsSupportedAgentsViewInstalled.mockResolvedValue(true); + mockHasCodexLogs.mockResolvedValue(true); + mockLoadConfig.mockReturnValue({ + ...fakeConfig, + device_id: "device-1", + device_name: "work-laptop", + }); + mockRunCcusageRawAsync.mockResolvedValue("{}"); + mockParseCcusageOutput.mockReturnValue({ + data: [agentsViewClaudeEntry(today)], + }); + mockCollectCodexUsageAsync.mockResolvedValue(codexOutput([codexEntry(today)])); + mockApiRequest.mockResolvedValue({ + results: [ + { date: today, usage_id: "u-1", post_id: "p-1", post_url: "https://straude.com/post/p-1", action: "updated" }, + ], + }); + + await pushCommand({}); + + expect(mockRunAgentsViewRawAsync).not.toHaveBeenCalled(); + expect(mockRunCcusageRawAsync).toHaveBeenCalled(); + const submitCall = mockApiRequest.mock.calls[0]!; + const body = JSON.parse(submitCall[2]!.body as string); + expect(body.collector).toEqual({ + claude: "ccusage-v18", + codex: "straude-codex-native-v1", + }); + }); + it("generates device_id on first push and persists to config", async () => { const today = todayStr(); // Config without device_id diff --git a/packages/cli/package.json b/packages/cli/package.json index d48ee64..acdaa9a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "straude", - "version": "0.1.24", - "description": "CLI for pushing Claude Code usage stats to Straude", + "version": "1.0.0", + "description": "CLI for pushing AI coding usage stats to Straude", "main": "dist/index.js", "bin": { "straude": "dist/index.js" diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index 08fcb80..022f3ef 100755 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -6,6 +6,14 @@ import { loginCommand } from "./login.js"; import { apiRequest } from "../lib/api.js"; import { runCcusageRawAsync, parseCcusageOutput, ensureCcusageInstalled } from "../lib/ccusage.js"; import type { CcusageDailyEntry, ModelBreakdownEntry } from "../lib/ccusage.js"; +import { + AGENTSVIEW_COLLECTOR, + MIN_AGENTSVIEW_VERSION, + getAgentsViewVersion, + isSupportedAgentsViewInstalled, + parseAgentsViewOutput, + runAgentsViewRawAsync, +} from "../lib/agentsview.js"; import { CODEX_NATIVE_COLLECTOR, collectCodexUsageAsync, @@ -27,8 +35,9 @@ interface UsageSubmitRequest { }>; hash?: string; collector?: { - claude?: "ccusage-v18"; + claude?: "ccusage-v18" | typeof AGENTSVIEW_COLLECTOR; codex?: typeof CODEX_NATIVE_COLLECTOR; + unified?: typeof AGENTSVIEW_COLLECTOR; }; source: "cli" | "web"; device_id?: string; @@ -55,6 +64,9 @@ interface PushOptions { timeoutMs?: number; } +type CollectorPreference = "auto" | "agentsview" | "legacy"; +type SelectedCollector = "agentsview-claude-native-codex" | "legacy"; + function isMissingClaudeDataError(error: Error): boolean { return error.message.includes("No valid Claude data directories found"); } @@ -63,6 +75,11 @@ function isCcusageNotInstalledError(error: Error): boolean { return error.message.includes("ccusage is not installed or not on PATH"); } +function isMissingAgentsViewClaudeDataError(error: Error): boolean { + const message = error.message ?? ""; + return /no\s+claude\s+(?:code\s+)?(?:data|sessions?|directories|director(?:y|ies))/i.test(message); +} + function formatDate(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); @@ -77,6 +94,31 @@ function formatDateCompact(d: Date): string { return `${y}${m}${day}`; } +function localTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + +function readCollectorPreference(): CollectorPreference { + const value = (process.env.STRAUDE_COLLECTOR ?? "auto").toLowerCase(); + if (value === "auto" || value === "agentsview" || value === "legacy") return value; + throw new Error("Invalid STRAUDE_COLLECTOR value. Expected auto, agentsview, or legacy."); +} + +function selectCollector( + preference: CollectorPreference, + shouldRunCodexRepair: boolean, + agentsViewAvailable: boolean, +): SelectedCollector { + if (preference === "legacy") return "legacy"; + if (preference === "agentsview") return "agentsview-claude-native-codex"; + if (shouldRunCodexRepair) return "legacy"; + return agentsViewAvailable ? "agentsview-claude-native-codex" : "legacy"; +} + function parseDate(dateStr: string): Date { const [y, m, d] = dateStr.split("-").map(Number); return new Date(y!, m! - 1, d); @@ -264,10 +306,28 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) saveConfig(config); } + let collectorPreference: CollectorPreference; + try { + collectorPreference = readCollectorPreference(); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + const today = new Date(); const shouldRunCodexRepair = !options.date && !config.codex_native_repair_completed_at && await containsSessionFile(); + const shouldProbeAgentsView = collectorPreference !== "legacy" + && !(collectorPreference === "auto" && shouldRunCodexRepair); + const agentsViewProbeTimeoutMs = Math.min(options.timeoutMs ?? 3_000, 3_000); + const agentsViewAvailable = shouldProbeAgentsView + ? await isSupportedAgentsViewInstalled(agentsViewProbeTimeoutMs) + : false; + if (collectorPreference === "agentsview" && !agentsViewAvailable) { + console.error(`agentsview ${MIN_AGENTSVIEW_VERSION} or newer is required. Install or upgrade it from https://www.agentsview.io/.`); + process.exit(1); + } const resolution = resolvePushDateRange({ today, @@ -284,6 +344,10 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) const sinceStr = formatDateCompact(sinceDate); const untilStr = formatDateCompact(untilDate); + const sinceIso = formatDate(sinceDate); + const untilIso = formatDate(untilDate); + + const selectedCollector = selectCollector(collectorPreference, shouldRunCodexRepair, agentsViewAvailable); console.log( sinceDate.getTime() === untilDate.getTime() @@ -291,82 +355,211 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) : `Pushing usage for ${formatDate(sinceDate)} to ${formatDate(untilDate)}...`, ); - // Try to ensure ccusage is installed. In a TTY this prompts the user and - // runs `bun add -g` / `npm install -g`. We catch the throw rather than - // propagating it: Codex-only users (no Claude data) shouldn't be blocked by - // a missing ccusage. If ccusage is genuinely required, the runCcusage call - // below will surface the "not installed" error and we treat it the same as - // missing Claude data. - let ccusageReady = true; - try { - await ensureCcusageInstalled(config); - } catch { - ccusageReady = false; - } - - // Run ccusage + codex in parallel — the single biggest perf win const scanSpinner = new Spinner("scan"); scanSpinner.start(); + + let entries: CcusageDailyEntry[] = []; + let rawHashInput = ""; + let collector: UsageSubmitRequest["collector"] | undefined; + let allAnomalies: NormalizationAnomaly[] = []; + let anomalyCounts = countAnomalies([]); let codexCollectFailed = false; - const [claudeResult, codexParsed] = await Promise.all([ - ccusageReady - ? runCcusageRawAsync(sinceStr, untilStr, options.timeoutMs).catch((err: Error) => err) - : Promise.resolve( - new Error("ccusage is not installed or not on PATH"), - ), - collectCodexUsageAsync(sinceStr, untilStr).catch(() => { - codexCollectFailed = true; - return { - data: [], - anomalies: [], - entryMeta: [], - fingerprint: "", - scannedFiles: 0, - parsedEvents: 0, - }; - }), - ]); - scanSpinner.stop(); - - let claudeRaw = ""; - let claudeEntries: CcusageDailyEntry[] = []; - let claudeAnomalies: NormalizationAnomaly[] = []; - - if (claudeResult instanceof Error) { - // Codex-only users do not have local Claude data; keep other Claude failures fatal. - if ( - isMissingClaudeDataError(claudeResult) || - isCcusageNotInstalledError(claudeResult) - ) { - console.log("No Claude Code data found locally; syncing Codex usage only."); + let agentsViewVersion: string | undefined; + const blockedDates = new Set(); + + if (isDebug()) { + debugLog(`collector mode: ${selectedCollector} (preference=${collectorPreference})`); + debugLog(`agentsview available: ${agentsViewAvailable}`); + debugLog(`codex repair pending: ${shouldRunCodexRepair}`); + debugLog(`codex_native_repair_completed_at: ${config.codex_native_repair_completed_at ?? "(unset)"}`); + debugLog(`timezone: ${localTimeZone() ?? "(unset)"}`); + debugLog(`pricing mode: offline`); + } + + if (selectedCollector === "agentsview-claude-native-codex") { + // Probe agentsview version once, eagerly, so telemetry + debug logs can + // record it even if the main agentsview run later fails. Failures here + // are non-fatal — the version is metadata, not a gate. + try { + agentsViewVersion = await getAgentsViewVersion(options.timeoutMs); + if (isDebug()) { + debugLog(`agentsview version: ${agentsViewVersion}`); + } + } catch { + if (isDebug()) { + debugLog(`agentsview version: (probe failed)`); + } + } + + const [agentsViewResult, codexParsed] = await Promise.all([ + runAgentsViewRawAsync(sinceIso, untilIso, options.timeoutMs, { + agent: "claude", + timezone: localTimeZone(), + }).catch((err: Error) => err), + collectCodexUsageAsync(sinceStr, untilStr).catch(() => { + codexCollectFailed = true; + return { + data: [], + anomalies: [], + entryMeta: [], + fingerprint: "", + scannedFiles: 0, + parsedEvents: 0, + }; + }), + ]); + scanSpinner.stop(); + + let agentsViewRaw = ""; + let claudeEntries: CcusageDailyEntry[] = []; + let claudeAnomalies: NormalizationAnomaly[] = []; + + if (agentsViewResult instanceof Error) { + // Mirror the legacy ccusage three-branch structure: a missing + // `~/.claude/` tree shouldn't crash a Codex-only user. Real failures + // (binary error, parse error, timeout) still exit fatally. + if (isMissingAgentsViewClaudeDataError(agentsViewResult)) { + console.log("No Claude Code data found locally; syncing Codex usage only."); + } else { + console.error(agentsViewResult.message); + process.exit(1); + } } else { - console.error(claudeResult.message); - process.exit(1); + agentsViewRaw = agentsViewResult; + try { + const parsed = parseAgentsViewOutput(agentsViewRaw); + claudeEntries = parsed.data; + claudeAnomalies = parsed.anomalies ?? []; + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } } + + allAnomalies = [ + ...claudeAnomalies, + ...(codexParsed.anomalies ?? []), + ]; + anomalyCounts = countAnomalies(allAnomalies); + + const codexMetaByDate = new Map((codexParsed.entryMeta ?? []).map((row) => [row.date, row.meta])); + + for (const [date, meta] of codexMetaByDate) { + if (meta.mode === "unresolved") { + blockedDates.add(date); + } + } + + if (blockedDates.size > 0) { + const blocked = [...blockedDates].sort(); + const reason = "unresolved codex normalization"; + console.log(`Warning: skipping Codex rows for ${blocked.length} date(s) due to ${reason}: ${blocked.join(", ")}`); + } + + const codexEntries = codexParsed.data.filter((entry) => !blockedDates.has(entry.date)); + + entries = mergeEntries(claudeEntries, codexEntries); + rawHashInput = codexParsed.fingerprint ? agentsViewRaw + codexParsed.fingerprint : agentsViewRaw; + const hybridCollector: UsageSubmitRequest["collector"] = {}; + if (claudeEntries.length > 0) hybridCollector.claude = AGENTSVIEW_COLLECTOR; + if (codexEntries.length > 0) hybridCollector.codex = CODEX_NATIVE_COLLECTOR; + collector = Object.keys(hybridCollector).length > 0 ? hybridCollector : undefined; } else { - claudeRaw = claudeResult; + // Legacy fallback path: ccusage + native Codex. + // + // Try to ensure ccusage is installed. In a TTY this prompts the user and + // runs `bun add -g` / `npm install -g`. We catch the throw rather than + // propagating it: Codex-only users (no Claude data) shouldn't be blocked + // by a missing ccusage. If ccusage is genuinely required, the runCcusage + // call below will surface the "not installed" error and we treat it the + // same as missing Claude data. + let ccusageReady = true; try { - const parsed = parseCcusageOutput(claudeRaw); - claudeEntries = parsed.data; - claudeAnomalies = parsed.anomalies ?? []; - } catch (err) { - console.error((err as Error).message); - process.exit(1); + await ensureCcusageInstalled(config); + } catch { + ccusageReady = false; + } + + const [claudeResult, codexParsed] = await Promise.all([ + ccusageReady + ? runCcusageRawAsync(sinceStr, untilStr, options.timeoutMs).catch((err: Error) => err) + : Promise.resolve( + new Error("ccusage is not installed or not on PATH"), + ), + collectCodexUsageAsync(sinceStr, untilStr).catch(() => { + codexCollectFailed = true; + return { + data: [], + anomalies: [], + entryMeta: [], + fingerprint: "", + scannedFiles: 0, + parsedEvents: 0, + }; + }), + ]); + scanSpinner.stop(); + + let claudeRaw = ""; + let claudeEntries: CcusageDailyEntry[] = []; + let claudeAnomalies: NormalizationAnomaly[] = []; + + if (claudeResult instanceof Error) { + // Codex-only users do not have local Claude data; keep other Claude failures fatal. + if ( + isMissingClaudeDataError(claudeResult) || + isCcusageNotInstalledError(claudeResult) + ) { + console.log("No Claude Code data found locally; syncing Codex usage only."); + } else { + console.error(claudeResult.message); + process.exit(1); + } + } else { + claudeRaw = claudeResult; + try { + const parsed = parseCcusageOutput(claudeRaw); + claudeEntries = parsed.data; + claudeAnomalies = parsed.anomalies ?? []; + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + } + + allAnomalies = [ + ...claudeAnomalies, + ...(codexParsed.anomalies ?? []), + ]; + anomalyCounts = countAnomalies(allAnomalies); + + const codexMetaByDate = new Map((codexParsed.entryMeta ?? []).map((row) => [row.date, row.meta])); + + for (const [date, meta] of codexMetaByDate) { + if (meta.mode === "unresolved") { + blockedDates.add(date); + } + } + + if (blockedDates.size > 0) { + const blocked = [...blockedDates].sort(); + const reason = "unresolved codex normalization"; + console.log(`Warning: skipping Codex rows for ${blocked.length} date(s) due to ${reason}: ${blocked.join(", ")}`); } + + const codexEntries = codexParsed.data.filter((entry) => !blockedDates.has(entry.date)); + + entries = mergeEntries(claudeEntries, codexEntries); + rawHashInput = codexParsed.fingerprint ? claudeRaw + codexParsed.fingerprint : claudeRaw; + const legacyCollector: UsageSubmitRequest["collector"] = {}; + if (claudeEntries.length > 0) legacyCollector.claude = "ccusage-v18"; + if (codexEntries.length > 0) legacyCollector.codex = CODEX_NATIVE_COLLECTOR; + collector = Object.keys(legacyCollector).length > 0 ? legacyCollector : undefined; } // Token-normalization anomalies are diagnostic, not user-actionable: rows - // tagged medium/low confidence still get pushed (we just had to infer - // cache semantics). Only `mode === "unresolved"` rows are dropped, and - // those have their own warning below. So keep these counts quiet by - // default and surface them only under --debug; ship the counts to PostHog - // either way so we can monitor normalization quality across users. - const allAnomalies: NormalizationAnomaly[] = [ - ...claudeAnomalies, - ...(codexParsed.anomalies ?? []), - ]; - const anomalyCounts = countAnomalies(allAnomalies); - + // tagged medium/low confidence still get pushed (we just had to infer cache + // semantics). Only native Codex `mode === "unresolved"` rows are dropped. if (isDebug() && anomalyCounts.mediumLow > 0) { debugLog( `normalization anomalies: ${anomalyCounts.mediumLow} medium/low,`, @@ -382,30 +575,10 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) } } - const codexMetaByDate = new Map((codexParsed.entryMeta ?? []).map((row) => [row.date, row.meta])); - const blockedDates = new Set(); - - for (const [date, meta] of codexMetaByDate) { - if (meta.mode === "unresolved") { - blockedDates.add(date); - } - } - - if (blockedDates.size > 0) { - const blocked = [...blockedDates].sort(); - const reason = "unresolved codex normalization"; - console.log(`Warning: skipping Codex rows for ${blocked.length} date(s) due to ${reason}: ${blocked.join(", ")}`); - } - - const codexEntries = codexParsed.data.filter((entry) => !blockedDates.has(entry.date)); - - // Merge Claude + Codex entries by date - const merged = mergeEntries(claudeEntries, codexEntries); - // Drop entries the server would reject as out-of-window. Pre-filtering keeps // a single edge-case row from failing the whole batch with HTTP 400. const droppedDates: string[] = []; - const entries = merged.filter((entry) => { + entries = entries.filter((entry) => { if (isWithinBackfillWindow(entry.date)) return true; droppedDates.push(entry.date); return false; @@ -448,12 +621,7 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) return; } - // Compute SHA-256 hash of Claude raw JSON plus native Codex aggregate fingerprint. - const hashInput = codexParsed.fingerprint ? claudeRaw + codexParsed.fingerprint : claudeRaw; - const hash = createHash("sha256").update(hashInput).digest("hex"); - const collector: UsageSubmitRequest["collector"] = {}; - if (claudeEntries.length > 0) collector.claude = "ccusage-v18"; - if (codexEntries.length > 0) collector.codex = CODEX_NATIVE_COLLECTOR; + const hash = createHash("sha256").update(rawHashInput).digest("hex"); const body: UsageSubmitRequest = { entries: entries.map((entry) => ({ @@ -461,7 +629,7 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) data: entry, })), hash, - collector: Object.keys(collector).length > 0 ? collector : undefined, + collector, source: "cli", device_id: config.device_id, device_name: config.device_name, @@ -520,21 +688,32 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) const totalTokens = entries.reduce((sum, e) => sum + e.totalTokens, 0); const datesCreated = response.results.filter((r) => r.action === "created").length; const datesUpdated = response.results.filter((r) => r.action === "updated").length; - posthog.capture({ - distinctId: getDistinctId(config), - event: "usage_pushed", - properties: { - days_pushed: entries.length, - dates_created: datesCreated, - dates_updated: datesUpdated, - total_cost_usd: Math.round(totalCost * 100) / 100, - total_tokens: totalTokens, - dry_run: false, - anomalies_medium_low: anomalyCounts.mediumLow, - anomalies_low_confidence: anomalyCounts.low, - anomalies_unresolved: anomalyCounts.unresolved, - }, - }); + // Wrap telemetry capture in try/catch — a property assembly bug or + // PostHog hiccup must never crash the user-facing command. + try { + posthog.capture({ + distinctId: getDistinctId(config), + event: "usage_pushed", + properties: { + days_pushed: entries.length, + dates_created: datesCreated, + dates_updated: datesUpdated, + total_cost_usd: Math.round(totalCost * 100) / 100, + total_tokens: totalTokens, + dry_run: false, + anomalies_medium_low: anomalyCounts.mediumLow, + anomalies_low_confidence: anomalyCounts.low, + anomalies_unresolved: anomalyCounts.unresolved, + collector_mode: selectedCollector, + collector_preference: collectorPreference, + agentsview_available: agentsViewAvailable, + codex_repair_pending: shouldRunCodexRepair, + agentsview_version: agentsViewVersion, + }, + }); + } catch { + // Telemetry failure is non-fatal. + } // Render visual dashboard try { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d10271c..048a9af 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -43,7 +43,7 @@ silenceEpipe(process.stdout); silenceEpipe(process.stderr); const HELP = ` -straude v${CLI_VERSION} — Push your Claude Code usage to Straude +straude v${CLI_VERSION} — Push your AI coding usage to Straude Usage: straude Sync latest stats (login if needed) @@ -60,6 +60,7 @@ Push options: --days N Push last N days (max 30) --dry-run Preview without posting --timeout N Subprocess timeout in seconds (default: 240) + STRAUDE_COLLECTOR=auto|agentsview|legacy Collector mode (default: auto) --auto Enable daily auto-push (OS scheduler) --auto hooks Enable auto-push via Claude Code hook --auto --time HH:MM Set auto-push time (default: 21:00) From 1870949ab9da00ff779511ffea6ccda57a08f7e9 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Wed, 6 May 2026 17:42:00 -0700 Subject: [PATCH 3/7] feat(web): accept agentsview-v1 collector + tighten codex trust /api/usage/submit now allow-lists collector metadata keys (claude/codex/ unified), enforces a 512-byte cap, and accepts the new "agentsview-v1" literal for claude and unified provenance. Unknown keys/values return 400 instead of being silently persisted. Tightens the trusted-Codex-correction rule so that straude-codex-native-v1 can only lower an existing higher-cost device row when the request authenticates through the CLI token path. A Supabase-web session presenting the same collector tag is now treated as untrusted and the mayOverwriteDevice guard preserves the existing total. Adds two regression tests: - rejects unknown collector metadata keys with 400 - rejects web-auth Codex repair attempts even with the trusted collector tag (asserts the device upsert + delete chains are not invoked and the existing higher cost is preserved) - accepts agentsview-v1 collector metadata via CLI auth and persists it on the device_usage row. UsageCollectorMeta gains an optional `unified: "agentsview-v1"` field for forward compatibility with a future fully-consolidated collector. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/__tests__/api/usage-submit.test.ts | 142 ++++++++++++++++++++ apps/web/app/api/usage/submit/route.ts | 40 +++++- apps/web/types/index.ts | 3 +- 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/api/usage-submit.test.ts b/apps/web/__tests__/api/usage-submit.test.ts index b0ef065..ad1d0a1 100644 --- a/apps/web/__tests__/api/usage-submit.test.ts +++ b/apps/web/__tests__/api/usage-submit.test.ts @@ -170,6 +170,47 @@ describe("POST /api/usage/submit", () => { expect(json.results).toHaveLength(1); }); + it("rejects unknown collector metadata", async () => { + const res = await POST( + mockRequest({ + entries: [makeEntry(todayStr())], + source: "cli", + collector: { claude: "ccusage-v18", extra: "surprise" }, + }) + ); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.error).toContain("Unknown collector metadata key"); + }); + + it("accepts agentsview collector metadata", async () => { + (verifyCliToken as any).mockReturnValue("cli-user-id"); + mockSupabaseAuth(null); + const svc = mockServiceClient(); + svc.single + .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) + .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) + .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); + + const res = await POST( + mockRequest( + { + entries: [makeEntry(todayStr())], + source: "cli", + collector: { claude: "agentsview-v1", codex: "straude-codex-native-v1" }, + }, + { authorization: "Bearer some-token" } + ) + ); + + expect(res.status).toBe(200); + expect(svc.upsert.mock.calls[0][0].collector_meta).toEqual({ + claude: "agentsview-v1", + codex: "straude-codex-native-v1", + }); + }); + it("handles Supabase session auth (cookie/web)", async () => { mockSupabaseAuth("web-user-id"); const svc = mockServiceClient(); @@ -903,6 +944,107 @@ describe("POST /api/usage/submit", () => { expect(json.results[0].daily_total).toBe(10); }); + it("rejects web-auth Codex repair attempts even with trusted collector", async () => { + // No CLI token — request authenticates via Supabase web session instead. + // Use a unique user ID so we don't share the 20/min in-memory rate-limit + // bucket with the other "user-1" tests in this describe block. + (verifyCliToken as any).mockReturnValue(null); + mockSupabaseAuth("web-codex-repair-user"); + + const existingDeviceRow = { + cost_usd: 100, + input_tokens: 100000, + output_tokens: 1000, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 101000, + models: ["gpt-5-codex"], + model_breakdown: [{ model: "gpt-5-codex", cost_usd: 100 }], + }; + + const deviceGuardChain: Record = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: { cost_usd: 100, models: ["gpt-5-codex"] }, + error: null, + }), + }; + const deviceCountChain: Record = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockImplementation(() => ({ + eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), + })), + }; + const deviceUpsertChain: Record = { + upsert: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), + }; + const deviceDeleteChain: Record = { + delete: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + }; + const deviceFetchChain: Record = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockImplementation(() => ({ + eq: vi.fn().mockResolvedValue({ data: [existingDeviceRow], error: null }), + })), + }; + const dailyChain: Record = { + select: vi.fn().mockReturnThis(), + upsert: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: { id: "usage-1", cost_usd: 100, models: ["gpt-5-codex"] }, error: null }), + single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), + }; + const postChain: Record = { + select: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24" }, error: null }), + single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), + }; + + let deviceFromCallCount = 0; + const fromFn = vi.fn((table: string) => { + if (table === "device_usage") { + deviceFromCallCount++; + if (deviceFromCallCount === 1) return deviceGuardChain; + if (deviceFromCallCount === 2) return deviceCountChain; + return deviceFetchChain; + } + if (table === "daily_usage") return dailyChain; + if (table === "posts") return postChain; + return dailyChain; + }); + (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: vi.fn().mockResolvedValue({ data: null, error: null }) }); + + const res = await POST( + mockRequest({ + entries: [makeEntry(todayStr(), { + models: ["gpt-5-codex"], + costUSD: 10, + inputTokens: 10000, + outputTokens: 100, + totalTokens: 10100, + modelBreakdown: [{ model: "gpt-5-codex", cost_usd: 10 }], + })], + source: "web", + collector: { codex: "straude-codex-native-v1" }, + }) + ); + const json = await res.json(); + + // The trusted collector flag is only honored on the CLI auth path. + // Web-auth callers must not be able to lower an existing higher-cost row. + expect(res.status).toBe(200); + expect(deviceUpsertChain.upsert).not.toHaveBeenCalled(); + expect(deviceDeleteChain.delete).not.toHaveBeenCalled(); + expect(json.results[0].daily_total).toBe(100); + expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(100); + }); + it("does not drop mixed legacy usage on a Codex-only repair", async () => { (verifyCliToken as any).mockReturnValue("user-1"); diff --git a/apps/web/app/api/usage/submit/route.ts b/apps/web/app/api/usage/submit/route.ts index b79075d..8cc2f97 100644 --- a/apps/web/app/api/usage/submit/route.ts +++ b/apps/web/app/api/usage/submit/route.ts @@ -9,8 +9,10 @@ import type { UsageSubmitRequest, UsageSubmitResponse, CcusageDailyEntry, ModelB const MAX_BACKFILL_DAYS = 30; const TRUSTED_CODEX_COLLECTOR = "straude-codex-native-v1"; +const AGENTSVIEW_COLLECTOR = "agentsview-v1"; const LEGACY_DEVICE_ID = "00000000-0000-0000-0000-000000000000"; const CODEX_MODEL_RE = /^(gpt-|o3|o4)/i; +const ALLOWED_COLLECTOR_KEYS = new Set(["claude", "codex", "unified"]); function isValidDate(dateStr: string): boolean { const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/); @@ -35,6 +37,36 @@ function validateEntry(entry: CcusageDailyEntry): string | null { return null; } +function validateCollectorMeta(collector: unknown): string | null { + if (collector == null) return null; + if (typeof collector !== "object" || Array.isArray(collector)) { + return "Invalid collector metadata"; + } + const json = JSON.stringify(collector); + if (json.length > 512) return "Collector metadata is too large"; + + const record = collector as Record; + for (const key of Object.keys(record)) { + if (!ALLOWED_COLLECTOR_KEYS.has(key)) return `Unknown collector metadata key: ${key}`; + if (typeof record[key] !== "string") return `Invalid collector metadata value for ${key}`; + } + + if ( + record.claude != null + && record.claude !== "ccusage-v18" + && record.claude !== AGENTSVIEW_COLLECTOR + ) { + return "Unknown Claude collector"; + } + if (record.codex != null && record.codex !== TRUSTED_CODEX_COLLECTOR) { + return "Unknown Codex collector"; + } + if (record.unified != null && record.unified !== AGENTSVIEW_COLLECTOR) { + return "Unknown unified collector"; + } + return null; +} + function isCodexModel(model: unknown): boolean { return typeof model === "string" && CODEX_MODEL_RE.test(model); } @@ -156,6 +188,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Invalid source" }, { status: 400 }); } + const collectorError = validateCollectorMeta(body.collector); + if (collectorError) { + return NextResponse.json({ error: collectorError }, { status: 400 }); + } + const auth = await resolveAuthContext(request); if (!auth) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -189,7 +226,8 @@ export async function POST(request: Request) { const deviceId = body.device_id; const deviceName = body.device_name; - const isTrustedCodexCorrection = body.collector?.codex === TRUSTED_CODEX_COLLECTOR; + const isTrustedCodexCorrection = auth.source === "cli" + && body.collector?.codex === TRUSTED_CODEX_COLLECTOR; if (!deviceId) { return NextResponse.json( diff --git a/apps/web/types/index.ts b/apps/web/types/index.ts index 7e9a3ac..55bebed 100644 --- a/apps/web/types/index.ts +++ b/apps/web/types/index.ts @@ -194,8 +194,9 @@ export interface DeviceUsage { } export interface UsageCollectorMeta { - claude?: "ccusage-v18" | string; + claude?: "ccusage-v18" | "agentsview-v1" | string; codex?: "straude-codex-native-v1" | string; + unified?: "agentsview-v1" | string; } // API request/response types From 3c16a7cb605e9c56d29bc62ba46aeb47b790d1eb Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Wed, 6 May 2026 17:42:13 -0700 Subject: [PATCH 4/7] feat(web): credit agentsview alongside ccusage and accept its JSON shape Updates the user-facing copy that anchored exclusively on ccusage so existing users aren't surprised by the CLI 1.0 collector model: - PrivacyPledge component links both ccusage and agentsview as legitimate local collectors. - /privacy "What Straude cannot access" mentions both. - /cli Data Sources section honestly describes auto's preference order (agentsview >= 0.26.1 for Claude when installed; ccusage fallback; Codex always on Straude's native collector). - README narrative aligns with the new collector model. The privacy pledge itself is unchanged: only aggregated daily totals ever leave the user's machine. Manual web import now accepts both schema shapes: the legacy { "type": "daily", "data": [...] } and the modern { daily, totals } that current ccusage v18 and agentsview emit. Adds a toCanonicalEntry helper that maps either shape to the canonical entry the API expects, deriving totalTokens from input+output+cacheCreation+cacheRead when absent. Updates instructions to point users at either `ccusage daily --json --since YYYYMMDD --until YYYYMMDD` or `agentsview usage daily --json --since YYYY-MM-DD --until YYYY-MM-DD`. Does not change the verified-vs-unverified semantics or the hardcoded web-import device_id constant. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 14 +-- apps/web/app/(app)/post/new/page.tsx | 3 +- apps/web/app/(app)/settings/import/page.tsx | 102 +++++++++++++++--- apps/web/app/(landing)/cli/page.tsx | 28 ++++- apps/web/app/(landing)/privacy/page.tsx | 9 ++ apps/web/components/landing/PrivacyPledge.tsx | 9 ++ 6 files changed, 135 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index f3262b0..eb8b1ec 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Straude -**Strava for Claude Code.** +**Strava for AI coding agents.** -Track your Claude Code usage. Share your sessions. Climb the leaderboard. +Track your AI coding usage. Share your sessions. Climb the leaderboard. [![Watch the demo](https://img.youtube.com/vi/NTI_-jRtW2g/maxresdefault.jpg)](https://www.youtube.com/watch?v=NTI_-jRtW2g) @@ -18,9 +18,9 @@ Sync your stats with a single command — no install needed: npx straude@latest ``` -The CLI reads your local [ccusage](https://github.com/ryoppippi/ccusage) data (cost, tokens, models, sessions), uploads it to Straude, and auto-creates a post on your feed. First run opens a browser login; after that, just run `npx straude@latest` daily. It automatically pushes new stats since your last sync. +The CLI reads local aggregate usage data, uploads it to Straude, and auto-creates a post on your feed. CLI 1.0 can use [agentsview](https://www.agentsview.io/) for Claude Code when installed and keeps Straude's native Codex collector for accurate Codex accounting. First run opens a browser login; after that, just run `npx straude@latest` daily. It automatically pushes new stats since your last sync. -Options: `--date YYYY-MM-DD` to push a specific date, `--days N` to backfill the last N days (max 7), `--dry-run` to preview without posting. Run `npx straude@latest status` to check your streak and rank. +Options: `--date YYYY-MM-DD` to push a specific date, `--days N` to backfill the last N days (max 30), `--dry-run` to preview without posting. Run `npx straude@latest status` to check your streak and rank. ## Features @@ -83,15 +83,15 @@ Only **aggregate token usage statistics** — the same numbers you'd see on your - Model names used (e.g. "Claude Opus", "GPT-4.1") - Session count and dates -That's it. **We have zero access to your prompts, code, conversations, file contents, or anything you do inside Claude Code or Codex.** The CLI reads pre-aggregated daily totals from local [ccusage](https://github.com/ryoppippi/ccusage) data — it never touches your session transcripts or project files. +That's it. **We have zero access to your prompts, code, conversations, file contents, or anything you do inside Claude Code or Codex.** The CLI reads pre-aggregated daily totals from local collector output — it never touches your session transcripts or project files. ### Where does the usage data come from? -The CLI runs [ccusage](https://github.com/ryoppippi/ccusage) locally on your machine, which reads the JSONL log files that Claude Code writes to `~/.claude/`. These logs contain token counts and cost per API call. ccusage aggregates them into daily totals, and the Straude CLI sends those totals to the server. The raw logs never leave your machine. +The CLI runs local collectors on your machine: agentsview or ccusage for Claude Code, and Straude's native collector for Codex. These collectors aggregate token counts and cost into daily totals, and the Straude CLI sends only those totals to the server. The raw logs never leave your machine. ### Can Straude see my code or prompts? -No. The data pipeline is: local JSONL logs → ccusage (local aggregation) → daily totals sent to Straude. At no point does any conversation content, prompt text, code, or file path leave your machine. You can verify this yourself — the CLI is open source, and you can run `npx straude --dry-run` to see exactly what would be sent before it's sent. +No. The data pipeline is: local agent logs → local aggregation → daily totals sent to Straude. At no point does any conversation content, prompt text, code, or file path leave your machine. You can verify this yourself — the CLI is open source, and you can run `npx straude --dry-run` to see exactly what would be sent before it's sent. ### Is my profile public by default? diff --git a/apps/web/app/(app)/post/new/page.tsx b/apps/web/app/(app)/post/new/page.tsx index 91885f8..7046b74 100644 --- a/apps/web/app/(app)/post/new/page.tsx +++ b/apps/web/app/(app)/post/new/page.tsx @@ -113,7 +113,8 @@ export default async function NewPostPage() { Or import manually

- Paste ccusage JSON output. Manual imports are unverified. + Paste ccusage or agentsview JSON output. Manual imports are + unverified.

): CanonicalEntry { + const num = (v: unknown) => (typeof v === "number" ? v : 0); + const models = Array.isArray(d.modelsUsed) + ? (d.modelsUsed as string[]) + : Array.isArray(d.models) + ? (d.models as string[]) + : []; + const inputTokens = num(d.inputTokens); + const outputTokens = num(d.outputTokens); + const cacheCreationTokens = num(d.cacheCreationTokens); + const cacheReadTokens = num(d.cacheReadTokens); + const totalTokens = + typeof d.totalTokens === "number" && d.totalTokens > 0 + ? d.totalTokens + : inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const costUSD = + typeof d.costUSD === "number" + ? d.costUSD + : typeof d.totalCost === "number" + ? d.totalCost + : 0; + return { + date: d.date as string, + models, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + costUSD, + }; +} + export default function ImportPage() { const posthog = usePostHog(); const [json, setJson] = useState(""); @@ -37,32 +90,39 @@ export default function ImportPage() { try { parsed = JSON.parse(json); } catch { - setError("Invalid JSON. Please paste the output of: ccusage daily --json"); + setError( + "Invalid JSON. Please paste the output of: ccusage daily --json or agentsview usage daily --json", + ); setLoading(false); return; } - const obj = parsed as { type?: string; data?: unknown[] }; - if (obj.type !== "daily" || !Array.isArray(obj.data)) { + // Accept both schema shapes: + // - Legacy ccusage: { type: "daily", data: [{ date, models, costUSD, totalTokens, ... }, ...] } + // - Modern (current ccusage and agentsview): + // { daily: [{ date, modelsUsed, totalCost, totalTokens?, ... }, ...], totals: {...} } + const obj = parsed as { + type?: string; + data?: unknown[]; + daily?: unknown[]; + }; + + let rawEntries: Record[]; + if (obj.type === "daily" && Array.isArray(obj.data)) { + rawEntries = obj.data as Record[]; + } else if (Array.isArray(obj.daily)) { + rawEntries = obj.daily as Record[]; + } else { setError( - 'Expected ccusage output with { "type": "daily", "data": [...] }. Make sure you run: ccusage daily --json', + 'Expected ccusage or agentsview output — either { "type": "daily", "data": [...] } or { "daily": [...], "totals": {...} }.', ); setLoading(false); return; } - const entries = (obj.data as Record[]).map((d) => ({ + const entries = rawEntries.map((d) => ({ date: d.date as string, - data: { - date: d.date as string, - models: (d.models as string[]) ?? [], - inputTokens: (d.inputTokens as number) ?? 0, - outputTokens: (d.outputTokens as number) ?? 0, - cacheCreationTokens: (d.cacheCreationTokens as number) ?? 0, - cacheReadTokens: (d.cacheReadTokens as number) ?? 0, - totalTokens: (d.totalTokens as number) ?? 0, - costUSD: (d.costUSD as number) ?? 0, - }, + data: toCanonicalEntry(d), })); const res = await fetch("/api/usage/submit", { @@ -118,7 +178,15 @@ export default function ImportPage() {

Alternative: paste JSON manually

- Run ccusage daily --json --since YYYYMMDD --until YYYYMMDD and paste the output below. Manual imports are unverified and won't count towards the leaderboard — they only post to your feed. + Run either{" "} + + ccusage daily --json --since YYYYMMDD --until YYYYMMDD + {" "} + or{" "} + + agentsview usage daily --json --since YYYY-MM-DD --until YYYY-MM-DD + {" "} + and paste the output below. Manual imports are unverified and won't count towards the leaderboard — they only post to your feed.

@@ -126,7 +194,7 @@ export default function ImportPage() {