Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Sync your stats with a single command — no install needed:
npx straude@latest
```

The CLI reads your local [ccusage](https://github.com/ccusage/ccusage) data (cost, tokens, models, sessions), uploads it to Straude, and auto-creates a post on your feed. That includes every source ccusage detects, currently Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, and Gemini CLI. 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 your local [ccusage](https://github.com/ccusage/ccusage) data (cost, tokens, models, sessions), uploads it to Straude, and auto-creates a post on your feed. That includes all 16 built-in sources in the bundled ccusage 20.0.20 release: Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and Grok Build CLI. 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 collect usage without submitting it. Run `npx straude@latest status` to check your streak and rank.

Expand Down
32 changes: 9 additions & 23 deletions apps/web/__tests__/api/usage-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,7 @@ import {
import { getServiceClient } from "@/lib/supabase/service";
import { resetRateLimiters } from "@/lib/rate-limit";

const ALL_BUILT_IN_CCUSAGE_AGENTS = [
"claude",
"codex",
"opencode",
"amp",
"droid",
"codebuff",
"hermes",
"pi",
"goose",
"openclaw",
"kilo",
"kimi",
"qwen",
"copilot",
"gemini",
];
import ALL_BUILT_IN_CCUSAGE_AGENTS from "../../../../packages/cli/__tests__/fixtures/ccusage-sources.json";

function mockAllowedRpc() {
return vi.fn((fn: string) => Promise.resolve(
Expand Down Expand Up @@ -1629,7 +1613,7 @@ describe("POST /api/usage/submit", () => {
});
});

it("accepts every ccusage source and stores row-specific collector metadata", async () => {
it.each([...ALL_BUILT_IN_CCUSAGE_AGENTS, "custom-agent"])("accepts source %s and stores row-specific collector metadata", async (agent) => {
(verifyCliToken as any).mockReturnValue("user-collector-meta");
const svc = mockServiceClient();
svc.single
Expand All @@ -1640,24 +1624,26 @@ describe("POST /api/usage/submit", () => {
const res = await POST(
mockRequest({
entries: [makeEntry(todayStr(), {
agents: ["opencode"],
agents: [agent],
models: ["gpt-5.6"],
modelBreakdown: [{ model: "gpt-5.6", cost_usd: 0.05 }],
})],
source: "cli",
collector: {
claude: "ccusage-claude-v20",
codex: "ccusage-codex-v20",
ccusage_version: "20.0.16",
ccusage_agents: ALL_BUILT_IN_CCUSAGE_AGENTS,
ccusage_version: "20.0.20",
ccusage_agents: [...ALL_BUILT_IN_CCUSAGE_AGENTS, "custom-agent"],
pricing_mode: "offline",
},
})
);

const expectedMeta = {
ccusage_version: "20.0.16",
ccusage_agents: ["opencode"],
...(agent === "claude" ? { claude: "ccusage-claude-v20" } : {}),
...(agent === "codex" ? { codex: "ccusage-codex-v20" } : {}),
ccusage_version: "20.0.20",
ccusage_agents: [agent],
pricing_mode: "offline",
};
expect(res.status).toBe(200);
Expand Down
161 changes: 161 additions & 0 deletions apps/web/__tests__/unit/join-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactElement } from "react";

const mocks = vi.hoisted(() => ({
getServiceClient: vi.fn(),
imageElement: null as unknown,
}));

vi.mock("@/lib/supabase/service", () => ({
getServiceClient: mocks.getServiceClient,
}));

vi.mock("@/lib/og-fonts", () => ({
loadFonts: vi.fn().mockResolvedValue([]),
}));

vi.mock("@/lib/og-safe-image", () => ({
loadSafeOgAvatar: vi.fn().mockResolvedValue(null),
}));

vi.mock("next/og", () => ({
ImageResponse: class MockImageResponse {
constructor(element: unknown) {
mocks.imageElement = element;
}
},
}));

vi.mock("@/components/landing/Navbar", () => ({
Navbar: () => <nav />,
}));

vi.mock("@/components/landing/Footer", () => ({
Footer: () => <footer />,
}));

vi.mock("@/components/landing/HalftoneCanvas", () => ({
HalftoneCanvas: () => null,
}));

vi.mock("@/app/(landing)/join/[username]/ref-cookie", () => ({
RefCookie: () => null,
}));

vi.mock("@/components/ui/Avatar", () => ({
Avatar: ({ alt }: { alt?: string }) => <span>{alt}</span>,
}));

vi.mock("lucide-react", () => ({
Flame: () => <span />,
}));

import JoinPage, {
generateMetadata,
} from "@/app/(landing)/join/[username]/page";
import JoinOgImage from "@/app/(landing)/join/[username]/opengraph-image";

type UsageRow = {
cost_usd: number;
model_breakdown?: Array<{ model: string; cost_usd: number }>;
agents?: string[];
};

function makeQuery(data: unknown) {
const result = { data, error: null };
const query: Record<string, any> = {
eq: vi.fn(() => query),
gte: vi.fn(() => query),
single: vi.fn().mockResolvedValue(result),
then: (onFulfilled: (value: typeof result) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve(result).then(onFulfilled, onRejected),
};
return query;
}

function mockServiceClient(rows: UsageRow[]) {
const profile = {
id: "user-1",
username: "agent-athlete",
display_name: "Agent Athlete",
avatar_url: null,
is_public: true,
};
const client = {
from: vi.fn((table: string) => {
if (table === "users") {
return { select: vi.fn(() => makeQuery(profile)) };
}
if (table === "daily_usage") {
return { select: vi.fn(() => makeQuery(rows)) };
}
throw new Error(`Unexpected table: ${table}`);
}),
rpc: vi.fn().mockResolvedValue({ data: 0, error: null }),
};

mocks.getServiceClient.mockReturnValue(client);
return client;
}

const params = { params: Promise.resolve({ username: "agent-athlete" }) };

beforeEach(() => {
vi.clearAllMocks();
mocks.imageElement = null;
});

describe("join pages", () => {
it("uses generic metadata for a Gemini-only profile", async () => {
mockServiceClient([
{
cost_usd: 12.34,
model_breakdown: [{ model: "gemini-2.5-pro", cost_usd: 12.34 }],
agents: ["gemini"],
},
]);

const metadata = await generateMetadata(params);

expect(metadata.description).toBe(
"@agent-athlete has spent $12.34 on AI coding. Think you can keep up?",
);
expect(metadata.description).not.toMatch(/Claude Code|Codex/);
});

it("does not infer the rendered provider from a GPT model", async () => {
mockServiceClient([
{
cost_usd: 56.78,
model_breakdown: [{ model: "gpt-5.6", cost_usd: 56.78 }],
agents: ["opencode"],
},
]);

const markup = renderToStaticMarkup(await JoinPage(params));

expect(markup).toContain(
"@agent-athlete has spent $56.78 on AI coding.",
);
expect(markup).not.toMatch(/Claude Code|Codex/);
});

it("uses generic copy in the Open Graph image", async () => {
mockServiceClient([
{
cost_usd: 90.12,
model_breakdown: [{ model: "gpt-5.6", cost_usd: 90.12 }],
agents: ["opencode"],
},
]);

await JoinOgImage(params);

const markup = renderToStaticMarkup(mocks.imageElement as ReactElement);
expect(markup).toContain(
"@agent-athlete has spent $90.12 on AI coding.",
);
expect(markup).not.toMatch(/Claude Code|Codex/);
});
});
28 changes: 18 additions & 10 deletions apps/web/app/(landing)/cli/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,22 +334,30 @@ straude auto logs`}</Pre>
Data Sources
</h2>
<p className="mt-2">
The CLI collects data from two sources in parallel:
The CLI uses one bundled{" "}
<strong>ccusage</strong> collector. It includes all 16 sources
supported by the current release:
</p>
<ul className="mt-3 list-disc pl-6 space-y-1">
<li>
<strong>ccusage</strong> — Claude Code session data (cost,
tokens, models).
</li>
<li>
<strong>@ccusage/codex</strong> — Codex usage data (same
schema).
Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes
Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen Code,
GitHub Copilot CLI, Gemini CLI, and Grok Build CLI.
</li>
</ul>
<p className="mt-2">
Entries are merged by date. If one source is unavailable, the
other is used alone. The server deduplicates — pushing the same
date twice is safe.
See the{" "}
<a
href="https://ccusage.com/guide/source-support-qa#source-support-q-a"
className="text-accent underline underline-offset-2"
target="_blank"
rel="noreferrer"
>
ccusage source support guide
</a>
. Antigravity and ZCode support is not in the released version
bundled here. Mistral Vibe is not currently supported by
ccusage. The server deduplicates repeated dates safely.
</p>
</section>

Expand Down
22 changes: 3 additions & 19 deletions apps/web/app/(landing)/join/[username]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default async function Image({
.gte("date", sevenDaysAgo),
supabase
.from("daily_usage")
.select("cost_usd, model_breakdown")
.select("cost_usd")
.eq("user_id", referrer.id),
supabase.rpc("calculate_user_streak", {
p_user_id: referrer.id,
Expand All @@ -60,26 +60,10 @@ export default async function Image({
totalRows?.reduce((s, r) => s + Number(r.cost_usd), 0) ?? 0;
const streak = (streakResult?.data as number) ?? 0;

let claudeSpend = 0;
let codexSpend = 0;
for (const row of totalRows ?? []) {
for (const m of (row.model_breakdown as Array<{
model: string;
cost_usd: number;
}>) ?? []) {
if (/^(gpt|codex|o[1-9])/i.test(m.model)) {
codexSpend += m.cost_usd;
} else {
claudeSpend += m.cost_usd;
}
}
}
const primaryTool = codexSpend > claudeSpend ? "Codex" : "Claude Code";

let headline: string;
let subline: string;
if (totalSpend > 0) {
headline = `@${username} has spent $${formatCurrency(totalSpend)} on ${primaryTool}.`;
headline = `@${username} has spent $${formatCurrency(totalSpend)} on AI coding.`;
subline = "Think you can keep up?";
} else if (streak > 0) {
headline = `@${username} has a ${streak}-day streak going.`;
Expand Down Expand Up @@ -327,7 +311,7 @@ function fallbackImage(fonts: Awaited<ReturnType<typeof loadFonts>>) {
marginTop: 8,
}}
>
Strava for Claude Code
Strava for AI coding
</div>
</div>
),
Expand Down
35 changes: 4 additions & 31 deletions apps/web/app/(landing)/join/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,14 @@ export async function generateMetadata({

const { data: totalRows } = await supabase
.from("daily_usage")
.select("cost_usd, model_breakdown")
.select("cost_usd")
.eq("user_id", user?.id ?? "");

const totalSpend = totalRows?.reduce((s, r) => s + Number(r.cost_usd), 0) ?? 0;

let claudeSpend = 0;
let codexSpend = 0;
for (const row of totalRows ?? []) {
for (const m of (row.model_breakdown as Array<{ model: string; cost_usd: number }>) ?? []) {
if (/^(gpt|codex|o[1-9])/i.test(m.model)) {
codexSpend += m.cost_usd;
} else {
claudeSpend += m.cost_usd;
}
}
}
const primaryTool = codexSpend > claudeSpend ? "Codex" : "Claude Code";

const description =
totalSpend > 0
? `@${username} has spent $${formatCurrency(totalSpend)} on ${primaryTool}. Think you can keep up?`
? `@${username} has spent $${formatCurrency(totalSpend)} on AI coding. Think you can keep up?`
: `@${username} just joined Straude. Race them to the top.`;

return {
Expand Down Expand Up @@ -118,7 +105,7 @@ export default async function JoinPage({
.gte("date", sevenDaysAgo),
supabase
.from("daily_usage")
.select("cost_usd, model_breakdown")
.select("cost_usd")
.eq("user_id", referrer.id),
supabase.rpc("calculate_user_streak", {
p_user_id: referrer.id,
Expand All @@ -134,26 +121,12 @@ export default async function JoinPage({
totalRows?.reduce((s, r) => s + Number(r.cost_usd), 0) ?? 0;
const streak = (streakResult?.data as number) ?? 0;

// Determine primary tool by total spend across model breakdowns
let claudeSpend = 0;
let codexSpend = 0;
for (const row of totalRows ?? []) {
for (const m of (row.model_breakdown as Array<{ model: string; cost_usd: number }>) ?? []) {
if (/^(gpt|codex|o[1-9])/i.test(m.model)) {
codexSpend += m.cost_usd;
} else {
claudeSpend += m.cost_usd;
}
}
}
const primaryTool = codexSpend > claudeSpend ? "Codex" : "Claude Code";

// Choose competitive headline
let headline: string;
let subline: string;

if (totalSpend > 0) {
headline = `@${username} has spent $${formatCurrency(totalSpend)} on ${primaryTool}.`;
headline = `@${username} has spent $${formatCurrency(totalSpend)} on AI coding.`;
subline = "Think you can keep up?";
} else if (streak > 0) {
headline = `@${username} has a ${streak}-day streak going.`;
Expand Down
Loading
Loading