diff --git a/docs/design/codeburn-mcp-plan.md b/docs/design/codeburn-mcp-plan.md new file mode 100644 index 0000000..83e2caa --- /dev/null +++ b/docs/design/codeburn-mcp-plan.md @@ -0,0 +1,755 @@ +# CodeBurn MCP Server — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `codeburn mcp` stdio MCP server exposing CodeBurn's usage/cost data to AI agents via two tools (`get_usage`, `get_savings`), each returning a markdown table plus typed structured JSON. + +**Architecture:** Extract the existing `status --format menubar-json` aggregation into one reusable `buildMenubarPayloadForRange(periodInfo, opts)` (with an `optimize` boolean — the only expensive call, `scanAndDetect`, is skipped for `get_usage`). A long-lived in-process `McpServer` registers the two tools, injects the aggregator for testability, coalesces concurrent calls, hashes project names by default, and relies on the existing 180 s parser cache for warm reuse. + +**Tech Stack:** TypeScript (ESM, `type: module`, node ≥ 22.13), commander, `@modelcontextprotocol/sdk@^1.29` (v1), zod, tsup, vitest. + +--- + +## File Structure + +- **Create `src/usage-aggregator.ts`** — owns `buildPeriodData` (moved from `main.ts`) and the extracted `buildMenubarPayloadForRange(periodInfo, opts): Promise`. Single responsibility: turn a resolved date range + filters into a `MenubarPayload`. +- **Create `src/mcp/redact.ts`** — `pseudonym()` + `redactProjectNames(payload, include)`. Privacy only. +- **Create `src/mcp/tables.ts`** — markdown renderers (`renderSummaryTable`, `renderBreakdownTable`, `renderSavingsTable`). Presentation only. +- **Create `src/mcp/server.ts`** — `createServer(deps)` (tool registration + handlers, aggregator injectable) and `startStdioServer(version)` (loadPricing + pre-warm + stdio transport). +- **Modify `src/main.ts`** — import `buildPeriodData`/`buildMenubarPayloadForRange` from the aggregator; refactor the `status` menubar branch to call the aggregator; add `.command('mcp')`. +- **Modify `package.json`** — add `@modelcontextprotocol/sdk` + `zod` deps. +- **Modify `tsup.config.ts`** — `external: ['@modelcontextprotocol/sdk', 'zod']`. +- **Create tests** — `tests/usage-aggregator.test.ts`, `tests/mcp-redact.test.ts`, `tests/mcp-tables.test.ts`, `tests/mcp-server.test.ts`. + +Internal MCP period names map to CodeBurn's: `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all` (≈6 months). + +--- + +## Task 1: Add dependencies and externalize them in the bundle + +**Files:** +- Modify: `package.json` (dependencies) +- Modify: `tsup.config.ts` + +- [ ] **Step 1: Add runtime deps** + +Run: `npm install @modelcontextprotocol/sdk@^1.29.0 zod@^3.25.0 --save-exact=false` +Expected: both appear under `"dependencies"` in `package.json`; `node_modules/@modelcontextprotocol/sdk` and `node_modules/zod` exist. + +- [ ] **Step 2: Externalize them so they are not inlined into `dist/main.js`** + +Edit `tsup.config.ts` to: + +```ts +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/main.ts'], + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + splitting: false, + sourcemap: true, + dts: false, + external: ['@modelcontextprotocol/sdk', 'zod'], +}) +``` + +- [ ] **Step 3: Build and confirm the SDK is external (not bundled)** + +Run: `npm run build && node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); if(/from'@modelcontextprotocol\/sdk|require\(.@modelcontextprotocol\/sdk./.test(s.replace(/\s/g,''))||!/McpServer/.test(s)){} console.log('built, bytes', s.length)"` +Expected: build succeeds, prints `built, bytes `. (The SDK isn't imported anywhere yet, so this just verifies the config builds.) + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json tsup.config.ts +git commit -m "build(mcp): add @modelcontextprotocol/sdk + zod, externalize in tsup" +``` + +--- + +## Task 2: Move `buildPeriodData` into a shared module + +`buildPeriodData` is currently a private function in `main.ts:410`. The aggregator needs it, so move it to the new module and import it back into `main.ts`. + +**Files:** +- Create: `src/usage-aggregator.ts` +- Modify: `src/main.ts:410` (remove local def), import section + +- [ ] **Step 1: Create the module with `buildPeriodData` moved verbatim** + +Create `src/usage-aggregator.ts`. Move the entire `function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { ... }` body from `main.ts` into it, exported, with imports it needs: + +```ts +import type { ProjectSummary } from './types.js' +import { type PeriodData } from './menubar-json.js' + +export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { + // ... exact body moved from main.ts:410 ... +} +``` + +(Copy the body unchanged. Add any imports the body references — e.g. `getShortModelName` from `./models.js` — until `npx tsc --noEmit` is clean.) + +- [ ] **Step 2: Update `main.ts` to import it and delete the local copy** + +Remove the `function buildPeriodData(...) {...}` at `main.ts:410`. Add to the import block near the top: + +```ts +import { buildPeriodData } from './usage-aggregator.js' +``` + +- [ ] **Step 3: Typecheck + run the full suite (parity guard)** + +Run: `npx tsc --noEmit && npm test` +Expected: typecheck clean; all existing tests pass (the `status` paths still use `buildPeriodData`, now imported). + +- [ ] **Step 4: Commit** + +```bash +git add src/usage-aggregator.ts src/main.ts +git commit -m "refactor: move buildPeriodData into usage-aggregator module" +``` + +--- + +## Task 3: Extract `buildMenubarPayloadForRange` + +Move the `status` menubar-json aggregation block (`main.ts:485–759`, everything after `periodInfo`/`now` are computed, ending at the `console.log`) into the aggregator as a function that **returns** the payload instead of printing it. + +**Files:** +- Modify: `src/usage-aggregator.ts` (add the function) +- Modify: `src/main.ts:476–761` (call it) +- Test: existing `tests/cli-status-menubar.test.ts` is the parity guard + +- [ ] **Step 1: Add the function signature + types to `src/usage-aggregator.ts`** + +```ts +import { homedir } from 'node:os' +import type { ProjectSummary, DateRange } from './types.js' +import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js' +import { parseAllSessions, getAllProviders, filterProjectsByName, filterProjectsByDays } from './parser.js' +import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { scanAndDetect } from './optimize.js' +import { hydrateCache, loadDailyCache, getDaysInRange, toDateString, BACKFILL_DAYS, type DailyCache } from './daily-cache.js' + +export type PeriodInfo = { range: DateRange; label: string } +export type AggregateOpts = { + provider?: string + project?: string[] + exclude?: string[] + daysSelection?: { range: DateRange; label: string; days: Set } | null + optimize?: boolean +} + +export async function buildMenubarPayloadForRange( + periodInfo: PeriodInfo, + opts: AggregateOpts = {}, +): Promise { + const pf = opts.provider ?? 'all' + const daysSelection = opts.daysSelection ?? null + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? []) + // ... moved block from main.ts:485–757 (now/todayStart/... through breakdowns) ... + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) + return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns) +} +``` + +Move lines `main.ts:485–757` (from `const now = new Date()` through the `breakdowns` IIFE) into the body unchanged. The block already references only the symbols imported above plus `homedir()`. `hydrateCache` is imported from `./daily-cache.js` (same module as `loadDailyCache`); if tsc reports a different source, follow the import it suggests. + +- [ ] **Step 2: Precondition note — pricing** + +The block assumes `loadPricing()` already ran. The `status` action calls it at `main.ts:473`; the MCP server will call it at startup (Task 6). Do **not** call `loadPricing()` inside the function. + +- [ ] **Step 3: Refactor the `status` menubar branch to call it** + +Replace `main.ts:485–760` (the inline block + `console.log` + `return`) with: + +```ts +const payload = await buildMenubarPayloadForRange(periodInfo, { + provider: pf, + project: opts.project, + exclude: opts.exclude, + daysSelection, + optimize: opts.optimize !== false, +}) +console.log(JSON.stringify(payload)) +return +``` + +Keep `main.ts:477–484` (the `daysSelection`/`customRange`/`daySelection`/`periodInfo` resolution) — `periodInfo` and `daysSelection` are the inputs now passed in. Add `buildMenubarPayloadForRange` to the existing import from `./usage-aggregator.js`. + +- [ ] **Step 4: Typecheck + parity test** + +Run: `npx tsc --noEmit && npm test -- cli-status-menubar` +Expected: clean typecheck; `tests/cli-status-menubar.test.ts` passes — i.e. `status --format menubar-json` output is unchanged (parity). + +- [ ] **Step 5: Add a direct unit test for the aggregator** + +Create `tests/usage-aggregator.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' +import { getDateRange } from '../src/cli-date.js' + +describe('buildMenubarPayloadForRange', () => { + it('returns a zero payload with no data and skips optimize when optimize:false', async () => { + const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }) + expect(payload.current.cost).toBe(0) + expect(payload.current.calls).toBe(0) + expect(payload.optimize.findingCount).toBe(0) + expect(Array.isArray(payload.current.topProjects)).toBe(true) + }) +}) +``` + +Run: `npm test -- usage-aggregator` +Expected: PASS (uses the empty real environment; `scanAndDetect` not called because `optimize:false`). + +- [ ] **Step 6: Commit** + +```bash +git add src/usage-aggregator.ts src/main.ts tests/usage-aggregator.test.ts +git commit -m "refactor: extract buildMenubarPayloadForRange for reuse by MCP" +``` + +--- + +## Task 4: Project-name redaction + +**Files:** +- Create: `src/mcp/redact.ts` +- Test: `tests/mcp-redact.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/mcp-redact.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { pseudonym, redactProjectNames } from '../src/mcp/redact.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function payload(): MenubarPayload { + const base = { + name: 'secret-client-repo', cost: 5, sessions: 2, avgCostPerSession: 2.5, sessionDetails: [], + } + return { + generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] }, + current: { + label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0, + cacheHitPercent: 0, topActivities: [], topModels: [], providers: {}, + topProjects: [base], modelEfficiency: [], + topSessions: [{ project: 'secret-client-repo', cost: 5, calls: 10, date: '2026-06-01' }], + retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, + routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +describe('redact', () => { + it('pseudonym is stable and path-free', () => { + expect(pseudonym('a')).toBe(pseudonym('a')) + expect(pseudonym('secret-client-repo')).toMatch(/^project-[0-9a-f]{6}$/) + }) + it('hashes project names by default, preserves numbers', () => { + const out = redactProjectNames(payload(), false) + expect(out.current.topProjects[0]!.name).toMatch(/^project-[0-9a-f]{6}$/) + expect(out.current.topSessions[0]!.project).toMatch(/^project-[0-9a-f]{6}$/) + expect(out.current.topProjects[0]!.cost).toBe(5) + }) + it('keeps real names when include=true', () => { + const out = redactProjectNames(payload(), true) + expect(out.current.topProjects[0]!.name).toBe('secret-client-repo') + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npm test -- mcp-redact` +Expected: FAIL ("Cannot find module '../src/mcp/redact.js'"). + +- [ ] **Step 3: Implement** + +Create `src/mcp/redact.ts`: + +```ts +import { createHash } from 'node:crypto' +import type { MenubarPayload } from '../menubar-json.js' + +export function pseudonym(name: string): string { + return `project-${createHash('sha256').update(name).digest('hex').slice(0, 6)}` +} + +export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload { + if (includeNames) return payload + return { + ...payload, + current: { + ...payload.current, + topProjects: payload.current.topProjects.map(p => ({ ...p, name: pseudonym(p.name) })), + topSessions: payload.current.topSessions.map(s => ({ ...s, project: pseudonym(s.project) })), + }, + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npm test -- mcp-redact` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/redact.ts tests/mcp-redact.test.ts +git commit -m "feat(mcp): hash project names by default with opt-in reveal" +``` + +--- + +## Task 5: Markdown table renderers + +**Files:** +- Create: `src/mcp/tables.ts` +- Test: `tests/mcp-tables.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/mcp-tables.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { renderSummaryTable, renderBreakdownTable, renderSavingsTable } from '../src/mcp/tables.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function payload(): MenubarPayload { + return { + generated: '', optimize: { findingCount: 1, savingsUSD: 2.5, topFindings: [{ title: 'Trim system prompt', impact: 'high', savingsUSD: 2.5 }] }, history: { daily: [] }, + current: { + label: 'Last 7 Days', cost: 12.5, calls: 100, sessions: 4, oneShotRate: 0.5, inputTokens: 1000, outputTokens: 500, + cacheHitPercent: 80, topActivities: [{ name: 'feature', cost: 8, turns: 30, oneShotRate: 0.6 }], + topModels: [{ name: 'Opus 4.8', cost: 10, calls: 60 }], providers: { 'claude code': 12.5 }, + topProjects: [{ name: 'project-abc123', cost: 12.5, sessions: 4, avgCostPerSession: 3.125, sessionDetails: [] }], + modelEfficiency: [], topSessions: [], + retryTax: { totalUSD: 1.2, retries: 4, editTurns: 20, byModel: [{ name: 'Opus 4.8', taxUSD: 1.2, retries: 4, retriesPerEdit: 0.2 }] }, + routingWaste: { totalSavingsUSD: 3, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [{ name: 'Opus 4.8', costPerEdit: 0.05, editTurns: 20, actualUSD: 1, counterfactualUSD: 0.2, savingsUSD: 0.8 }] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +describe('tables', () => { + it('summary shows headline cost and top models', () => { + const t = renderSummaryTable(payload()) + expect(t).toContain('Last 7 Days') + expect(t).toContain('Opus 4.8') + expect(t).toContain('| Model | Cost | Calls |') + }) + it('breakdown by provider lists providers', () => { + expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code') + }) + it('breakdown handles empty dimension gracefully', () => { + const p = payload(); p.current.topActivities = [] + expect(renderBreakdownTable(p, 'task', 20)).toContain('no data') + }) + it('savings shows retry tax and routing waste', () => { + const t = renderSavingsTable(payload()) + expect(t).toContain('Retry tax') + expect(t).toContain('Routing waste') + expect(t).toContain('Trim system prompt') + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npm test -- mcp-tables` +Expected: FAIL ("Cannot find module '../src/mcp/tables.js'"). + +- [ ] **Step 3: Implement** + +Create `src/mcp/tables.ts`: + +```ts +import { formatCost, formatTokens } from '../format.js' +import type { MenubarPayload } from '../menubar-json.js' + +export type BreakdownBy = 'project' | 'model' | 'task' | 'provider' + +function mdTable(headers: string[], rows: string[][]): string { + const head = `| ${headers.join(' | ')} |` + const sep = `| ${headers.map(() => '---').join(' | ')} |` + if (rows.length === 0) return `${head}\n${sep}\n| _(no data)_ |${' |'.repeat(headers.length - 1)}` + return [head, sep, ...rows.map(r => `| ${r.join(' | ')} |`)].join('\n') +} + +const pct = (n: number) => `${Math.round(n)}%` +const oneShot = (r: number | null) => (r === null ? 'n/a' : pct(r * 100)) + +export function renderSummaryTable(p: MenubarPayload): string { + const c = p.current + return [ + `**${c.label}** — ${formatCost(c.cost)} · ${c.calls} calls · ${c.sessions} sessions`, + `cache hit ${pct(c.cacheHitPercent)} · one-shot ${oneShot(c.oneShotRate)} · in ${formatTokens(c.inputTokens)} / out ${formatTokens(c.outputTokens)}`, + '', + '_Top models_', + mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])), + '', + '_Top projects_', + mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])), + ].join('\n') +} + +export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string { + const c = p.current + if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)])) + if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)])) + if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)])) + return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)])) +} + +export function renderSavingsTable(p: MenubarPayload): string { + const c = p.current + const findings = mdTable(['Finding', 'Impact', 'Saves'], p.optimize.topFindings.slice(0, 10).map(f => [f.title, f.impact, formatCost(f.savingsUSD)])) + const retry = mdTable(['Model', 'Retry tax', 'Retries'], c.retryTax.byModel.map(m => [m.name, formatCost(m.taxUSD), String(m.retries)])) + const routing = mdTable(['Model', 'Overpaid', 'vs baseline'], c.routingWaste.byModel.map(m => [m.name, formatCost(m.savingsUSD), c.routingWaste.baselineModel])) + return [ + `**Savings — ${c.label}**`, + `Optimize findings: ${p.optimize.findingCount} (≈ ${formatCost(p.optimize.savingsUSD)})`, + findings, '', + `_Retry tax_ — ${formatCost(c.retryTax.totalUSD)} on ${c.retryTax.retries} retries`, + retry, '', + `_Routing waste_ — ${formatCost(c.routingWaste.totalSavingsUSD)} vs ${c.routingWaste.baselineModel || 'n/a'}`, + routing, + ].join('\n') +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npm test -- mcp-tables` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/tables.ts tests/mcp-tables.test.ts +git commit -m "feat(mcp): markdown table renderers for usage and savings" +``` + +--- + +## Task 6: MCP server (tools, schemas, handlers, coalescing) + +**Files:** +- Create: `src/mcp/server.ts` +- Test: `tests/mcp-server.test.ts` + +- [ ] **Step 1: Write the failing test (in-memory transport, injected aggregator)** + +Create `tests/mcp-server.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { createServer } from '../src/mcp/server.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function fakePayload(calls = 100): MenubarPayload { + return { + generated: '', optimize: { findingCount: 1, savingsUSD: 2, topFindings: [{ title: 'X', impact: 'high', savingsUSD: 2 }] }, history: { daily: [] }, + current: { + label: 'Today', cost: 9, calls, sessions: 1, oneShotRate: 0.5, inputTokens: 10, outputTokens: 5, cacheHitPercent: 50, + topActivities: [{ name: 'feature', cost: 9, turns: 5, oneShotRate: 0.5 }], topModels: [{ name: 'Opus 4.8', cost: 9, calls }], + providers: { 'claude code': 9 }, topProjects: [{ name: 'real-repo', cost: 9, sessions: 1, avgCostPerSession: 9, sessionDetails: [] }], + modelEfficiency: [], topSessions: [{ project: 'real-repo', cost: 9, calls, date: '2026-06-01' }], + retryTax: { totalUSD: 1, retries: 2, editTurns: 5, byModel: [{ name: 'Opus 4.8', taxUSD: 1, retries: 2, retriesPerEdit: 0.4 }] }, + routingWaste: { totalSavingsUSD: 1, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +async function connect(aggregate: any) { + const server = createServer({ version: 'test', aggregate }) + const [a, b] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1' }) + await Promise.all([server.connect(a), client.connect(b)]) + return client +} + +describe('mcp server', () => { + it('exposes exactly two read-only tools', async () => { + const client = await connect(async () => fakePayload()) + const { tools } = await client.listTools() + expect(tools.map(t => t.name).sort()).toEqual(['get_savings', 'get_usage']) + expect(tools.find(t => t.name === 'get_usage')!.annotations?.readOnlyHint).toBe(true) + }) + it('get_usage hashes project names by default', async () => { + const client = await connect(async () => fakePayload()) + const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project' } }) + expect(JSON.stringify(res)).not.toContain('real-repo') + expect(JSON.stringify(res)).toMatch(/project-[0-9a-f]{6}/) + expect(res.isError).toBeFalsy() + }) + it('get_usage reveals names when opted in', async () => { + const client = await connect(async () => fakePayload()) + const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project', include_project_names: true } }) + expect(JSON.stringify(res)).toContain('real-repo') + }) + it('empty data returns a friendly message, not a zero table', async () => { + const client = await connect(async () => fakePayload(0)) + const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today' } }) + expect(res.content[0].text.toLowerCase()).toContain('no usage') + }) + it('aggregator failure surfaces as isError', async () => { + const client = await connect(async () => { throw new Error('boom') }) + const res: any = await client.callTool({ name: 'get_savings', arguments: {} }) + expect(res.isError).toBe(true) + expect(res.content[0].text).toContain('boom') + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npm test -- mcp-server` +Expected: FAIL ("Cannot find module '../src/mcp/server.js'"). + +- [ ] **Step 3: Implement the server** + +Create `src/mcp/server.ts`: + +```ts +import { z } from 'zod' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { getDateRange } from '../cli-date.js' +import { loadPricing } from '../models.js' +import { buildMenubarPayloadForRange, type PeriodInfo } from '../usage-aggregator.js' +import type { MenubarPayload } from '../menubar-json.js' +import { redactProjectNames } from './redact.js' +import { renderSummaryTable, renderBreakdownTable, renderSavingsTable, type BreakdownBy } from './tables.js' + +const PERIOD = { today: 'today', last_7_days: 'week', last_30_days: '30days', month_to_date: 'month', last_6_months: 'all' } as const +type McpPeriod = keyof typeof PERIOD +const periodSchema = z.enum(['today', 'last_7_days', 'last_30_days', 'month_to_date', 'last_6_months']) + +type Aggregate = (periodInfo: PeriodInfo, opts: { provider?: string; optimize?: boolean }) => Promise + +const INSTRUCTIONS = + 'CodeBurn exposes local AI-coding spend data. Use get_usage for spend/usage and breakdowns (fast); ' + + 'use get_savings to find cost reductions (slower — runs a deeper analysis). Project names are pseudonymized ' + + 'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' + + 'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.' + +export function createServer(deps: { version: string; aggregate?: Aggregate }): McpServer { + const aggregate = deps.aggregate ?? buildMenubarPayloadForRange + const inflight = new Map>() + + const getPayload = (period: McpPeriod, optimize: boolean): Promise => { + const key = `${optimize ? 'sav' : 'use'}:${period}` + const existing = inflight.get(key) + if (existing) return existing + const { range, label } = getDateRange(PERIOD[period]) + const p = aggregate({ range, label }, { provider: 'all', optimize }).finally(() => inflight.delete(key)) + inflight.set(key, p) + return p + } + + const server = new McpServer({ name: 'codeburn', version: deps.version }, { instructions: INSTRUCTIONS }) + + server.registerTool( + 'get_usage', + { + title: 'CodeBurn — usage & cost', + description: + 'Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break ' + + 'it down by project, model, task, or provider (Claude Code / Cursor / Codex). Fast. Local to this machine.', + inputSchema: { + period: periodSchema.default('today'), + by: z.enum(['project', 'model', 'task', 'provider']).optional(), + limit: z.number().int().min(1).max(100).default(20), + include_project_names: z.boolean().default(false), + }, + outputSchema: { + period: z.string(), + empty: z.boolean(), + totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }), + breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(), + }, + annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }, + async ({ period, by, limit, include_project_names }): Promise => { + try { + const payload = redactProjectNames(await getPayload(period, false), include_project_names) + const c = payload.current + const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate } + if (c.calls === 0) { + return { content: [{ type: 'text', text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }], structuredContent: { period: c.label, empty: true, totals, breakdown: null } } + } + const text = by ? renderBreakdownTable(payload, by as BreakdownBy, limit) : renderSummaryTable(payload) + const breakdown = by ? breakdownRows(payload, by as BreakdownBy, limit) : null + return { content: [{ type: 'text', text }], structuredContent: { period: c.label, empty: false, totals, breakdown } } + } catch (err) { + return { content: [{ type: 'text', text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }], isError: true } + } + }, + ) + + server.registerTool( + 'get_savings', + { + title: 'CodeBurn — savings opportunities', + description: + 'Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing ' + + 'work), and routing waste (what you would have saved on a cheaper model). Slower than get_usage.', + inputSchema: { period: periodSchema.default('last_7_days'), include_project_names: z.boolean().default(false) }, + outputSchema: { + period: z.string(), + optimize: z.object({ findingCount: z.number(), savingsUSD: z.number(), topFindings: z.array(z.object({ title: z.string(), impact: z.string(), savingsUSD: z.number() })) }), + retryTaxUSD: z.number(), + routingWasteUSD: z.number(), + }, + annotations: { title: 'CodeBurn — savings opportunities', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }, + async ({ period, include_project_names }): Promise => { + try { + const payload = redactProjectNames(await getPayload(period, true), include_project_names) + const c = payload.current + return { + content: [{ type: 'text', text: renderSavingsTable(payload) }], + structuredContent: { period: c.label, optimize: payload.optimize, retryTaxUSD: c.retryTax.totalUSD, routingWasteUSD: c.routingWaste.totalSavingsUSD }, + } + } catch (err) { + return { content: [{ type: 'text', text: `codeburn: failed to compute savings — ${err instanceof Error ? err.message : String(err)}` }], isError: true } + } + }, + ) + + return server +} + +function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> { + const c = p.current + if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost })) + if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost })) + if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost })) + return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost })) +} + +export async function startStdioServer(version: string): Promise { + await loadPricing() + const server = createServer({ version }) + // Pre-warm the parser cache for the common case; ignore failures. + void buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }).catch(() => {}) + await server.connect(new StdioServerTransport()) +} +``` + +> If the installed SDK rejects the raw-shape `inputSchema`/`outputSchema` (object of zod validators), wrap each in `z.object({ ... })` — the in-memory test in Step 4 will surface this immediately. Likewise, if `InMemoryTransport`'s import path differs, it is exported from `@modelcontextprotocol/sdk/inMemory.js` in v1. + +- [ ] **Step 4: Run to verify pass** + +Run: `npm test -- mcp-server` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/server.ts tests/mcp-server.test.ts +git commit -m "feat(mcp): get_usage + get_savings tools with annotations, schemas, coalescing" +``` + +--- + +## Task 7: Wire the `codeburn mcp` command + +**Files:** +- Modify: `src/main.ts` (add command + stdout guard) + +- [ ] **Step 1: Add the command** + +In `src/main.ts`, alongside the other `.command(...)` registrations (e.g. after the `status` block), add: + +```ts +program + .command('mcp') + .description('Run a Model Context Protocol server (stdio) exposing usage + savings to AI agents') + .action(async () => { + // stdout MUST carry only JSON-RPC; route stray logs to stderr. + console.log = ((...args: unknown[]) => process.stderr.write(args.join(' ') + '\n')) as typeof console.log + const { startStdioServer } = await import('./mcp/server.js') + await startStdioServer(version) + }) +``` + +(`version` is already in scope at `main.ts:30`.) + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: succeeds. `src/mcp/server.ts` is reachable from the `main.ts` import graph (via the dynamic import), so tsup bundles it; the `@modelcontextprotocol/sdk` and `zod` imports stay external (Task 1). + +- [ ] **Step 3: Smoke-test the built server over stdio** + +Run: +```bash +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | node dist/cli.js mcp 2>/dev/null | head -2 +``` +Expected: two JSON-RPC result lines on stdout; the second lists `get_usage` and `get_savings`. No non-JSON noise on stdout (warnings, if any, went to stderr). + +- [ ] **Step 4: Verify the SDK is external in the bundle** + +Run: `node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); console.log('McpServer source inlined?', /class McpServer/.test(s))"` +Expected: `McpServer source inlined? false` (it's imported from node_modules at runtime, not bundled). + +- [ ] **Step 5: Commit** + +```bash +git add src/main.ts +git commit -m "feat(mcp): add 'codeburn mcp' stdio command with stdout guard" +``` + +--- + +## Task 8: Full suite + final verification + +**Files:** none (verification) + +- [ ] **Step 1: Run everything** + +Run: `npx tsc --noEmit && npm test && npm run build` +Expected: typecheck clean; all tests pass (incl. the pre-existing suite — parity preserved); build succeeds. + +- [ ] **Step 2: Manual end-to-end against real data (optional but recommended)** + +Run: `node dist/cli.js mcp` then, in another shell, point a local MCP client (or the smoke command from Task 7) at it and call `get_usage {"period":"today"}` and `get_savings {"period":"last_7_days"}`. Confirm tables render and project names are hashed. + +- [ ] **Step 3: Commit any cleanup** + +```bash +git add -A && git commit -m "chore(mcp): final verification" || echo "nothing to commit" +``` + +--- + +## Deferred (not in v1 — see spec §10 and note below) + +- **Per-provider graceful degradation (`degraded[]`).** The spec (§5/§8) called for `allSettled` per-provider isolation. That requires changing the shared `parseAllSessions` loop (`parser.ts:2133`), which every command uses — out of scope for v1 to avoid destabilizing the parser. v1 handles failures at the tool boundary (`isError: true` with the message). A malformed provider aborting a scan is a **pre-existing** behavior shared with the `status`/menubar path, not introduced here. *This is the one intentional deviation from the committed spec.* +- HTTP/SSE transport, `compare_periods`, MCP prompts/resources, README + registry listings. + +## Self-Review + +- **Spec coverage:** in-process stdio server (Task 6/7) ✓; cheap vs optimize split via `optimize` flag (Task 3, refined — `scanAndDetect` is the only expensive call) ✓; 2 tools with annotations + outputSchema + isError (Task 6) ✓; hash-by-default redaction (Task 4) ✓; in-flight coalescing + pre-warm (Task 6) ✓; tsup external + pinned deps (Task 1) ✓; stdout guard (Task 7) ✓; period-enum rename + `last_6_months` semantics (Task 6 + instructions) ✓; empty-state message (Task 6) ✓; token discipline via `limit` + summarized history (Task 6, no daily array returned) ✓. Deviation: per-provider `degraded[]` deferred (documented above). +- **Placeholders:** none — every code step has full source; the only "move verbatim" steps (Tasks 2–3) are mechanical relocations of existing, cited code, gated by the existing parity test. +- **Type consistency:** `buildMenubarPayloadForRange(PeriodInfo, AggregateOpts)`, `redactProjectNames(MenubarPayload, boolean)`, `renderBreakdownTable(payload, BreakdownBy, limit)`, `createServer({version, aggregate})` — names/signatures consistent across tasks and tests. diff --git a/docs/design/codeburn-mcp.md b/docs/design/codeburn-mcp.md new file mode 100644 index 0000000..b2c7a31 --- /dev/null +++ b/docs/design/codeburn-mcp.md @@ -0,0 +1,125 @@ +# CodeBurn MCP Server — Design Spec + +- **Date:** 2026-06-02 +- **Status:** Approved design (pre-implementation) +- **Author:** brainstormed + validator/devil's-advocate hardened + +## 1. Context & Goal + +CodeBurn already aggregates rich AI-coding usage/cost data (by task, model, project, provider; retry tax; routing waste; optimize findings; 365-day history). This spec adds an **MCP server** that exposes that data to AI agents (Claude Code, Cursor, Claude Desktop) so an agent can answer "where did my tokens go?" and "how do I spend less?" mid-conversation. + +**Why (and why not):** This is a **product / differentiation** play, not a downloads play. We measured that MCP is a niche *download* lever (`@ccusage/mcp` ≈ 1.1% of ccusage's downloads; competitor tokscale ships no MCP), and the real download lever is npx-first CLI positioning — tracked separately. The MCP's value is agent-facing utility on data competitors don't expose (retry tax, routing waste, one-shot rate, task attribution). + +## 2. Decisions (from brainstorming) + +1. **Use case:** unified — serves both live self-optimization and historical analysis from one tool set. +2. **Output contract:** every tool returns a ready-to-display **markdown table** *and* the same data as **structured JSON**. +3. **Architecture:** Approach A — a `codeburn mcp` subcommand on the existing CLI (no second package), reusing the existing aggregation; run as a **long-lived in-process stdio server**. +4. **Tool surface:** **2 tools** — `get_usage` and `get_savings`. (`compare_periods` cut — no reusable backend, overlap-incoherent.) +5. **Privacy:** project/session names **hashed by default** (`project-<6hex>`); real names only when `include_project_names: true`. Absolute paths never exposed. + +## 3. Architecture + +### Process model +- `codeburn mcp` starts a **resident, in-process** MCP server over **stdio** (`StdioServerTransport`). Not exec-per-call — a resident process is required so the existing in-process session cache (180s TTL, `src/parser.ts`) amortizes across tool calls. Measured cost otherwise: `--period all` is ~17.6s **even warm** when the process exits between calls. +- **First line of the `mcp` action:** `console.log = console.error`. The aggregation path is stdout-clean today (writes go to stderr), but the global `preAction` hook and `runOptimize` contain stdout `console.log`s; reassigning immunizes the JSON-RPC stream against any present or future stdout write. (Verified: `scanAndDetect`, `parseAllSessions`, providers, caches, `buildMenubarPayload` do not write to stdout.) +- Pre-warm `today` usage on boot so the first interactive call is fast. + +### Modules +- **`src/usage-aggregator.ts`** *(new)* — extracted from the inline logic in the `status` handler (`src/main.ts` ~476–760): + - `buildUsage(period, opts): Promise` — **cheap path**, no optimize pass. + - `buildSavings(period, opts): Promise` — adds `scanAndDetect` (optimize findings + retry tax + routing waste). + - `opts: { provider?: string; project?: string[]; exclude?: string[]; range?: ResolvedRange }`. The `provider` and project/range filters are **required** for parity — the `status` body forks on `isAllProviders` and resolves a range from `day/days/from/to` before `period`. `status --format menubar-json` is refactored to call these (one shared path). +- **`src/mcp/server.ts`** *(new)* — builds `McpServer`, registers the 2 tools, connects `StdioServerTransport`, owns the in-flight coalescing map and the empty-state messages. +- **`src/mcp/tables.ts`** *(new)* — compact markdown renderers per slice, reusing `format.ts` and `models-report.ts` where they already render tables. +- **`src/mcp/redact.ts`** *(new)* — stable pseudonym hashing for project/session names; applied unless the caller passes `include_project_names: true`. +- **`src/main.ts`** — add `.command('mcp')` that dynamic-`import()`s `./mcp/server.js` and starts it. + +### Dependencies & build +- Add to **`dependencies`**: `@modelcontextprotocol/sdk@^1.29.0` (v1 line; import paths `@modelcontextprotocol/sdk/server/mcp.js` and `/server/stdio.js`) and `zod@^3.25` (NOT transitive — it's a peer dep of the SDK and absent from the lockfile today). +- Add to **`tsup.config.ts`**: `external: ['@modelcontextprotocol/sdk', 'zod']`. The current config bundles all deps (`splitting: false`, no `external`), so without this a dynamic import would inline the SDK (~MBs) into `dist/main.js`. Externalizing keeps `dist` small and makes the dynamic import a real lazy load from `node_modules`. (`files: ["dist"]` means externalized deps must be runtime `dependencies`.) +- Pin the SDK major: a separately-named v2 package exists with different import paths; `^1.29.0` keeps the v1 paths valid. + +## 4. Tool Surface + +Period enum (LLM-clear names, mapped internally): `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all`. Documented: `last_6_months` is the maximum window (codeburn's "all" = ~6 months); history is summarized, not dumped. + +Both tools carry annotations `{ readOnlyHint: true, openWorldHint: false, idempotentHint: true, title }`, a zod `inputSchema` and `outputSchema`, and return `{ content: [{ type:'text', text: }], structuredContent: }`. + +### 4.1 `get_usage` +- **title:** "CodeBurn — usage & cost" +- **description (agent-facing):** "Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break it down by project, model, task, or provider. Fast (does not run the deeper savings analysis). Data is local to this machine and current as of the last scan." +- **inputSchema:** + - `period?: enum` (default `today`) + - `by?: "project" | "model" | "task" | "provider"` + - `limit?: number` (default 20, max 100) — row cap for breakdowns + - `include_project_names?: boolean` (default `false`) +- **Behavior:** no `by` → headline (cost, calls, sessions, input/output tokens, cache-hit %, one-shot rate) + top-N models/projects/tasks. With `by` → one ranked table for that dimension (`project→topProjects`, `model→topModels`, `task→topActivities`, `provider→providers` cost map). Uses `buildUsage` (cheap path). +- **outputSchema:** the relevant subset of `MenubarPayload.current` (typed). + +### 4.2 `get_savings` +- **title:** "CodeBurn — savings opportunities" +- **description (agent-facing):** "Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing work), and routing waste (what you'd have saved on a cheaper model). Runs a deeper analysis, so it is slower than get_usage." +- **inputSchema:** `period?: enum` (default `last_7_days` — never default to the slow `last_6_months`), `include_project_names?: boolean` (default `false`). +- **Behavior:** runs `buildSavings`; returns optimize findings (title, impact, $ saved), retry tax (total + by model), routing waste (total savings, baseline model, by model). +- **outputSchema:** `{ optimize, retryTax, routingWaste }` (typed). + +### 4.3 Server `instructions` +> "CodeBurn exposes local AI-coding spend data. Use `get_usage` for spend/usage and breakdowns (fast); use `get_savings` to find cost reductions (slower). Project names are pseudonymized unless you pass `include_project_names: true`. All data is read locally from this machine; `last_6_months` is the widest window. Numbers reflect the most recent scan and may lag the current in-flight session by a short interval." + +## 5. Data Flow + +``` +agent tool call + → zod inputSchema validation (invalid params → protocol error, auto) + → in-flight coalesce on {kind, period, opts-hash} (parallel callers share one scan) + → buildUsage / buildSavings(period, {provider:'all', ...}) + → parseAllSessions with PER-PROVIDER allSettled isolation → degraded[] + → existing 180s in-process session cache amortizes + → redact project/session names unless include_project_names + → render markdown table + build structuredContent (validated vs outputSchema) + → return { content:[{type:'text',text}], structuredContent } (isError:true on failure) +``` + +## 6. Privacy / Redaction +- Name-bearing fields — `topProjects[].name`, `topSessions[].project`, `topProjects[].sessionDetails` — are replaced with stable pseudonyms `project-<6hex(sha256(name))>` unless `include_project_names: true`. +- Absolute paths are never emitted (they aren't in the payload today; the MCP must not enrich with them). +- Rationale: an MCP is an egress surface to possibly-remote/cloud agents; codeburn's brand is "data never leaves your machine." Hashing keeps breakdowns coherent (stable pseudonyms) without leaking identity; local users who want real names opt in per call. + +## 7. Performance +- **Two paths:** `get_usage` uses the cheap aggregation (`--no-optimize` seam already exists; ~3s `today`, ~5s `all`). `get_savings` runs the optimize pass (~+13s) — isolated to the one tool that needs it. Without this split, every tool paid the 13s tax. +- **Resident process** + existing 180s session cache → warm calls are cheap. +- **In-flight coalescing:** concurrent calls for the same `{kind, period, opts}` await a single scan (agents fire tools in parallel). +- **Pre-warm** `today` usage on boot. +- **Token discipline:** breakdowns capped at `limit` (default 20); history is summarized (totals + top-N), never the full 365-day array; tables are compact. + +## 8. Error Handling +- Invalid params → zod → MCP protocol error (auto). +- No data for period (fresh install) → `isError: false` with a friendly "no usage recorded for yet — run some coding sessions" message (not a zero-filled table). +- One provider fails to parse → isolate via `allSettled`, return partial data, list skipped providers in `degraded[]` and a table footer note. +- Aggregation throw / payload-shape mismatch → `isError: true` with a clear message (incl. version-skew hint); never hang the transport. + +## 9. Testing +- **Parity:** `buildUsage('today', {provider:'all'})` deep-equals current `status --format menubar-json --period today` (plus one more period). Guards the extraction. +- **Per-tool:** fixture payload → expected markdown table (snapshot) + structuredContent keys; `by` each dimension; redaction on/off (no real names/paths leak when off; pseudonyms stable); empty-state message. +- **MCP protocol smoke:** in-memory transport — `listTools` returns the 2 tools with annotations + outputSchema; call each; assert result shape (`content` + `structuredContent`) and `isError` paths; concurrent identical calls coalesce to one scan. +- **Build:** assert SDK + zod are externalized (absent from `dist`) and declared in `dependencies`. + +## 10. Out of Scope (v1 — YAGNI) +- HTTP/SSE transport (stdio only). +- `compare_periods` (agent can diff two `get_usage` calls; backend is net-new and overlap-incoherent). +- Write/mutating tools, auth, multi-machine/remote data. +- MCP **prompts** and **resources** primitives (tools-only v1; revisit if a "cost-review" prompt template proves useful). +- README "MCP" section and MCP-registry listings (lobehub/Smithery/mcp.so) — follow-up tasks, not code. + +## 11. Provenance — review findings incorporated +Hardened by a validator + devil's-advocate pass: +- **BLOCKER** in-process resident server (caches don't help exec-per-call) — §3, §7. +- **BLOCKER** split cheap vs optimize path (optimize ≈ 70% of cost) — §7. +- **BLOCKER** redact names by default (raw repo names + dated spend would egress) — §6. +- **MAJOR** `buildUsage/buildSavings` signature carries provider/project/range for parity — §3. +- **MAJOR** tsup `external` + deps in `dependencies`; pin SDK `^1.29.0`; add `zod` explicitly — §3. +- **MAJOR** per-provider `allSettled` isolation + `degraded[]` — §5, §8. +- **MAJOR** in-flight coalescing — §5, §7. +- **MAJOR** drop `compare_periods`; rename period enums; default `today` — §4, §10. +- **MINOR** `console.log→console.error` stdout guard; friendly empty-state; run compiled `dist` not `tsx`; validate payload shape; `last_6_months` is the real max — §3, §8, §4. diff --git a/package-lock.json b/package-lock.json index b79a4c1..2f3aec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,21 @@ { "name": "codeburn", - "version": "0.9.10", + "version": "0.9.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn", - "version": "0.9.10", + "version": "0.9.11", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.4.1", "commander": "^13.1.0", "ink": "^7.0.0", "react": "^19.2.5", - "strip-ansi": "^7.2.0" + "strip-ansi": "^7.2.0", + "zod": "^3.25.76" }, "bin": { "codeburn": "dist/cli.js" @@ -485,6 +487,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -524,6 +538,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1034,6 +1088,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1047,6 +1114,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1115,6 +1215,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1131,6 +1255,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1141,6 +1274,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1277,6 +1439,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -1286,6 +1470,55 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1297,7 +1530,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1321,6 +1553,44 @@ "node": ">=6" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -1333,6 +1603,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1340,6 +1628,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", @@ -1392,6 +1692,12 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -1411,6 +1717,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1421,6 +1757,89 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1439,6 +1858,27 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1451,6 +1891,24 @@ "rollup": "^4.34.8" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1466,6 +1924,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -1478,6 +1945,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -1491,6 +1995,87 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -1503,6 +2088,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ink": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/ink/-/ink-7.0.0.tgz", @@ -1552,6 +2143,24 @@ } } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -1582,6 +2191,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1599,6 +2229,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1646,6 +2288,61 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -1672,7 +2369,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -1706,16 +2402,57 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -1731,6 +2468,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -1740,6 +2486,25 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1787,6 +2552,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -1871,6 +2645,58 @@ } } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -1909,6 +2735,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1990,12 +2825,178 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2064,6 +3065,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -2256,6 +3266,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2361,6 +3380,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2389,6 +3439,24 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -2560,6 +3628,21 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2609,6 +3692,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -2635,6 +3724,24 @@ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 732b6b7..7322402 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,13 @@ }, "homepage": "https://github.com/getagentseal/codeburn#readme", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.4.1", "commander": "^13.1.0", "ink": "^7.0.0", "react": "^19.2.5", - "strip-ansi": "^7.2.0" + "strip-ansi": "^7.2.0", + "zod": "^3.25.76" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/src/main.ts b/src/main.ts index cc562e7..ee24166 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,22 +1,19 @@ -import { homedir } from 'node:os' import { Command } from 'commander' import { installMenubarApp } from './menubar-installer.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' import { loadPricing, setModelAliases } from './models.js' -import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from './parser.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js' import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' -import { type PeriodData, type ProviderCost, type BreakdownArrays } from './menubar-json.js' -import { buildMenubarPayload } from './menubar-json.js' -import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' -import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' +import { toDateString } from './daily-cache.js' +import { dateKey } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { aggregateModelEfficiency } from './model-efficiency.js' +import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js' import { renderDashboard } from './dashboard.js' import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js' -import { runOptimize, scanAndDetect } from './optimize.js' +import { runOptimize } from './optimize.js' import { renderCompare } from './compare.js' -import { getAllProviders } from './providers/index.js' import { installAntigravityStatusLineHook, runAgyStatusLineHook, @@ -31,17 +28,6 @@ const require = createRequire(import.meta.url) const { version } = require('../package.json') import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' -async function hydrateCache() { - try { - return await ensureCacheHydrated( - (range) => parseAllSessions(range, 'all'), - aggregateProjectsIntoDays, - ) - } catch { - return emptyCache() - } -} - function collect(val: string, acc: string[]): string[] { acc.push(val) return acc @@ -407,45 +393,6 @@ program await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day) }) -function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { - const sessions = projects.flatMap(p => p.sessions) - const catTotals: Record = {} - const modelTotals: Record = {} - let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 - - for (const sess of sessions) { - inputTokens += sess.totalInputTokens - outputTokens += sess.totalOutputTokens - cacheReadTokens += sess.totalCacheReadTokens - cacheWriteTokens += sess.totalCacheWriteTokens - for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { - if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } - catTotals[cat].turns += d.turns - catTotals[cat].cost += d.costUSD - catTotals[cat].editTurns += d.editTurns - catTotals[cat].oneShotTurns += d.oneShotTurns - } - for (const [model, d] of Object.entries(sess.modelBreakdown)) { - if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 } - modelTotals[model].calls += d.calls - modelTotals[model].cost += d.costUSD - } - } - - return { - label, - cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), - calls: projects.reduce((s, p) => s + p.totalApiCalls, 0), - sessions: projects.reduce((s, p) => s + p.sessions.length, 0), - inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, - categories: Object.entries(catTotals) - .sort(([, a], [, b]) => b.cost - a.cost) - .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), - models: Object.entries(modelTotals) - .sort(([, a], [, b]) => b.cost - a.cost) - .map(([name, d]) => ({ name, ...d })), - } -} program .command('status') @@ -482,281 +429,14 @@ program : customRange ? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) } : daySelection ?? getDateRange(opts.period) - const now = new Date() - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const todayRange: DateRange = { start: todayStart, end: now } - const todayStr = toDateString(todayStart) - const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) - const rangeStartStr = toDateString(periodInfo.range.start) - const rangeEndStr = toDateString(periodInfo.range.end) - const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr - const isAllProviders = pf === 'all' - - let todayAllProjects: ProjectSummary[] | null = null - let todayAllDays: ReturnType | null = null - - const getTodayAllProjects = async (): Promise => { - if (!todayAllProjects) { - todayAllProjects = fp(await parseAllSessions(todayRange, 'all')) - } - return todayAllProjects - } - - const getTodayAllDays = async (): Promise> => { - if (!todayAllDays) { - todayAllDays = aggregateProjectsIntoDays(await getTodayAllProjects()) - } - return todayAllDays - } - - let currentData: PeriodData - let scanProjects: ProjectSummary[] - let scanRange: DateRange - let cache: DailyCache - let todayProviderData: PeriodData | null = null - - if (isAllProviders) { - cache = await hydrateCache() - const todayProjects = await getTodayAllProjects() - const todayDays = await getTodayAllDays() - const historicalDays = rangeStartStr <= historicalRangeEndStr - ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) - : [] - const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) - const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) - const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays - currentData = buildPeriodDataFromDays(allDays, periodInfo.label) - const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr - if (isTodayOnly) { - scanProjects = todayProjects - scanRange = todayRange - } else { - const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all')) - scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects - scanRange = periodInfo.range - } - } else { - cache = await loadDailyCache() - const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf)) - const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects - todayProviderData = buildPeriodData(periodInfo.label, fullProjects) - currentData = todayProviderData - scanProjects = fullProjects - scanRange = periodInfo.range - } - if (isAllProviders) { - currentData = buildPeriodData(periodInfo.label, scanProjects) - } - - // PROVIDERS - // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. - // For specific: just this single provider with its scoped cost. - const allProviders = await getAllProviders() - const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) - const providers: ProviderCost[] = [] - if (isAllProviders) { - const unfilteredProviderDays = [ - ...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []), - ...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr), - ] - const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays - const providerTotals: Record = {} - for (const d of allDaysForProviders) { - for (const [name, p] of Object.entries(d.providers)) { - providerTotals[name] = (providerTotals[name] ?? 0) + p.cost - } - } - for (const [name, cost] of Object.entries(providerTotals)) { - providers.push({ name: displayNameByName.get(name) ?? name, cost }) - } - for (const p of allProviders) { - if (providers.some(pc => pc.name === p.displayName)) continue - const sources = await p.discoverSessions() - if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 }) - } - } else { - const display = displayNameByName.get(pf) ?? pf - providers.push({ name: display, cost: currentData.cost }) - } - - // DAILY HISTORY (last 365 days) - // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive - // a provider-filtered history without re-parsing. Tokens aren't broken down per provider - // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). - const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) - const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) - - let dailyHistory - if (isAllProviders) { - const todayDays = (await getTodayAllDays()).filter(d => d.date === todayStr) - const fullHistory = [...allCacheDays, ...todayDays] - dailyHistory = fullHistory.map(d => { - const topModels = Object.entries(d.models) - .filter(([name]) => name !== '') - .sort(([, a], [, b]) => b.cost - a.cost) - .slice(0, 5) - .map(([name, m]) => ({ - name, - cost: m.cost, - calls: m.calls, - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - })) - return { - date: d.date, - cost: d.cost, - calls: d.calls, - inputTokens: d.inputTokens, - outputTokens: d.outputTokens, - cacheReadTokens: d.cacheReadTokens, - cacheWriteTokens: d.cacheWriteTokens, - topModels, - } - }) - } else { - const emptyModels = [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[] - const historyFromCache = allCacheDays.map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: emptyModels, - } - }) - const todayFromParse = aggregateProjectsIntoDays(scanProjects) - .filter(d => d.date === todayStr) - .map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: emptyModels, - } - }) - dailyHistory = [...historyFromCache, ...todayFromParse] - } - - const home = homedir() - const friendlyProject = (p: ProjectSummary) => { - const resolved = p.projectPath || p.project - if (resolved === home || resolved === home + '/') return 'Home' - return resolved.split('/').filter(Boolean).pop() || p.project - } - - currentData.projects = scanProjects.map(p => ({ - name: friendlyProject(p), - cost: p.totalCostUSD, - sessions: p.sessions.length, - sessionDetails: [...p.sessions] - .sort((a, b) => b.totalCostUSD - a.totalCostUSD) - .slice(0, 10) - .map(s => ({ - cost: s.totalCostUSD, - calls: s.apiCalls, - inputTokens: s.totalInputTokens, - outputTokens: s.totalOutputTokens, - date: s.firstTimestamp?.split('T')[0] ?? '', - models: Object.entries(s.modelBreakdown) - .map(([name, m]) => ({ name, cost: m.costUSD })) - .sort((a, b) => b.cost - a.cost) - .slice(0, 3), - })), - })) - - const effMap = aggregateModelEfficiency(scanProjects) - currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({ - name, - costPerEdit: eff.costPerEditUSD, - oneShotRate: eff.oneShotRate, - })) - - const retryTaxByModel = [...effMap.values()] - .filter(m => m.retries > 0 && m.editTurns > 0) - .map(m => ({ - name: m.model, - taxUSD: m.retries * (m.editCostUSD / m.editTurns), - retries: m.retries, - retriesPerEdit: m.retriesPerEdit, - })) - .sort((a, b) => b.taxUSD - a.taxUSD) - const retryTax = { - totalUSD: retryTaxByModel.reduce((s, m) => s + m.taxUSD, 0), - retries: retryTaxByModel.reduce((s, m) => s + m.retries, 0), - editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0), - byModel: retryTaxByModel.slice(0, 5), - } - - currentData.topSessions = scanProjects.flatMap(p => - p.sessions.map(s => ({ - project: friendlyProject(p), - cost: s.totalCostUSD, - calls: s.apiCalls, - date: s.firstTimestamp?.split('T')[0] ?? '', - })) - ).sort((a, b) => b.cost - a.cost).slice(0, 5) - - // Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits), - // then compute how much each pricier model overpaid. - const reliableModels = [...effMap.values()] - .filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5 - && (m.costPerEditUSD ?? 0) >= 0.01) - .sort((a, b) => (a.costPerEditUSD ?? Infinity) - (b.costPerEditUSD ?? Infinity)) - const baseline = reliableModels[0] - const routingWasteByModel = baseline - ? [...effMap.values()] - .filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0)) - .map(m => { - const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0) - return { - name: m.model, - costPerEdit: m.costPerEditUSD ?? 0, - editTurns: m.editTurns, - actualUSD: m.editCostUSD, - counterfactualUSD: counterfactual, - savingsUSD: m.editCostUSD - counterfactual, - } - }) - .filter(m => m.savingsUSD > 0) - .sort((a, b) => b.savingsUSD - a.savingsUSD) - : [] - const routingWaste = { - totalSavingsUSD: routingWasteByModel.reduce((s, m) => s + m.savingsUSD, 0), - baselineModel: baseline?.model ?? '', - baselineCostPerEdit: baseline?.costPerEditUSD ?? 0, - byModel: routingWasteByModel.slice(0, 5), - } - - const breakdowns: BreakdownArrays = (() => { - const toolMap: Record = {} - const skillMap: Record = {} - const subagentMap: Record = {} - const mcpMap: Record = {} - for (const p of scanProjects) for (const s of p.sessions) { - for (const [t, d] of Object.entries(s.toolBreakdown)) { if (!t.startsWith('lang:')) toolMap[t] = (toolMap[t] ?? 0) + d.calls } - for (const [sk, d] of Object.entries(s.skillBreakdown)) { const e = skillMap[sk] ?? { turns: 0, cost: 0 }; e.turns += d.turns; e.cost += d.costUSD; skillMap[sk] = e } - for (const [sa, d] of Object.entries(s.subagentBreakdown)) { const e = subagentMap[sa] ?? { calls: 0, cost: 0 }; e.calls += d.calls; e.cost += d.costUSD; subagentMap[sa] = e } - for (const [m, d] of Object.entries(s.mcpBreakdown)) { mcpMap[m] = (mcpMap[m] ?? 0) + d.calls } - } - return { - tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), - skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), - subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), - mcpServers: Object.entries(mcpMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), - } - })() - - const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) - console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns))) + const payload = await buildMenubarPayloadForRange(periodInfo, { + provider: pf, + project: opts.project, + exclude: opts.exclude, + daysSelection, + optimize: opts.optimize !== false, + }) + console.log(JSON.stringify(payload)) return } @@ -1278,4 +958,16 @@ program await runAgyStatusLineHook() }) +program + .command('mcp') + .description('Run a Model Context Protocol server (stdio) exposing usage + savings to AI agents') + .action(async () => { + // stdout MUST carry only JSON-RPC; route stray logs to stderr. + // NOTE: only console.log is guarded here. process.stdout.write is left intact + // because the MCP StdioServerTransport relies on it for JSON-RPC output. + console.log = ((...args: unknown[]) => process.stderr.write(args.join(' ') + '\n')) as typeof console.log + const { startStdioServer } = await import('./mcp/server.js') + await startStdioServer(version) + }) + program.parse() diff --git a/src/mcp/redact.ts b/src/mcp/redact.ts new file mode 100644 index 0000000..3355cbb --- /dev/null +++ b/src/mcp/redact.ts @@ -0,0 +1,47 @@ +import { createHash, randomBytes } from 'node:crypto' +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import type { MenubarPayload } from '../menubar-json.js' + +let salt: string | undefined + +function getSalt(): string { + if (salt) return salt + const dir = join(homedir(), '.config', 'codeburn') + const saltPath = join(dir, '.mcp-salt') + try { + salt = readFileSync(saltPath, 'utf-8').trim() + if (salt) return salt + } catch { /* first run */ } + salt = randomBytes(32).toString('hex') + try { + mkdirSync(dir, { recursive: true }) + writeFileSync(saltPath, salt + '\n', { mode: 0o600 }) + } catch { /* best-effort */ } + return salt +} + +export function pseudonym(name: string): string { + return `project-${createHash('sha256').update(getSalt() + name).digest('hex').slice(0, 6)}` +} + +function redactSessionDetails(details: Array<{ cost: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number }> }>): Array<{ cost: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number }> }> { + return details.map(d => ({ ...d, date: '', models: [] })) +} + +export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload { + if (includeNames) return payload + return { + ...payload, + current: { + ...payload.current, + topProjects: payload.current.topProjects.map(p => ({ + ...p, + name: pseudonym(p.name), + sessionDetails: p.sessionDetails ? redactSessionDetails(p.sessionDetails) : [], + })), + topSessions: payload.current.topSessions.map(s => ({ ...s, project: pseudonym(s.project) })), + }, + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..41dd39c --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,136 @@ +import { z } from 'zod' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { getDateRange } from '../cli-date.js' +import { loadPricing } from '../models.js' +import { buildMenubarPayloadForRange, type PeriodInfo } from '../usage-aggregator.js' +import type { MenubarPayload } from '../menubar-json.js' +import { redactProjectNames } from './redact.js' +import { renderSummaryTable, renderBreakdownTable, renderSavingsTable, type BreakdownBy } from './tables.js' + +const PERIOD = { today: 'today', last_7_days: 'week', last_30_days: '30days', month_to_date: 'month', last_6_months: 'all' } as const +type McpPeriod = keyof typeof PERIOD +const periodSchema = z.enum(['today', 'last_7_days', 'last_30_days', 'month_to_date', 'last_6_months']) + +type Aggregate = (periodInfo: PeriodInfo, opts: { provider?: string; optimize?: boolean }) => Promise + +const INSTRUCTIONS = + 'CodeBurn exposes local AI-coding spend data. Use get_usage for spend/usage and breakdowns (fast); ' + + 'use get_savings to find cost reductions (slower — runs a deeper analysis). Project names are pseudonymized ' + + 'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' + + 'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.' + +function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> { + const c = p.current + if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost })) + if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost })) + if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost })) + return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost })) +} + +export function createServer(deps: { version: string; aggregate?: Aggregate }): McpServer { + const aggregate = deps.aggregate ?? buildMenubarPayloadForRange + const inflight = new Map>() + + const getPayload = (period: McpPeriod, optimize: boolean): Promise => { + const key = `${optimize ? 'sav' : 'use'}:${period}` + const existing = inflight.get(key) + if (existing) return existing + const { range, label } = getDateRange(PERIOD[period]) + const p = aggregate({ range, label }, { provider: 'all', optimize }).finally(() => inflight.delete(key)) + inflight.set(key, p) + return p + } + + const server = new McpServer({ name: 'codeburn', version: deps.version }, { instructions: INSTRUCTIONS }) + + server.registerTool( + 'get_usage', + { + title: 'CodeBurn — usage & cost', + description: + 'Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break ' + + 'it down by project, model, task, or provider (Claude Code / Cursor / Codex). Fast. Local to this machine.', + inputSchema: { + period: periodSchema.default('today'), + by: z.enum(['project', 'model', 'task', 'provider']).optional(), + limit: z.number().int().min(1).max(100).default(20), + include_project_names: z.boolean().default(false), + }, + outputSchema: { + period: z.string(), + empty: z.boolean(), + totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }), + breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(), + }, + annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }, + async ({ period, by, limit, include_project_names }) => { + try { + const payload = redactProjectNames(await getPayload(period, false), include_project_names) + const c = payload.current + const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate } + if (c.calls === 0) { + return { + content: [{ type: 'text' as const, text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }], + structuredContent: { period: c.label, empty: true, totals, breakdown: null }, + } + } + const text = by ? renderBreakdownTable(payload, by, limit) : renderSummaryTable(payload) + const breakdown = by ? breakdownRows(payload, by, limit) : null + return { + content: [{ type: 'text' as const, text }], + structuredContent: { period: c.label, empty: false, totals, breakdown }, + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }], + structuredContent: { period: 'unknown', empty: true, totals: { costUSD: 0, calls: 0, sessions: 0, cacheHitPercent: 0, oneShotRate: null }, breakdown: null }, + isError: true, + } + } + }, + ) + + server.registerTool( + 'get_savings', + { + title: 'CodeBurn — savings opportunities', + description: + 'Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing ' + + 'work), and routing waste (what you would have saved on a cheaper model). Slower than get_usage.', + inputSchema: { period: periodSchema.default('last_7_days'), include_project_names: z.boolean().default(false) }, + outputSchema: { + period: z.string(), + optimize: z.object({ findingCount: z.number(), savingsUSD: z.number(), topFindings: z.array(z.object({ title: z.string(), impact: z.string(), savingsUSD: z.number() })) }), + retryTaxUSD: z.number(), + routingWasteUSD: z.number(), + }, + annotations: { title: 'CodeBurn — savings opportunities', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }, + async ({ period, include_project_names }) => { + try { + const payload = redactProjectNames(await getPayload(period, true), include_project_names) + const c = payload.current + return { + content: [{ type: 'text' as const, text: renderSavingsTable(payload) }], + structuredContent: { period: c.label, optimize: payload.optimize, retryTaxUSD: c.retryTax.totalUSD, routingWasteUSD: c.routingWaste.totalSavingsUSD }, + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `codeburn: failed to compute savings — ${err instanceof Error ? err.message : String(err)}` }], + structuredContent: { period: 'unknown', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, retryTaxUSD: 0, routingWasteUSD: 0 }, + isError: true, + } + } + }, + ) + + return server +} + +export async function startStdioServer(version: string): Promise { + await loadPricing() + const server = createServer({ version }) + await server.connect(new StdioServerTransport()) +} diff --git a/src/mcp/tables.ts b/src/mcp/tables.ts new file mode 100644 index 0000000..cfcb995 --- /dev/null +++ b/src/mcp/tables.ts @@ -0,0 +1,52 @@ +import { formatCost, formatTokens } from '../format.js' +import type { MenubarPayload } from '../menubar-json.js' + +export type BreakdownBy = 'project' | 'model' | 'task' | 'provider' + +function mdTable(headers: string[], rows: string[][]): string { + const head = `| ${headers.join(' | ')} |` + const sep = `| ${headers.map(() => '---').join(' | ')} |` + if (rows.length === 0) return `${head}\n${sep}\n| _(no data)_ ${' | '.repeat(headers.length - 1)}|` + return [head, sep, ...rows.map(r => `| ${r.join(' | ')} |`)].join('\n') +} + +const pct = (n: number) => `${Math.round(n)}%` +const oneShot = (r: number | null) => (r === null ? 'n/a' : pct(r * 100)) + +export function renderSummaryTable(p: MenubarPayload): string { + const c = p.current + return [ + `**${c.label}** — ${formatCost(c.cost)} · ${c.calls} calls · ${c.sessions} sessions`, + `cache hit ${pct(c.cacheHitPercent)} · one-shot ${oneShot(c.oneShotRate)} · in ${formatTokens(c.inputTokens)} / out ${formatTokens(c.outputTokens)}`, + '', + '_Top models_', + mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])), + '', + '_Top projects_', + mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])), + ].join('\n') +} + +export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string { + const c = p.current + if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)])) + if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)])) + if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)])) + return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)])) +} + +export function renderSavingsTable(p: MenubarPayload): string { + const c = p.current + const findings = mdTable(['Finding', 'Impact', 'Saves'], p.optimize.topFindings.slice(0, 10).map(f => [f.title, f.impact, formatCost(f.savingsUSD)])) + const retry = mdTable(['Model', 'Retry tax', 'Retries'], c.retryTax.byModel.map(m => [m.name, formatCost(m.taxUSD), String(m.retries)])) + const routing = mdTable(['Model', 'Overpaid', 'vs baseline'], c.routingWaste.byModel.map(m => [m.name, formatCost(m.savingsUSD), c.routingWaste.baselineModel])) + return [ + `**Savings — ${c.label}**`, + `Optimize findings: ${p.optimize.findingCount} (≈ ${formatCost(p.optimize.savingsUSD)})`, + findings, '', + `_Retry tax_ — ${formatCost(c.retryTax.totalUSD)} on ${c.retryTax.retries} retries`, + retry, '', + `_Routing waste_ — ${formatCost(c.routingWaste.totalSavingsUSD)} vs ${c.routingWaste.baselineModel || 'n/a'}`, + routing, + ].join('\n') +} diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts new file mode 100644 index 0000000..6f7483e --- /dev/null +++ b/src/usage-aggregator.ts @@ -0,0 +1,356 @@ +import { homedir } from 'node:os' +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange } from './types.js' +import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDays } from './parser.js' +import { getAllProviders } from './providers/index.js' +import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { scanAndDetect } from './optimize.js' +import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' + +export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { + const sessions = projects.flatMap(p => p.sessions) + const catTotals: Record = {} + const modelTotals: Record = {} + let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 + + for (const sess of sessions) { + inputTokens += sess.totalInputTokens + outputTokens += sess.totalOutputTokens + cacheReadTokens += sess.totalCacheReadTokens + cacheWriteTokens += sess.totalCacheWriteTokens + for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { + if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } + catTotals[cat].turns += d.turns + catTotals[cat].cost += d.costUSD + catTotals[cat].editTurns += d.editTurns + catTotals[cat].oneShotTurns += d.oneShotTurns + } + for (const [model, d] of Object.entries(sess.modelBreakdown)) { + if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 } + modelTotals[model].calls += d.calls + modelTotals[model].cost += d.costUSD + } + } + + return { + label, + cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), + calls: projects.reduce((s, p) => s + p.totalApiCalls, 0), + sessions: projects.reduce((s, p) => s + p.sessions.length, 0), + inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, + categories: Object.entries(catTotals) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), + models: Object.entries(modelTotals) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([name, d]) => ({ name, ...d })), + } +} + +async function hydrateCache(): Promise { + try { + return await ensureCacheHydrated( + (range) => parseAllSessions(range, 'all'), + aggregateProjectsIntoDays, + ) + } catch { + return emptyCache() + } +} + +export type PeriodInfo = { range: DateRange; label: string } +export type AggregateOpts = { + provider?: string + project?: string[] + exclude?: string[] + daysSelection?: { range: DateRange; label: string; days: Set } | null + optimize?: boolean +} + +/** + * Resolved-range aggregation shared by `status --format menubar-json` and the MCP server. + * Pricing must already be loaded (callers run loadPricing first). When opts.optimize is + * false, the expensive scanAndDetect pass is skipped (retryTax/routingWaste still computed). + */ +export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: AggregateOpts = {}): Promise { + const pf = opts.provider ?? 'all' + const daysSelection = opts.daysSelection ?? null + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? []) + + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const todayRange: DateRange = { start: todayStart, end: now } + const todayStr = toDateString(todayStart) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + const rangeStartStr = toDateString(periodInfo.range.start) + const rangeEndStr = toDateString(periodInfo.range.end) + const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr + const isAllProviders = pf === 'all' + + let todayAllProjects: ProjectSummary[] | null = null + let todayAllDays: ReturnType | null = null + + const getTodayAllProjects = async (): Promise => { + if (!todayAllProjects) { + todayAllProjects = fp(await parseAllSessions(todayRange, 'all')) + } + return todayAllProjects + } + + const getTodayAllDays = async (): Promise> => { + if (!todayAllDays) { + todayAllDays = aggregateProjectsIntoDays(await getTodayAllProjects()) + } + return todayAllDays + } + + let currentData: PeriodData + let scanProjects: ProjectSummary[] + let scanRange: DateRange + let cache: DailyCache + let todayProviderData: PeriodData | null = null + + if (isAllProviders) { + cache = await hydrateCache() + const todayProjects = await getTodayAllProjects() + const todayDays = await getTodayAllDays() + const historicalDays = rangeStartStr <= historicalRangeEndStr + ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) + : [] + const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) + const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) + const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays + currentData = buildPeriodDataFromDays(allDays, periodInfo.label) + const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr + if (isTodayOnly) { + scanProjects = todayProjects + scanRange = todayRange + } else { + const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all')) + scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects + scanRange = periodInfo.range + } + } else { + cache = await loadDailyCache() + const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf)) + const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects + todayProviderData = buildPeriodData(periodInfo.label, fullProjects) + currentData = todayProviderData + scanProjects = fullProjects + scanRange = periodInfo.range + } + if (isAllProviders) { + currentData = buildPeriodData(periodInfo.label, scanProjects) + } + + // PROVIDERS + // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. + // For specific: just this single provider with its scoped cost. + const allProviders = await getAllProviders() + const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) + const providers: ProviderCost[] = [] + if (isAllProviders) { + const unfilteredProviderDays = [ + ...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []), + ...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr), + ] + const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays + const providerTotals: Record = {} + for (const d of allDaysForProviders) { + for (const [name, p] of Object.entries(d.providers)) { + providerTotals[name] = (providerTotals[name] ?? 0) + p.cost + } + } + for (const [name, cost] of Object.entries(providerTotals)) { + providers.push({ name: displayNameByName.get(name) ?? name, cost }) + } + for (const p of allProviders) { + if (providers.some(pc => pc.name === p.displayName)) continue + const sources = await p.discoverSessions() + if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 }) + } + } else { + const display = displayNameByName.get(pf) ?? pf + providers.push({ name: display, cost: currentData.cost }) + } + + // DAILY HISTORY (last 365 days) + // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive + // a provider-filtered history without re-parsing. Tokens aren't broken down per provider + // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). + const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) + const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) + + let dailyHistory + if (isAllProviders) { + const todayDays = (await getTodayAllDays()).filter(d => d.date === todayStr) + const fullHistory = [...allCacheDays, ...todayDays] + dailyHistory = fullHistory.map(d => { + const topModels = Object.entries(d.models) + .filter(([name]) => name !== '') + .sort(([, a], [, b]) => b.cost - a.cost) + .slice(0, 5) + .map(([name, m]) => ({ + name, + cost: m.cost, + calls: m.calls, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + })) + return { + date: d.date, + cost: d.cost, + calls: d.calls, + inputTokens: d.inputTokens, + outputTokens: d.outputTokens, + cacheReadTokens: d.cacheReadTokens, + cacheWriteTokens: d.cacheWriteTokens, + topModels, + } + }) + } else { + const emptyModels = [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[] + const historyFromCache = allCacheDays.map(d => { + const prov = d.providers[pf] ?? { calls: 0, cost: 0 } + return { + date: d.date, + cost: prov.cost, + calls: prov.calls, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: emptyModels, + } + }) + const todayFromParse = aggregateProjectsIntoDays(scanProjects) + .filter(d => d.date === todayStr) + .map(d => { + const prov = d.providers[pf] ?? { calls: 0, cost: 0 } + return { + date: d.date, + cost: prov.cost, + calls: prov.calls, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: emptyModels, + } + }) + dailyHistory = [...historyFromCache, ...todayFromParse] + } + + const home = homedir() + const friendlyProject = (p: ProjectSummary) => { + const resolved = p.projectPath || p.project + if (resolved === home || resolved === home + '/') return 'Home' + return resolved.split('/').filter(Boolean).pop() || p.project + } + + currentData.projects = scanProjects.map(p => ({ + name: friendlyProject(p), + cost: p.totalCostUSD, + sessions: p.sessions.length, + sessionDetails: [...p.sessions] + .sort((a, b) => b.totalCostUSD - a.totalCostUSD) + .slice(0, 10) + .map(s => ({ + cost: s.totalCostUSD, + calls: s.apiCalls, + inputTokens: s.totalInputTokens, + outputTokens: s.totalOutputTokens, + date: s.firstTimestamp?.split('T')[0] ?? '', + models: Object.entries(s.modelBreakdown) + .map(([name, m]) => ({ name, cost: m.costUSD })) + .sort((a, b) => b.cost - a.cost) + .slice(0, 3), + })), + })) + + const effMap = aggregateModelEfficiency(scanProjects) + currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({ + name, + costPerEdit: eff.costPerEditUSD, + oneShotRate: eff.oneShotRate, + })) + + const retryTaxByModel = [...effMap.values()] + .filter(m => m.retries > 0 && m.editTurns > 0) + .map(m => ({ + name: m.model, + taxUSD: m.retries * (m.editCostUSD / m.editTurns), + retries: m.retries, + retriesPerEdit: m.retriesPerEdit, + })) + .sort((a, b) => b.taxUSD - a.taxUSD) + const retryTax = { + totalUSD: retryTaxByModel.reduce((s, m) => s + m.taxUSD, 0), + retries: retryTaxByModel.reduce((s, m) => s + m.retries, 0), + editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0), + byModel: retryTaxByModel.slice(0, 5), + } + + currentData.topSessions = scanProjects.flatMap(p => + p.sessions.map(s => ({ + project: friendlyProject(p), + cost: s.totalCostUSD, + calls: s.apiCalls, + date: s.firstTimestamp?.split('T')[0] ?? '', + })) + ).sort((a, b) => b.cost - a.cost).slice(0, 5) + + // Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits), + // then compute how much each pricier model overpaid. + const reliableModels = [...effMap.values()] + .filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5 + && (m.costPerEditUSD ?? 0) >= 0.01) + .sort((a, b) => (a.costPerEditUSD ?? Infinity) - (b.costPerEditUSD ?? Infinity)) + const baseline = reliableModels[0] + const routingWasteByModel = baseline + ? [...effMap.values()] + .filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0)) + .map(m => { + const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0) + return { + name: m.model, + costPerEdit: m.costPerEditUSD ?? 0, + editTurns: m.editTurns, + actualUSD: m.editCostUSD, + counterfactualUSD: counterfactual, + savingsUSD: m.editCostUSD - counterfactual, + } + }) + .filter(m => m.savingsUSD > 0) + .sort((a, b) => b.savingsUSD - a.savingsUSD) + : [] + const routingWaste = { + totalSavingsUSD: routingWasteByModel.reduce((s, m) => s + m.savingsUSD, 0), + baselineModel: baseline?.model ?? '', + baselineCostPerEdit: baseline?.costPerEditUSD ?? 0, + byModel: routingWasteByModel.slice(0, 5), + } + + const breakdowns: BreakdownArrays = (() => { + const toolMap: Record = {} + const skillMap: Record = {} + const subagentMap: Record = {} + const mcpMap: Record = {} + for (const p of scanProjects) for (const s of p.sessions) { + for (const [t, d] of Object.entries(s.toolBreakdown)) { if (!t.startsWith('lang:')) toolMap[t] = (toolMap[t] ?? 0) + d.calls } + for (const [sk, d] of Object.entries(s.skillBreakdown)) { const e = skillMap[sk] ?? { turns: 0, cost: 0 }; e.turns += d.turns; e.cost += d.costUSD; skillMap[sk] = e } + for (const [sa, d] of Object.entries(s.subagentBreakdown)) { const e = subagentMap[sa] ?? { calls: 0, cost: 0 }; e.calls += d.calls; e.cost += d.costUSD; subagentMap[sa] = e } + for (const [m, d] of Object.entries(s.mcpBreakdown)) { mcpMap[m] = (mcpMap[m] ?? 0) + d.calls } + } + return { + tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), + skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), + subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), + mcpServers: Object.entries(mcpMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), + } + })() + + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) + return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns) +} diff --git a/tests/mcp-redact.test.ts b/tests/mcp-redact.test.ts new file mode 100644 index 0000000..2315708 --- /dev/null +++ b/tests/mcp-redact.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { pseudonym, redactProjectNames } from '../src/mcp/redact.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function payload(): MenubarPayload { + const base = { + name: 'secret-client-repo', cost: 5, sessions: 2, avgCostPerSession: 2.5, + sessionDetails: [{ cost: 3, calls: 5, inputTokens: 100, outputTokens: 50, date: '2026-06-01', models: [{ name: 'Opus', cost: 3 }] }], + } + return { + generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] }, + current: { + label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0, + cacheHitPercent: 0, topActivities: [], topModels: [], providers: {}, + topProjects: [base], modelEfficiency: [], + topSessions: [{ project: 'secret-client-repo', cost: 5, calls: 10, date: '2026-06-01' }], + retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, + routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +describe('redact', () => { + it('pseudonym is stable and path-free', () => { + expect(pseudonym('a')).toBe(pseudonym('a')) + expect(pseudonym('secret-client-repo')).toMatch(/^project-[0-9a-f]{6}$/) + expect(pseudonym('a/b/c')).not.toContain('/') + }) + it('hashes project names by default, preserves numbers', () => { + const out = redactProjectNames(payload(), false) + expect(out.current.topProjects[0]!.name).toMatch(/^project-[0-9a-f]{6}$/) + expect(out.current.topSessions[0]!.project).toMatch(/^project-[0-9a-f]{6}$/) + expect(out.current.topProjects[0]!.cost).toBe(5) + }) + it('redacts session details when hashing', () => { + const out = redactProjectNames(payload(), false) + const details = out.current.topProjects[0]!.sessionDetails! + expect(details).toHaveLength(1) + expect(details[0]!.date).toBe('') + expect(details[0]!.models).toEqual([]) + expect(details[0]!.cost).toBe(3) + }) + it('same project name gets same pseudonym in topProjects and topSessions', () => { + const out = redactProjectNames(payload(), false) + expect(out.current.topProjects[0]!.name).toBe(out.current.topSessions[0]!.project) + }) + it('keeps real names and session details when include=true', () => { + const out = redactProjectNames(payload(), true) + expect(out.current.topProjects[0]!.name).toBe('secret-client-repo') + expect(out.current.topProjects[0]!.sessionDetails![0]!.date).toBe('2026-06-01') + }) +}) diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts new file mode 100644 index 0000000..e022af2 --- /dev/null +++ b/tests/mcp-server.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { createServer } from '../src/mcp/server.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function fakePayload(calls = 100): MenubarPayload { + return { + generated: '', optimize: { findingCount: 1, savingsUSD: 2, topFindings: [{ title: 'X', impact: 'high', savingsUSD: 2 }] }, history: { daily: [] }, + current: { + label: 'Today', cost: 9, calls, sessions: 1, oneShotRate: 0.5, inputTokens: 10, outputTokens: 5, cacheHitPercent: 50, + topActivities: [{ name: 'feature', cost: 9, turns: 5, oneShotRate: 0.5 }], topModels: [{ name: 'Opus 4.8', cost: 9, calls }], + providers: { 'claude code': 9 }, topProjects: [{ name: 'real-repo', cost: 9, sessions: 1, avgCostPerSession: 9, sessionDetails: [] }], + modelEfficiency: [], topSessions: [{ project: 'real-repo', cost: 9, calls, date: '2026-06-01' }], + retryTax: { totalUSD: 1, retries: 2, editTurns: 5, byModel: [{ name: 'Opus 4.8', taxUSD: 1, retries: 2, retriesPerEdit: 0.4 }] }, + routingWaste: { totalSavingsUSD: 1, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +async function connect(aggregate: (p: unknown, o: unknown) => Promise) { + const server = createServer({ version: 'test', aggregate: aggregate as never }) + const [a, b] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1' }) + await Promise.all([server.connect(a), client.connect(b)]) + return client +} + +describe('mcp server', () => { + it('exposes exactly two read-only tools', async () => { + const client = await connect(async () => fakePayload()) + const { tools } = await client.listTools() + expect(tools.map(t => t.name).sort()).toEqual(['get_savings', 'get_usage']) + expect(tools.find(t => t.name === 'get_usage')!.annotations?.readOnlyHint).toBe(true) + }) + it('get_usage hashes project names by default', async () => { + const client = await connect(async () => fakePayload()) + const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project' } }) + expect(JSON.stringify(res)).not.toContain('real-repo') + expect(JSON.stringify(res)).toMatch(/project-[0-9a-f]{6}/) + expect(res.isError).toBeFalsy() + }) + it('get_usage reveals names when opted in', async () => { + const client = await connect(async () => fakePayload()) + const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project', include_project_names: true } }) + expect(JSON.stringify(res)).toContain('real-repo') + }) + it('empty data returns a friendly message, not a zero table', async () => { + const client = await connect(async () => fakePayload(0)) + const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today' } }) + expect(String((res.content as Array<{ text: string }>)[0].text).toLowerCase()).toContain('no usage') + }) + it('aggregator failure surfaces as isError', async () => { + const client = await connect(async () => { throw new Error('boom') }) + const res = await client.callTool({ name: 'get_savings', arguments: {} }) + expect(res.isError).toBe(true) + expect(JSON.stringify(res)).toContain('boom') + }) +}) diff --git a/tests/mcp-tables.test.ts b/tests/mcp-tables.test.ts new file mode 100644 index 0000000..e0d32e4 --- /dev/null +++ b/tests/mcp-tables.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { renderSummaryTable, renderBreakdownTable, renderSavingsTable } from '../src/mcp/tables.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function payload(): MenubarPayload { + return { + generated: '', optimize: { findingCount: 1, savingsUSD: 2.5, topFindings: [{ title: 'Trim system prompt', impact: 'high', savingsUSD: 2.5 }] }, history: { daily: [] }, + current: { + label: 'Last 7 Days', cost: 12.5, calls: 100, sessions: 4, oneShotRate: 0.5, inputTokens: 1000, outputTokens: 500, + cacheHitPercent: 80, topActivities: [{ name: 'feature', cost: 8, turns: 30, oneShotRate: 0.6 }], + topModels: [{ name: 'Opus 4.8', cost: 10, calls: 60 }], providers: { 'claude code': 12.5 }, + topProjects: [{ name: 'project-abc123', cost: 12.5, sessions: 4, avgCostPerSession: 3.125, sessionDetails: [] }], + modelEfficiency: [], topSessions: [], + retryTax: { totalUSD: 1.2, retries: 4, editTurns: 20, byModel: [{ name: 'Opus 4.8', taxUSD: 1.2, retries: 4, retriesPerEdit: 0.2 }] }, + routingWaste: { totalSavingsUSD: 3, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [{ name: 'Opus 4.8', costPerEdit: 0.05, editTurns: 20, actualUSD: 1, counterfactualUSD: 0.2, savingsUSD: 0.8 }] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +describe('tables', () => { + it('summary shows headline cost and top models', () => { + const t = renderSummaryTable(payload()) + expect(t).toContain('Last 7 Days') + expect(t).toContain('Opus 4.8') + expect(t).toContain('| Model | Cost | Calls |') + }) + it('breakdown by provider lists providers', () => { + expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code') + }) + it('breakdown handles empty dimension gracefully', () => { + const p = payload(); p.current.topActivities = [] + expect(renderBreakdownTable(p, 'task', 20)).toContain('no data') + }) + it('savings shows retry tax and routing waste', () => { + const t = renderSavingsTable(payload()) + expect(t).toContain('Retry tax') + expect(t).toContain('Routing waste') + expect(t).toContain('Trim system prompt') + }) +}) diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts new file mode 100644 index 0000000..529ca5f --- /dev/null +++ b/tests/usage-aggregator.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, beforeAll } from 'vitest' +import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' +import { getDateRange } from '../src/cli-date.js' +import { loadPricing } from '../src/models.js' + +describe('buildMenubarPayloadForRange', () => { + beforeAll(async () => { await loadPricing() }) + + it('returns a valid payload and skips optimize findings when optimize:false', async () => { + const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }) + expect(typeof payload.current.label).toBe('string') + expect(payload.current.cost).toBeGreaterThanOrEqual(0) + expect(Array.isArray(payload.current.topProjects)).toBe(true) + expect(Array.isArray(payload.current.topModels)).toBe(true) + expect(Array.isArray(payload.history.daily)).toBe(true) + expect(payload.current.retryTax.totalUSD).toBeGreaterThanOrEqual(0) + // optimize:false => scanAndDetect skipped => empty optimize block regardless of data + expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] }) + }) +}) diff --git a/tsup.config.ts b/tsup.config.ts index 957fdce..c20e55c 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -9,4 +9,5 @@ export default defineConfig({ splitting: false, sourcemap: true, dts: false, + external: ['@modelcontextprotocol/sdk', 'zod'], })