Skip to content
Closed
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
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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 from [agentsview](https://www.agentsview.io/), 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.

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

Expand Down Expand Up @@ -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 agentsview locally on your machine. Agentsview aggregates supported coding-agent token counts and estimated 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?

Expand Down
178 changes: 173 additions & 5 deletions apps/web/__tests__/api/usage-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,46 @@ 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 unified 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: { unified: "agentsview-v1" },
},
{ authorization: "Bearer some-token" }
)
);

expect(res.status).toBe(200);
expect(svc.upsert.mock.calls[0][0].collector_meta).toEqual({
unified: "agentsview-v1",
});
});

it("handles Supabase session auth (cookie/web)", async () => {
mockSupabaseAuth("web-user-id");
const svc = mockServiceClient();
Expand Down Expand Up @@ -536,6 +576,9 @@ describe("POST /api/usage/submit", () => {
const deviceUpsertChain: Record<string, any> = {
upsert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockImplementation(() => ({
eq: vi.fn().mockResolvedValue({ data: deviceRows, error: null }),
})),
single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }),
};
const deviceFetchChain: Record<string, any> = {
Expand Down Expand Up @@ -749,6 +792,15 @@ describe("POST /api/usage/submit", () => {
eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }),
})),
};
const deviceWriteProbeChain: Record<string, any> = {
upsert: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockImplementation(() => ({
eq: vi.fn().mockResolvedValue({ data: [existingDeviceRow], error: null }),
})),
single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }),
};
const deviceFetchChain: Record<string, any> = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockImplementation(() => ({
Expand Down Expand Up @@ -776,6 +828,7 @@ describe("POST /api/usage/submit", () => {
deviceFromCallCount++;
if (deviceFromCallCount === 1) return deviceGuardChain;
if (deviceFromCallCount === 2) return deviceCountChain;
if (deviceFromCallCount === 3) return deviceWriteProbeChain;
return deviceFetchChain;
}
if (table === "daily_usage") return dailyChain;
Expand All @@ -799,12 +852,18 @@ describe("POST /api/usage/submit", () => {
const json = await res.json();

expect(res.status).toBe(200);
expect(deviceWriteProbeChain.upsert).not.toHaveBeenCalled();
expect(deviceWriteProbeChain.delete).not.toHaveBeenCalled();
expect(json.results[0].daily_total).toBe(100);
expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(100);
});

it("allows native Codex repair submissions to lower inflated device and daily rows", async () => {
(verifyCliToken as any).mockReturnValue("user-1");
it.each([
["native Codex repair", { codex: "straude-codex-native-v1" }],
["unified agentsview", { unified: "agentsview-v1" }],
])("allows %s submissions to lower inflated device and daily rows", async (label, collector) => {
const userId = `repair-${label.toLowerCase().replaceAll(" ", "-")}`;
(verifyCliToken as any).mockReturnValue(userId);

const correctedDeviceRow = {
cost_usd: 10,
Expand All @@ -828,6 +887,9 @@ describe("POST /api/usage/submit", () => {
const deviceUpsertChain: Record<string, any> = {
upsert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockImplementation(() => ({
eq: vi.fn().mockResolvedValue({ data: [existingDeviceRow], error: null }),
})),
single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }),
};
const deviceCountChain: Record<string, any> = {
Expand Down Expand Up @@ -888,21 +950,127 @@ describe("POST /api/usage/submit", () => {
modelBreakdown: [{ model: "gpt-5-codex", cost_usd: 10 }],
})],
source: "cli",
collector: { codex: "straude-codex-native-v1" },
collector,
})
);
const json = await res.json();

expect(res.status).toBe(200);
expect(deviceUpsertChain.upsert).toHaveBeenCalled();
expect(deviceUpsertChain.upsert.mock.calls[0][0].collector_meta).toEqual({ codex: "straude-codex-native-v1" });
expect(deviceUpsertChain.upsert.mock.calls[0][0].collector_meta).toEqual(collector);
expect(deviceDeleteChain.delete).toHaveBeenCalled();
expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(10);
expect(dailyChain.upsert.mock.calls[0][0].collector_meta).toEqual({ codex: "straude-codex-native-v1" });
expect(dailyChain.upsert.mock.calls[0][0].collector_meta).toEqual(collector);
expect(json.results[0].previous_cost).toBe(100);
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<string, any> = {
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<string, any> = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockImplementation(() => ({
eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }),
})),
};
const deviceUpsertChain: Record<string, any> = {
upsert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockImplementation(() => ({
eq: vi.fn().mockResolvedValue({ data: [existingDeviceRow], error: null }),
})),
single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }),
};
const deviceDeleteChain: Record<string, any> = {
delete: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const deviceFetchChain: Record<string, any> = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockImplementation(() => ({
eq: vi.fn().mockResolvedValue({ data: [existingDeviceRow], error: null }),
})),
};
const dailyChain: Record<string, any> = {
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<string, any> = {
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;
if (deviceFromCallCount === 3) return deviceUpsertChain;
if (deviceFromCallCount === 4) return deviceDeleteChain;
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");

Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/(app)/post/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export default async function NewPostPage() {
Or import manually
</h4>
<p className="mt-2 text-sm text-muted">
Paste ccusage JSON output. Manual imports are unverified.
Paste ccusage or agentsview JSON output. Manual imports are
unverified.
</p>
<Link
href="/settings/import"
Expand Down
Loading