diff --git a/packages/cli/README.md b/packages/cli/README.md index b932471..873e1e8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,122 +1,178 @@ # @agentmeter/cli -Track what your AI coding sessions actually cost. `@agentmeter/cli` scans your local Claude Code and Cursor session data, calculates per-session token costs, and syncs them to [AgentMeter](https://agentmeter.app?utm_source=github&utm_medium=readme-package-cli&utm_campaign=agentmeter-sdk) - giving you and your team a unified dashboard of AI spend across tools, projects, and engineers. +Track what your AI coding sessions cost. Reads the session data that Claude Code and Cursor already write to your machine — token counts, model, duration, project — and exposes it as an MCP server your AI agent can query directly. -No proxying. No API key sharing. The CLI reads session data that the agents already write to your machine. +No account required. No setup beyond two lines of JSON. -- **Claude Code** — parses JSONL conversation logs in `~/.claude/projects/`, extracting exact token counts from the recorded Anthropic API responses. -- **Cursor** — reads the local SQLite state database across all three storage formats Cursor has used. Counts are approximate (Cursor is subscription-based and doesn't expose exact billing data locally). +--- -Sessions are tracked by ID, so re-syncing is safe — existing records update rather than duplicate. +## MCP server (no account needed) -## Why +Add to your MCP client config and start asking questions. The server scans your local Claude Code and Cursor data on demand — no prior sync, no background service, nothing to install first. -Engineering teams are adopting AI coding agents faster than they can track the cost. Provider dashboards show an aggregate monthly number; they can't tell you which engineer, which project, or which task drove the spend. AgentMeter is the attribution layer - visibility before the bill arrives, not after. +**Claude Code** — add to `~/.claude.json`: -## Requirements +```json +{ + "mcpServers": { + "agentmeter": { + "command": "npx", + "args": ["@agentmeter/cli", "mcp"] + } + } +} +``` -- **Node.js 22.5+** — the Cursor scanner uses `node:sqlite`, built in as of 22.5. -- **macOS or Linux** — full support including background service. On Windows, `sync` works manually but `install`/`uninstall` (launchd/systemd) do not. +**Cursor** — add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project): + +```json +{ + "mcpServers": { + "agentmeter": { + "command": "npx", + "args": ["@agentmeter/cli", "mcp"] + } + } +} +``` -## Quick Start +The first call scans your local data (takes a moment). Subsequent calls within 60 seconds return instantly from the in-memory cache. -```bash -# 1. Get your API key from https://agentmeter.app/settings/api-keys -# 2. Initialize -npx @agentmeter/cli init +### Available tools (no account needed) -# 3. Sync once to confirm it works -npx @agentmeter/cli sync --dry-run # preview without sending -npx @agentmeter/cli sync # actually sync +| Tool | Description | +|------|-------------| +| `list_recent_sessions` | Sessions from the last 12 months, sorted newest first. Includes token counts (input/output/cache), duration, model, engine, and repo. `costCents` is populated if you have an AgentMeter account (see below). | +| `get_session` | Look up a session by ID from local data. Same fields as above. Falls back to the AgentMeter API if not found locally (requires account). | -# 4. Install as a background service so it runs automatically -npx @agentmeter/cli install -``` +Example questions your agent can answer with no account: -## Getting your API key +> "Show me my last 10 Claude Code sessions." +> "Which session used the most tokens this week?" +> "What's my total token usage across the last 20 sessions?" +> "Show me my recent Cursor sessions on the `my-app` repo." -Sign in at [agentmeter.app](https://agentmeter.app) with GitHub and generate a **personal API key** under Settings → API Keys. Personal keys attribute sessions to you specifically, so your costs show up correctly in team views. (Org-level keys work too, but sessions submitted with them won't be attributed to an individual.) +--- -## Commands +## Unlock cost data, dashboards, and team visibility -| Command | Description | -| ----------- | --------------------------------------- | -| `init` | Configure API key and device name | -| `sync` | One-time scan and upload | -| `watch` | Background daemon mode (foreground loop) | -| `install` | Install as system service (macOS/Linux) | -| `uninstall` | Remove system service | -| `upgrade` | Reinstall service from current binary | -| `status` | Show service and sync health | +Connecting to [AgentMeter](https://agentmeter.app) gives you cost-per-session in dollars (not just tokens), a web dashboard, spend trends over time, and team-level visibility. -### `sync` flags +### What you get -| Flag | Description | -|---|---| -| `--verbose` | Show each session's status (cost, duration, new/updated/unchanged) | -| `--dry-run` | Show what would be submitted without sending anything | -| `--since ` | Only sync sessions after this date (ISO 8601) | -| `--engine ` | Only run a specific scanner (e.g. `claude`, `cursor`) | +- **`costCents` per session** — calculated by the API and returned to your local cache, so `list_recent_sessions` starts showing dollar costs too +- **Web dashboard** — session history, per-repo breakdowns, model-level spend +- **Spend trends** — daily and weekly charts queryable via MCP +- **Team visibility** — connected engineers, session counts, and spend (Pro) -### `watch` flags +### Connect in 30 seconds -| Flag | Description | -|---|---| -| `--interval ` | Sync interval in seconds (default: 300) | +Sign in at [agentmeter.app](https://agentmeter.app) with GitHub, generate a personal API key under **Settings → API Keys**, then: -## Running as a background service +```bash +npx @agentmeter/cli init +npx @agentmeter/cli sync # submit your sessions and get cost data back +npx @agentmeter/cli install # keep it syncing automatically every 5 minutes +``` -`install` sets up the CLI to sync automatically every 5 minutes, survive reboots, and start on login — so neither you nor your teammates have to remember to run it. +Or pass the key via your MCP config without running `init`: + +```json +{ + "mcpServers": { + "agentmeter": { + "command": "npx", + "args": ["@agentmeter/cli", "mcp"], + "env": { + "AGENTMETER_API_KEY": "am_sk_your_key_here" + } + } + } +} +``` -- **macOS** — installs a launchd agent (`~/Library/LaunchAgents/`). -- **Linux** — installs a systemd user service. +### Additional MCP tools with an account -Check it's healthy anytime: +| Tool | Description | +|------|-------------| +| `get_session` (API fallback) | Fetches a session from the API when not in local data. | +| `get_my_spend` | Spend summary + daily time series for the last N days. Returns `totalCostCents`, `totalRuns`, `avgCostPerRunCents`, and a per-day breakdown. | +| `get_team_spend` | Per-contributor breakdown — `login`, `totalCostCents`, `totalRuns`, `connectionStatus`. Pro plan. | -```bash -npx @agentmeter/cli status -``` +Example questions unlocked with an account: -Remove it cleanly (config is preserved): +> "How much have I spent on Claude Code this week?" +> "What's my average cost per session this month?" +> "Show me my team's AI spend for the last 30 days." -```bash -npx @agentmeter/cli uninstall -``` +--- + +## CLI reference + +The CLI is optional for MCP usage but required for background auto-sync and for submitting sessions to AgentMeter. -## Upgrading +### Requirements -If you have the background service running and want to update to the latest version: +- **Node.js 22.5+** — the Cursor scanner uses `node:sqlite`, built in as of 22.5 +- **macOS or Linux** — full support. On Windows, `sync` works manually but `install`/`uninstall` do not -**npx (no global install):** +### Commands + +| Command | Description | +| ----------- | -------------------------------------------------- | +| `mcp` | Start MCP stdio server | +| `sync` | Scan local sessions and submit to AgentMeter | +| `install` | Install as a system service (macOS/Linux) | +| `uninstall` | Remove the system service | +| `watch` | Run the sync loop in the foreground | +| `upgrade` | Reinstall the service from the current binary | +| `status` | Show service health and session counts | +| `init` | Configure an AgentMeter API key | + +### `sync` flags + +| Flag | Description | +|---|---| +| `--verbose` | Show each session's status, cost, and duration | +| `--dry-run` | Preview what would be submitted without sending anything | +| `--since ` | Only include sessions after this date (ISO 8601) | +| `--engine ` | Only run a specific scanner (`claude`, `cursor`) | + +### Background service + +`install` sets up the CLI to sync every 5 minutes, survive reboots, and start on login. + +- **macOS** — installs a launchd agent (`~/Library/LaunchAgents/`) +- **Linux** — installs a systemd user service ```bash -npx @agentmeter/cli@latest upgrade +npx @agentmeter/cli status # check it's running +npx @agentmeter/cli uninstall # remove it (config and data are preserved) ``` -**Global install:** +### Upgrading ```bash -npm install -g @agentmeter/cli@latest -agentmeter upgrade +npx @agentmeter/cli@latest upgrade # npx — no global install +npm install -g @agentmeter/cli@latest && agentmeter upgrade # global install ``` -`upgrade` stops the current service, reinstalls it pointing at the new binary, and starts it again. Config and sync state are preserved. - -## For teams +`upgrade` stops the service, reinstalls pointing at the new binary, and restarts. Config and sync state are preserved. -Rolling this out across a team? Add `npx @agentmeter/cli init` and -`npx @agentmeter/cli install` to your onboarding script or setup docs. Each engineer uses their own personal API key, so the dashboard attributes spend per person automatically. The team admin can see coverage - who's set up and who hasn't - in the AgentMeter dashboard. +--- -## Environment Variables +## Environment variables -- `AGENTMETER_API_KEY` — overrides the API key in config -- `AGENTMETER_API_URL` — overrides the API URL (useful for local dev) +| Variable | Description | +|---|---| +| `AGENTMETER_API_KEY` | API key — overrides the value in `~/.agentmeter/config.json` | +| `AGENTMETER_API_URL` | Override the API base URL (useful for self-hosting or local dev) | ## Privacy -AgentMeter stores session metadata and token counts — never your code, prompts, or conversation content. The CLI only extracts: token counts, model, timestamps, duration, project path, and the first line of the session as a title. Nothing else leaves your machine. +AgentMeter stores session metadata and token counts — never your code, prompts, or conversation content. The CLI extracts: token counts, model, timestamps, duration, project path, and the first line of the session as a title. Nothing else leaves your machine. -## Supported Agents +## Supported agents | Agent | Token data | | ----------- | ----------------------------------- | @@ -126,4 +182,4 @@ AgentMeter stores session metadata and token counts — never your code, prompts ## Links - [AgentMeter dashboard](https://agentmeter.app) -- [How it works](https://agentmeter.app/how-it-works) \ No newline at end of file +- [How it works](https://agentmeter.app/how-it-works) diff --git a/packages/cli/__tests__/commands/mcp.test.ts b/packages/cli/__tests__/commands/mcp.test.ts new file mode 100644 index 0000000..cb6d61d --- /dev/null +++ b/packages/cli/__tests__/commands/mcp.test.ts @@ -0,0 +1,558 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import type { LocalSession } from '../../src/schemas/session.js'; + +const tmpDir = path.join(os.tmpdir(), `agentmeter-test-mcp-${process.pid}`); + +vi.mock('../../src/utils/platform.js', () => ({ + getAgentMeterDir: () => tmpDir, + getConfigPath: () => path.join(tmpDir, 'config.json'), + getSyncStatePath: () => path.join(tmpDir, 'sync-state.json'), + getLogDir: () => path.join(tmpDir, 'logs'), + getLogPath: () => path.join(tmpDir, 'logs', 'sync.log'), + getClaudeProjectsDir: () => path.join(tmpDir, 'claude-projects'), + getPlatform: () => 'macos', +})); + +// --------------------------------------------------------------------------- +// Scanner mocks — populated per-test via claudeSessions / cursorSessions arrays. +// The factory executes lazily (when the mock module is first imported inside a +// test), so by that point these module-level arrays are fully initialised. +// --------------------------------------------------------------------------- + +const claudeSessions: LocalSession[] = []; +const cursorSessions: LocalSession[] = []; + +vi.mock('../../src/scanners/claude.js', () => ({ + ClaudeScanner: class { + readonly name = 'claude'; + async isAvailable() { + return claudeSessions.length > 0; + } + async scan() { + return [...claudeSessions]; + } + }, +})); + +vi.mock('../../src/scanners/cursor.js', () => ({ + CursorScanner: class { + readonly name = 'cursor'; + async isAvailable() { + return cursorSessions.length > 0; + } + async scan() { + return [...cursorSessions]; + } + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Creates a minimal valid LocalSession */ +function makeSession(overrides: Partial = {}): LocalSession { + return { + sessionId: 'sess-default', + repoFullName: 'org/repo', + workspacePath: '/tmp/repo', + engine: 'claude', + model: 'claude-sonnet', + status: 'success', + title: 'Test session', + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + durationSeconds: 120, + tokens: { input: 1000, output: 200, cacheRead: 500, cacheWrite: 100 }, + turns: 4, + ...overrides, + }; +} + +function writeConfig(): void { + fs.writeFileSync( + path.join(tmpDir, 'config.json'), + JSON.stringify({ apiKey: 'am_sk_test', apiUrl: 'https://agentmeter.app', deviceName: 'test' }), + 'utf8', + ); +} + +function writeSyncStateWithCost(sessionId: string, costCents: number): void { + fs.writeFileSync( + path.join(tmpDir, 'sync-state.json'), + JSON.stringify({ + lastSyncAt: null, + sessions: { + [sessionId]: { status: 'success', submittedAt: new Date().toISOString(), costCents }, + }, + }), + 'utf8', + ); +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + fs.mkdirSync(tmpDir, { recursive: true }); + claudeSessions.length = 0; + cursorSessions.length = 0; + vi.resetModules(); + vi.clearAllMocks(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------- +// fetchJson +// --------------------------------------------------------------------------- + +describe('fetchJson', () => { + it('returns ok:true with parsed data on 200', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ value: 42 }), + }), + ); + + const { fetchJson } = await import('../../src/commands/mcp.js'); + const result = await fetchJson({ + apiKey: 'am_sk_test', + apiUrl: 'https://agentmeter.app', + path: '/api/test', + schema: z.object({ value: z.number() }), + }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toEqual({ value: 42 }); + }); + + it('returns ok:false on network error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + + const { fetchJson } = await import('../../src/commands/mcp.js'); + const result = await fetchJson({ + apiKey: 'am_sk_test', + apiUrl: 'https://agentmeter.app', + path: '/api/test', + schema: z.object({ value: z.number() }), + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/Network error/); + }); + + it('returns ok:false on non-2xx response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }), + ); + + const { fetchJson } = await import('../../src/commands/mcp.js'); + const result = await fetchJson({ + apiKey: 'am_sk_test', + apiUrl: 'https://agentmeter.app', + path: '/api/test', + schema: z.object({ value: z.number() }), + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/401/); + }); + + it('returns ok:false when JSON parse fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + }), + ); + + const { fetchJson } = await import('../../src/commands/mcp.js'); + const result = await fetchJson({ + apiKey: 'am_sk_test', + apiUrl: 'https://agentmeter.app', + path: '/api/test', + schema: z.object({ value: z.number() }), + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/parse/i); + }); + + it('returns ok:false when Zod schema mismatch', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ unexpected: 'shape' }), + }), + ); + + const { fetchJson } = await import('../../src/commands/mcp.js'); + const result = await fetchJson({ + apiKey: 'am_sk_test', + apiUrl: 'https://agentmeter.app', + path: '/api/test', + schema: z.object({ value: z.number() }), + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/Unexpected response shape/); + }); +}); + +// --------------------------------------------------------------------------- +// getLocalSessions +// --------------------------------------------------------------------------- + +describe('getLocalSessions', () => { + it('returns sessions from all available scanners sorted by startTime desc', async () => { + claudeSessions.push( + makeSession({ sessionId: 'c1', engine: 'claude', startTime: '2026-06-01T10:00:00.000Z' }), + makeSession({ sessionId: 'c2', engine: 'claude', startTime: '2026-06-03T10:00:00.000Z' }), + ); + cursorSessions.push( + makeSession({ sessionId: 'u1', engine: 'cursor', startTime: '2026-06-02T10:00:00.000Z' }), + ); + + const { getLocalSessions } = await import('../../src/commands/mcp.js'); + const sessions = await getLocalSessions(); + + expect(sessions.map((s) => s.sessionId)).toEqual(['c2', 'u1', 'c1']); + }); + + it('returns empty array when no scanners are available', async () => { + // claudeSessions and cursorSessions are both empty + const { getLocalSessions } = await import('../../src/commands/mcp.js'); + const sessions = await getLocalSessions(); + expect(sessions).toHaveLength(0); + }); + + it('uses in-memory cache on second call', async () => { + const scanCallCount = 0; + claudeSessions.push(makeSession({ sessionId: 'sess-1', startTime: new Date().toISOString() })); + + // Intercept scan by patching the array after first import — reuse scan count trick + const { getLocalSessions } = await import('../../src/commands/mcp.js'); + + const first = await getLocalSessions(); + // Clear the source array — if cache works, second call still returns the same data + claudeSessions.length = 0; + const second = await getLocalSessions(); + + void scanCallCount; // suppress unused warning + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + expect(second[0]?.sessionId).toBe('sess-1'); + }); +}); + +// --------------------------------------------------------------------------- +// handleListRecentSessions +// --------------------------------------------------------------------------- + +describe('handleListRecentSessions', () => { + it('returns sessions sorted by startTime descending', async () => { + claudeSessions.push( + makeSession({ sessionId: 'sess-a', startTime: '2026-06-01T10:00:00.000Z' }), + makeSession({ sessionId: 'sess-b', startTime: '2026-06-03T10:00:00.000Z' }), + makeSession({ sessionId: 'sess-c', startTime: '2026-06-02T10:00:00.000Z' }), + ); + + const { handleListRecentSessions } = await import('../../src/commands/mcp.js'); + const result = await handleListRecentSessions({}); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + sessions: Array<{ sessionId: string }>; + }; + + expect(parsed.sessions[0]?.sessionId).toBe('sess-b'); + expect(parsed.sessions[1]?.sessionId).toBe('sess-c'); + expect(parsed.sessions[2]?.sessionId).toBe('sess-a'); + }); + + it('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + claudeSessions.push( + makeSession({ + sessionId: `sess-${i}`, + startTime: new Date(Date.now() - i * 60_000).toISOString(), + }), + ); + } + + const { handleListRecentSessions } = await import('../../src/commands/mcp.js'); + const result = await handleListRecentSessions({ limit: 2 }); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + sessions: unknown[]; + total: number; + }; + + expect(parsed.sessions).toHaveLength(2); + expect(parsed.total).toBe(5); + }); + + it('filters out sessions older than 12 months', async () => { + const recentTime = new Date().toISOString(); + const oldTime = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000).toISOString(); // >12 months + + claudeSessions.push( + makeSession({ sessionId: 'recent', startTime: recentTime }), + makeSession({ sessionId: 'old', startTime: oldTime }), + ); + + const { handleListRecentSessions } = await import('../../src/commands/mcp.js'); + const result = await handleListRecentSessions({ limit: 50 }); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + sessions: Array<{ sessionId: string }>; + total: number; + }; + + expect(parsed.sessions.some((s) => s.sessionId === 'recent')).toBe(true); + expect(parsed.sessions.some((s) => s.sessionId === 'old')).toBe(false); + expect(parsed.total).toBe(1); + }); + + it('works with no API key configured', async () => { + claudeSessions.push(makeSession({ sessionId: 'sess-x' })); + + const { handleListRecentSessions } = await import('../../src/commands/mcp.js'); + const result = await handleListRecentSessions({}); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + sessions: Array<{ sessionId: string; costCents: null }>; + }; + + expect(parsed.sessions).toHaveLength(1); + expect(parsed.sessions[0]?.costCents).toBeNull(); + }); + + it('enriches costCents from sync-state when available', async () => { + claudeSessions.push(makeSession({ sessionId: 'sess-rich' })); + writeSyncStateWithCost('sess-rich', 250); + + const { handleListRecentSessions } = await import('../../src/commands/mcp.js'); + const result = await handleListRecentSessions({}); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + sessions: Array<{ sessionId: string; costCents: number | null }>; + }; + + expect(parsed.sessions[0]?.costCents).toBe(250); + }); + + it('returns empty sessions when no scanners are available', async () => { + const { handleListRecentSessions } = await import('../../src/commands/mcp.js'); + const result = await handleListRecentSessions({}); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + sessions: unknown[]; + total: number; + }; + + expect(parsed.sessions).toHaveLength(0); + expect(parsed.total).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// handleGetMySpend +// --------------------------------------------------------------------------- + +describe('handleGetMySpend', () => { + it('returns hint when no API key is configured', async () => { + const { handleGetMySpend } = await import('../../src/commands/mcp.js'); + const result = await handleGetMySpend({}); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + error: string; + hint: string; + }; + + expect(parsed.error).toMatch(/API key/i); + expect(parsed.hint).toMatch(/agentmeter/i); + }); + + it('returns spend data when API key is present', async () => { + writeConfig(); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + summary: { totalCostCents: 1234, totalRuns: 7, avgCostPerRunCents: 176 }, + timeSeries: [{ date: '2026-06-01', costCents: 200, runs: 1 }], + }), + }), + ); + + const { handleGetMySpend } = await import('../../src/commands/mcp.js'); + const result = await handleGetMySpend({ days: 7 }); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + summary: { totalCostCents: number; totalRuns: number }; + days: number; + }; + + expect(parsed.summary.totalCostCents).toBe(1234); + expect(parsed.days).toBe(7); + }); +}); + +// --------------------------------------------------------------------------- +// handleGetSession +// --------------------------------------------------------------------------- + +describe('handleGetSession', () => { + it('returns local session without an API call when found by scanner', async () => { + claudeSessions.push( + makeSession({ + sessionId: 'local-sess', + title: 'My local session', + engine: 'claude', + model: 'claude-sonnet', + tokens: { input: 5000, output: 1000, cacheRead: 0, cacheWrite: 0 }, + }), + ); + + const mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + + const { handleGetSession } = await import('../../src/commands/mcp.js'); + const result = await handleGetSession({ sessionId: 'local-sess' }); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + sessionId: string; + source: string; + tokens: { input: number }; + }; + + expect(parsed.sessionId).toBe('local-sess'); + expect(parsed.source).toBe('local'); + expect(parsed.tokens.input).toBe(5000); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('enriches costCents from sync-state for locally found session', async () => { + claudeSessions.push(makeSession({ sessionId: 'sess-cost' })); + writeSyncStateWithCost('sess-cost', 99); + + const { handleGetSession } = await import('../../src/commands/mcp.js'); + const result = await handleGetSession({ sessionId: 'sess-cost' }); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + costCents: number | null; + }; + + expect(parsed.costCents).toBe(99); + }); + + it('returns hint when session not found locally and no API key', async () => { + // No sessions in scanner, no config + const { handleGetSession } = await import('../../src/commands/mcp.js'); + const result = await handleGetSession({ sessionId: 'unknown-sess' }); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + error: string; + hint: string; + sessionId: string; + }; + + expect(parsed.error).toMatch(/not found/i); + expect(parsed.hint).toMatch(/API key/i); + expect(parsed.sessionId).toBe('unknown-sess'); + }); + + it('falls back to API when not found locally and key is configured', async () => { + writeConfig(); + // No sessions in scanner + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + sessionId: 'remote-sess', + costCents: 42, + model: 'claude-sonnet', + engine: 'claude', + status: 'success', + title: 'Remote session', + durationSeconds: 120, + }), + }), + ); + + const { handleGetSession } = await import('../../src/commands/mcp.js'); + const result = await handleGetSession({ sessionId: 'remote-sess' }); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + sessionId: string; + source: string; + costCents: number; + }; + + expect(parsed.sessionId).toBe('remote-sess'); + expect(parsed.source).toBe('api'); + expect(parsed.costCents).toBe(42); + }); +}); + +// --------------------------------------------------------------------------- +// handleGetTeamSpend +// --------------------------------------------------------------------------- + +describe('handleGetTeamSpend', () => { + it('returns hint when no API key is configured', async () => { + const { handleGetTeamSpend } = await import('../../src/commands/mcp.js'); + const result = await handleGetTeamSpend({}); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + error: string; + hint: string; + }; + + expect(parsed.error).toMatch(/API key/i); + expect(parsed.hint).toMatch(/agentmeter/i); + }); + + it('returns contributor data when API key is present', async () => { + writeConfig(); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => [ + { login: 'alice', totalCostCents: 500, totalRuns: 10, connectionStatus: 'connected' }, + { login: 'bob', totalCostCents: 200, totalRuns: 4, connectionStatus: 'connected' }, + ], + }), + ); + + const { handleGetTeamSpend } = await import('../../src/commands/mcp.js'); + const result = await handleGetTeamSpend({ days: 30 }); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as { + contributors: Array<{ login: string }>; + days: number; + }; + + expect(parsed.contributors).toHaveLength(2); + expect(parsed.contributors[0]?.login).toBe('alice'); + expect(parsed.days).toBe(30); + }); +}); diff --git a/packages/cli/__tests__/services/sync-state.test.ts b/packages/cli/__tests__/services/sync-state.test.ts index 69ff53d..2dd9ea0 100644 --- a/packages/cli/__tests__/services/sync-state.test.ts +++ b/packages/cli/__tests__/services/sync-state.test.ts @@ -71,3 +71,64 @@ describe('sync-state service', () => { expect(fs.existsSync(syncStatePath)).toBe(true); }); }); + +describe('trimSyncState', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('returns state unchanged when under the cap', async () => { + const { trimSyncState } = await import('../../src/services/sync-state.js'); + + const state = { + lastSyncAt: null, + sessions: { + s1: { status: 'success' as const, submittedAt: '2026-01-01T00:00:00.000Z' }, + s2: { status: 'success' as const, submittedAt: '2026-01-02T00:00:00.000Z' }, + }, + }; + + const result = trimSyncState(state); + expect(result).toBe(state); // same reference — no copy made + }); + + it('always keeps running sessions regardless of count', async () => { + const { trimSyncState } = await import('../../src/services/sync-state.js'); + + // Build MAX+1 completed sessions plus one running + const sessions: Record = {}; + for (let i = 0; i < 5001; i++) { + sessions[`sess-${i}`] = { + status: 'success', + submittedAt: new Date(Date.now() - i * 60_000).toISOString(), + }; + } + sessions['running-sess'] = { status: 'running', submittedAt: new Date().toISOString() }; + + const { trimSyncState: trim } = await import('../../src/services/sync-state.js'); + const result = trim({ lastSyncAt: null, sessions }); + + expect(result.sessions['running-sess']).toBeDefined(); + }); + + it('keeps the most recent sessions when count cap is hit', async () => { + const { trimSyncState } = await import('../../src/services/sync-state.js'); + + // Build 5002 recent completed sessions — 2 should be trimmed + const sessions: Record = {}; + for (let i = 0; i < 5002; i++) { + const ts = new Date(Date.now() - i * 60_000).toISOString(); // 1 min apart, newest first + sessions[`sess-${i}`] = { status: 'success', submittedAt: ts }; + } + + const result = trimSyncState({ lastSyncAt: null, sessions }); + const kept = Object.keys(result.sessions); + + expect(kept).toHaveLength(5000); + // The 2 oldest (sess-5000, sess-5001) should be gone + expect(result.sessions['sess-5000']).toBeUndefined(); + expect(result.sessions['sess-5001']).toBeUndefined(); + // The newest should be kept + expect(result.sessions['sess-0']).toBeDefined(); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index d523fdd..5d7e82a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,6 +38,7 @@ "test:watch": "vitest watch" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "commander": "^12.1.0", "picocolors": "^1.1.1", "zod": "^3.23.8" diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 0000000..680dc5a --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,485 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { Command } from 'commander'; +import { z } from 'zod'; +import { ClaudeScanner } from '../scanners/claude.js'; +import { CursorScanner } from '../scanners/cursor.js'; +import type { LocalSession } from '../schemas/session.js'; +import { getEffectiveConfig } from '../services/config.js'; +import { readSyncState } from '../services/sync-state.js'; + +// --------------------------------------------------------------------------- +// Zod schemas for API response validation +// --------------------------------------------------------------------------- + +const TrendsSummarySchema = z.object({ + avgCostPerRunCents: z.number().nullable().optional(), + totalCostCents: z.number(), + totalRuns: z.number(), +}); + +const TimeSeriesItemSchema = z.object({ + costCents: z.number(), + date: z.string(), + runs: z.number(), +}); + +const TrendsResponseSchema = z.object({ + summary: TrendsSummarySchema, + timeSeries: z.array(TimeSeriesItemSchema), +}); + +const ContributorSchema = z.object({ + connectionStatus: z.string().optional(), + login: z.string(), + totalCostCents: z.number(), + totalRuns: z.number(), +}); + +const ContributorsResponseSchema = z.array(ContributorSchema); + +const RunResponseSchema = z.object({ + costCents: z.number().nullable().optional(), + durationSeconds: z.number().nullable().optional(), + engine: z.string().optional(), + model: z.string().nullable().optional(), + sessionId: z.string(), + status: z.string().optional(), + title: z.string().nullable().optional(), +}); + +// --------------------------------------------------------------------------- +// Local session scanning — with in-memory cache +// --------------------------------------------------------------------------- + +/** Sessions older than this are excluded from list_recent_sessions results */ +const LIST_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; // 12 months + +/** How long to reuse a scan result before re-scanning */ +const SCAN_CACHE_TTL_MS = 60_000; // 60 seconds + +/** + * In-memory cache of the last scan result. + * Lives for the lifetime of the MCP server process (one per client connection). + */ +interface SessionScanCache { + /** Timestamp when this cache entry was populated */ + cachedAt: number; + /** All sessions found across all available scanners, sorted by startTime descending */ + sessions: LocalSession[]; +} + +let sessionScanCache: SessionScanCache | null = null; + +/** + * Runs all available scanners and returns their combined results sorted by + * startTime descending. Results are cached in memory for SCAN_CACHE_TTL_MS — + * subsequent calls within the TTL window skip the scan and return immediately. + * Scanner failures are caught individually so one broken scanner never blocks another. + */ +export async function getLocalSessions(): Promise { + if (sessionScanCache !== null && Date.now() - sessionScanCache.cachedAt < SCAN_CACHE_TTL_MS) { + return sessionScanCache.sessions; + } + + const scanners = [new ClaudeScanner(), new CursorScanner()]; + const all: LocalSession[] = []; + + for (const scanner of scanners) { + try { + if (!(await scanner.isAvailable())) continue; + const found = await scanner.scan(); + all.push(...found); + } catch { + // One scanner failing should not prevent results from others + } + } + + all.sort((a, b) => b.startTime.localeCompare(a.startTime)); + + sessionScanCache = { cachedAt: Date.now(), sessions: all }; + return all; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const NO_API_KEY_HINT = + 'Run `agentmeter init` to configure your API key, or set the AGENTMETER_API_KEY env var. ' + + 'Sign up free at https://agentmeter.app'; + +/** + * Serialises a value as a plain text MCP tool result + */ +function textResult(value: unknown): { content: [{ text: string; type: 'text' }] } { + return { + content: [{ text: JSON.stringify(value, null, 2), type: 'text' as const }], + }; +} + +/** + * Reads sync-state and returns a sessionId → costCents map. + * Returns an empty object if sync-state is absent or unreadable. + * costCents is only present for sessions that have been submitted to AgentMeter. + */ +function readCostMap(): Record { + try { + const state = readSyncState(); + const map: Record = {}; + for (const [id, s] of Object.entries(state.sessions)) { + map[id] = s?.costCents ?? null; + } + return map; + } catch { + return {}; + } +} + +/** + * Discriminated result from fetchJson — either successful with data or failed with an error message + */ +type FetchResult = { ok: true; data: T } | { ok: false; error: string }; + +/** + * Returns a Bearer-authed fetch result, parsed through the given Zod schema. + * On any failure returns `{ ok: false, error }` instead of throwing. + */ +export async function fetchJson({ + apiKey, + apiUrl, + schema, + path, +}: { + /** API key for Authorization header */ + apiKey: string; + + /** Base API URL */ + apiUrl: string; + + /** Zod schema to parse the response */ + schema: z.ZodType; + + /** URL path, e.g. "/api/trends?days=7" */ + path: string; +}): Promise> { + let response: Response; + try { + response = await fetch(`${apiUrl}${path}`, { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + } catch (err) { + return { + ok: false, + error: `Network error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + if (!response.ok) { + return { ok: false, error: `API returned ${response.status} ${response.statusText}` }; + } + + let raw: unknown; + try { + raw = await response.json(); + } catch { + return { ok: false, error: 'Failed to parse JSON response' }; + } + + const result = schema.safeParse(raw); + if (!result.success) { + return { ok: false, error: `Unexpected response shape: ${result.error.message}` }; + } + + return { ok: true, data: result.data }; +} + +// --------------------------------------------------------------------------- +// Tool handlers (exported for unit testing) +// --------------------------------------------------------------------------- + +/** + * Scans local Claude Code and Cursor data and returns the most recent sessions. + * Results are filtered to the last 12 months and limited to `limit` entries. + * costCents is enriched from sync-state when available (requires AgentMeter account). + * No API key required. + */ +export async function handleListRecentSessions({ + limit = 10, +}: { + /** Maximum number of sessions to return (1–50) */ + limit?: number; +}): Promise> { + let all: LocalSession[]; + try { + all = await getLocalSessions(); + } catch (err) { + return textResult({ + error: `Failed to scan local sessions: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const cutoff = new Date(Date.now() - LIST_MAX_AGE_MS).toISOString(); + const recent = all.filter((s) => s.startTime >= cutoff); + + const costMap = readCostMap(); + + const sessions = recent.slice(0, limit).map((s) => ({ + costCents: costMap[s.sessionId] ?? null, + durationSeconds: s.durationSeconds, + endTime: s.endTime, + engine: s.engine, + model: s.model, + repoFullName: s.repoFullName, + sessionId: s.sessionId, + startTime: s.startTime, + status: s.status, + title: s.title, + tokens: s.tokens, + turns: s.turns, + })); + + return textResult({ sessions, total: recent.length }); +} + +/** + * Fetches spend summary and daily time series for the last `days` days. + * Requires a valid AgentMeter API key. + */ +export async function handleGetMySpend({ + days = 7, +}: { + /** Number of days to look back */ + days?: number; +}): Promise> { + const config = getEffectiveConfig(); + if (!config) { + return textResult({ error: 'AgentMeter API key required', hint: NO_API_KEY_HINT }); + } + + const result = await fetchJson({ + apiKey: config.apiKey, + apiUrl: config.apiUrl, + path: `/api/trends?days=${days}`, + schema: TrendsResponseSchema, + }); + + if (!result.ok) { + return textResult({ error: result.error }); + } + + return textResult({ + days, + summary: { + avgCostPerRunCents: result.data.summary.avgCostPerRunCents ?? null, + totalCostCents: result.data.summary.totalCostCents, + totalRuns: result.data.summary.totalRuns, + }, + timeSeries: result.data.timeSeries, + }); +} + +/** + * Looks up a session by ID. Checks live local scan first (no API key required); + * if not found and an API key is configured, falls back to GET /api/runs/. + */ +export async function handleGetSession({ + sessionId, +}: { + /** Session ID to look up */ + sessionId: string; +}): Promise> { + let all: LocalSession[]; + try { + all = await getLocalSessions(); + } catch (err) { + return textResult({ + error: `Failed to scan local sessions: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const local = all.find((s) => s.sessionId === sessionId); + + if (local) { + const costMap = readCostMap(); + return textResult({ + costCents: costMap[sessionId] ?? null, + durationSeconds: local.durationSeconds, + endTime: local.endTime, + engine: local.engine, + model: local.model, + repoFullName: local.repoFullName, + sessionId: local.sessionId, + source: 'local', + startTime: local.startTime, + status: local.status, + title: local.title, + tokens: local.tokens, + turns: local.turns, + }); + } + + const config = getEffectiveConfig(); + if (!config) { + return textResult({ + error: 'Session not found in local data', + hint: `Configure an API key to enable remote session lookup. ${NO_API_KEY_HINT}`, + sessionId, + }); + } + + const result = await fetchJson({ + apiKey: config.apiKey, + apiUrl: config.apiUrl, + path: `/api/runs/${encodeURIComponent(sessionId)}`, + schema: RunResponseSchema, + }); + + if (!result.ok) { + return textResult({ error: result.error, sessionId }); + } + + return textResult({ + costCents: result.data.costCents ?? null, + durationSeconds: result.data.durationSeconds ?? null, + engine: result.data.engine ?? null, + model: result.data.model ?? null, + sessionId: result.data.sessionId, + source: 'api', + status: result.data.status ?? null, + title: result.data.title ?? null, + }); +} + +/** + * Fetches per-contributor spend breakdown. Requires a Pro plan and a valid API key. + */ +export async function handleGetTeamSpend({ + days = 30, +}: { + /** Number of days to look back */ + days?: number; +}): Promise> { + const config = getEffectiveConfig(); + if (!config) { + return textResult({ error: 'AgentMeter API key required', hint: NO_API_KEY_HINT }); + } + + const result = await fetchJson({ + apiKey: config.apiKey, + apiUrl: config.apiUrl, + path: `/api/contributors?days=${days}`, + schema: ContributorsResponseSchema, + }); + + if (!result.ok) { + return textResult({ error: result.error }); + } + + return textResult({ contributors: result.data, days }); +} + +// --------------------------------------------------------------------------- +// MCP server +// --------------------------------------------------------------------------- + +/** + * Creates and starts the MCP stdio server, registering all AgentMeter tools. + * Never writes to stdout — the MCP protocol uses stdout exclusively. + * Local tools scan Claude Code and Cursor data on demand (no prior setup required). + * API tools return a structured error + sign-up hint when no key is configured. + */ +async function startMcpServer(): Promise { + const server = new McpServer( + { name: 'agentmeter', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ); + + server.tool( + 'list_recent_sessions', + 'List recent AI coding sessions scanned live from Claude Code and Cursor on this machine. ' + + 'Returns sessions from the last 12 months sorted by start time (newest first). ' + + 'No API key required. Includes token counts (input/output/cache), duration, and model. ' + + 'Cost in cents is included when sessions have been synced to AgentMeter (agentmeter.app). ' + + 'The first call may take a moment to scan local data; subsequent calls within 60 seconds are instant.', + { + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe('Maximum number of sessions to return (1–50, default 10)'), + }, + async ({ limit = 10 }) => handleListRecentSessions({ limit }), + ); + + server.tool( + 'get_my_spend', + 'Fetch your AgentMeter spend summary and daily time series from the API. ' + + 'Returns total cost, total runs, average cost per run, and a per-day breakdown. ' + + 'Requires an AgentMeter API key (free tier available at https://agentmeter.app). ' + + 'If no key is configured, returns instructions on how to set one up.', + { + days: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe('Number of days to look back (default 7)'), + }, + async ({ days = 7 }) => handleGetMySpend({ days }), + ); + + server.tool( + 'get_session', + 'Look up a specific AI coding session by ID. ' + + 'Checks local Claude Code and Cursor data first (no API key needed); ' + + 'falls back to the AgentMeter API if not found locally (requires API key). ' + + 'Returns cost, model, engine, status, title, tokens, and duration.', + { + sessionId: z.string().describe('The session ID to look up'), + }, + async ({ sessionId }) => handleGetSession({ sessionId }), + ); + + server.tool( + 'get_team_spend', + 'Fetch per-contributor spend breakdown from the AgentMeter API. ' + + 'Returns an array of contributors with total cost, total runs, and connection status. ' + + 'Requires a Pro plan and a valid AgentMeter API key. ' + + 'If no key is configured, returns instructions on how to set one up.', + { + days: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe('Number of days to look back (default 30)'), + }, + async ({ days = 30 }) => handleGetTeamSpend({ days }), + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +/** + * Commander command that starts an MCP stdio server exposing AgentMeter spend data + */ +export const mcpCommand = new Command('mcp') + .description('Start an MCP stdio server for querying AgentMeter spend data') + .action(async () => { + try { + await startMcpServer(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`Error starting MCP server: ${message}\n`); + process.exit(1); + } + }); diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 0d99ee6..4fd5717 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -301,6 +301,7 @@ async function submitAll({ repoFullName: session.repoFullName, model: session.model, startTime: session.startTime, + tokens: session.tokens, }; if (result.costCents) totalCostCents += result.costCents; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ab233ef..9baca74 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { createRequire } from 'node:module'; import { Command } from 'commander'; import { initCommand } from './commands/init.js'; import { installCommand } from './commands/install.js'; +import { mcpCommand } from './commands/mcp.js'; import { statusCommand } from './commands/status.js'; import { syncCommand } from './commands/sync.js'; import { uninstallCommand } from './commands/uninstall.js'; @@ -25,5 +26,6 @@ program.addCommand(installCommand); program.addCommand(uninstallCommand); program.addCommand(upgradeCommand); program.addCommand(statusCommand); +program.addCommand(mcpCommand); program.parse(); diff --git a/packages/cli/src/schemas/sync-state.ts b/packages/cli/src/schemas/sync-state.ts index 10e1bc0..0e719fb 100644 --- a/packages/cli/src/schemas/sync-state.ts +++ b/packages/cli/src/schemas/sync-state.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { TokensSchema } from './session.js'; export const SyncedSessionSchema = z.object({ status: z.enum(['running', 'success', 'failure', 'cancelled']), @@ -11,6 +12,8 @@ export const SyncedSessionSchema = z.object({ repoFullName: z.string().optional(), model: z.string().nullable().optional(), startTime: z.string().optional(), + /** Token counts from the scanner — present for sessions synced after this field was added */ + tokens: TokensSchema.optional(), }); export const SyncStateSchema = z.object({ diff --git a/packages/cli/src/services/sync-state.ts b/packages/cli/src/services/sync-state.ts index 0738728..a92b603 100644 --- a/packages/cli/src/services/sync-state.ts +++ b/packages/cli/src/services/sync-state.ts @@ -2,6 +2,18 @@ import fs from 'node:fs'; import { type SyncState, SyncStateSchema } from '../schemas/sync-state.js'; import { getAgentMeterDir, getSyncStatePath } from '../utils/platform.js'; +/** + * Maximum number of completed sessions to retain in sync-state.json. + * Running sessions are always kept regardless of this cap. + * + * No time-based cutoff is applied: the Claude Code scanner re-reads all JSONL + * files on every run, so age-based trimming would cause old sessions to be + * re-submitted as "new" on the next sync — generating noise and extra API calls. + * A count cap is sufficient: at ~500 bytes/entry this allows up to ~2.5 MB, + * which parses in milliseconds. + */ +const MAX_COMPLETED_SESSIONS = 5_000; + /** * Reads the sync state file, returning an empty state if absent or invalid */ @@ -23,12 +35,50 @@ export function readSyncState(): SyncState { } /** - * Validates and writes sync state to ~/.agentmeter/sync-state.json + * Trims a sync state so it never exceeds MAX_COMPLETED_SESSIONS completed entries. + * The most recently submitted sessions are kept. Running sessions are always + * preserved regardless of the cap — they are needed for vanished-session detection. + * + * Trimmed sessions may be re-discovered by the scanner and re-submitted. The API + * is idempotent for known session IDs (it updates rather than duplicates), so + * re-submissions are safe. The count cap is chosen so re-submissions are rare + * in practice: a heavy user doing 20 sessions/day takes ~8 months to hit 5,000. + */ +export function trimSyncState(state: SyncState): SyncState { + const entries = Object.entries(state.sessions); + + const running: typeof entries = []; + const completed: typeof entries = []; + for (const entry of entries) { + if (entry[1]?.status === 'running') { + running.push(entry); + } else { + completed.push(entry); + } + } + + if (completed.length <= MAX_COMPLETED_SESSIONS) { + return state; + } + + const kept = completed + .sort(([, a], [, b]) => (b?.submittedAt ?? '').localeCompare(a?.submittedAt ?? '')) + .slice(0, Math.max(0, MAX_COMPLETED_SESSIONS - running.length)); + + return { + ...state, + sessions: Object.fromEntries([...running, ...kept]), + }; +} + +/** + * Validates, trims, and writes sync state to ~/.agentmeter/sync-state.json */ export function writeSyncState(state: SyncState): void { const agentMeterDir = getAgentMeterDir(); fs.mkdirSync(agentMeterDir, { recursive: true }); - const validated = SyncStateSchema.parse(state); + const trimmed = trimSyncState(state); + const validated = SyncStateSchema.parse(trimmed); fs.writeFileSync(getSyncStatePath(), `${JSON.stringify(validated, null, 2)}\n`, 'utf8'); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1044dcc..605dbaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: packages/cli: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) commander: specifier: ^12.1.0 version: 12.1.0 @@ -561,6 +564,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -574,6 +583,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@rollup/rollup-android-arm-eabi@4.61.1': resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} cpu: [arm] @@ -777,11 +796,26 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -789,16 +823,32 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: esbuild: '>=0.18' + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -826,6 +876,34 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -839,9 +917,36 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -857,13 +962,44 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.6.0: + resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -873,18 +1009,91 @@ packages: picomatch: optional: true + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -902,6 +1111,26 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -916,10 +1145,36 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -941,6 +1196,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -966,10 +1225,30 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -979,6 +1258,48 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -997,6 +1318,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -1034,6 +1359,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1069,6 +1398,10 @@ packages: resolution: {integrity: sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg==} hasBin: true + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1080,6 +1413,14 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1141,11 +1482,24 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -1411,6 +1765,10 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true + '@hono/node-server@1.19.14(hono@4.12.31)': + dependencies: + hono: 4.12.31 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1425,6 +1783,28 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.31) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) + hono: 4.12.31 + jose: 6.2.4 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@rollup/rollup-android-arm-eabi@4.61.1': optional: true @@ -1564,19 +1944,61 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn@8.17.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + any-promise@1.3.0: {} assertion-error@2.0.1: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 load-tsconfig: 0.2.5 + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -1599,14 +2021,55 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 deep-eql@5.0.2: {} + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1691,27 +2154,155 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expect-type@1.3.0: {} + express-rate-limit@8.6.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.4: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.2 rollup: 4.61.1 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.31: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.4: {} + joycon@3.1.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -1724,6 +2315,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mlly@1.8.2: dependencies: acorn: 8.17.0 @@ -1741,8 +2344,26 @@ snapshots: nanoid@3.3.12: {} + negotiator@1.0.0: {} + object-assign@4.1.1: {} + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -1755,6 +2376,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -1774,8 +2397,29 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + readdirp@4.1.2: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} rollup@4.61.1: @@ -1809,6 +2453,79 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.61.1 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + 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.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} simple-git-hooks@2.13.1: {} @@ -1819,6 +2536,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} sucrase@3.35.1: @@ -1854,6 +2573,8 @@ snapshots: tinyspy@3.0.2: {} + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -1901,12 +2622,22 @@ snapshots: '@turbo/windows-64': 2.9.18 '@turbo/windows-arm64': 2.9.18 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} ufo@1.6.4: {} undici-types@6.21.0: {} + unpipe@1.0.0: {} + + vary@1.1.2: {} + vite-node@2.1.9(@types/node@22.19.21): dependencies: cac: 6.7.14 @@ -1969,9 +2700,19 @@ snapshots: - supports-color - terser + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {}