diff --git a/.env.example b/.env.example index 0f2ec1e..0c4cdf5 100644 --- a/.env.example +++ b/.env.example @@ -3,15 +3,61 @@ # ============================================================================= # Copy this file to .env and fill in the values you need. # All variables are optional unless noted otherwise. +# +# ─── HOW AUTHENTICATION WORKS ─────────────────────────────────────────────── +# +# The CLI supports multiple authentication backends, resolved in this order: +# +# 1. PROVIDER SELECTION (src/utils/model/providers.ts — getAPIProvider()): +# Checks CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or +# CLAUDE_CODE_USE_FOUNDRY. If set, routes to that 3rd-party provider. +# Otherwise uses Direct Anthropic API. +# +# 2. OAUTH vs API KEY DECISION (src/utils/auth.ts — isAnthropicAuthEnabled()): +# OAuth is DISABLED (API-key-only mode) when any of these are true: +# - --bare flag is used (sets CLAUDE_CODE_SIMPLE=1) +# - CLAUDE_CODE_USE_BEDROCK / VERTEX / FOUNDRY is set +# - ANTHROPIC_API_KEY env var is set (and not in managed OAuth context) +# - ANTHROPIC_AUTH_TOKEN env var is set +# - An apiKeyHelper is configured in settings +# If none of the above, OAuth is attempted via ~/.claude/.credentials.json. +# +# 3. API KEY RESOLUTION (src/utils/auth.ts — getAnthropicApiKey()): +# Priority order when OAuth is not active: +# a. CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR (pipe-passed key) +# b. apiKeyHelper (configured external command in settings) +# c. ANTHROPIC_API_KEY env var +# d. System keychain +# e. ~/.claude/.credentials config +# +# 4. CLIENT CONSTRUCTION (src/services/api/client.ts — getAnthropicClient()): +# Creates the appropriate SDK client (Anthropic, Bedrock, Vertex, or +# Foundry) using the resolved auth credentials plus any custom headers +# from ANTHROPIC_CUSTOM_HEADERS. +# +# FOR DEVELOPMENT: The simplest path is to set ANTHROPIC_API_KEY below. +# The auth system will detect the external key and skip OAuth entirely. # ============================================================================= # ── Authentication ─────────────────────────────────────────────────────────── # Your Anthropic API key (required for direct API access) ANTHROPIC_API_KEY= +# Bearer auth token (alternative to API key — used by bridge/remote) +# ANTHROPIC_AUTH_TOKEN= + # Custom API base URL (default: https://api.anthropic.com) # ANTHROPIC_BASE_URL= +# Custom headers sent with every API request (multiline, "Name: Value" per line) +# ANTHROPIC_CUSTOM_HEADERS= + +# Add additional protection header to API requests +# CLAUDE_CODE_ADDITIONAL_PROTECTION=true + +# Pipe-pass API key via file descriptor (advanced — for managed environments) +# CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR= + # ── Model Selection ────────────────────────────────────────────────────────── # Override the default model # ANTHROPIC_MODEL= @@ -38,16 +84,67 @@ ANTHROPIC_API_KEY= # Model for sub-agents / teammates # CLAUDE_CODE_SUBAGENT_MODEL= -# ── Alternative Providers ──────────────────────────────────────────────────── -# Use AWS Bedrock instead of direct Anthropic API +# ── AWS Bedrock ────────────────────────────────────────────────────────────── +# Enable Bedrock backend (uses AWS SDK default credentials: IAM, profile, env) # CLAUDE_CODE_USE_BEDROCK=true + +# Custom Bedrock endpoint URL # ANTHROPIC_BEDROCK_BASE_URL= + +# AWS region for Bedrock (default: us-east-1) +# AWS_REGION=us-east-1 +# AWS_DEFAULT_REGION=us-east-1 + +# Override AWS region specifically for the small fast model (Haiku) +# ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION= + +# Bearer token auth for Bedrock (alternative to IAM) # AWS_BEARER_TOKEN_BEDROCK= + +# Skip Bedrock auth (for testing without real AWS credentials) # CLAUDE_CODE_SKIP_BEDROCK_AUTH=false -# Use Google Vertex AI +# ── Google Vertex AI ───────────────────────────────────────────────────────── +# Enable Vertex AI backend # CLAUDE_CODE_USE_VERTEX=true +# Required: Your GCP project ID +# ANTHROPIC_VERTEX_PROJECT_ID= + +# Default GCP region for all models +# CLOUD_ML_REGION=us-east5 + +# Model-specific region overrides (highest priority) +# VERTEX_REGION_CLAUDE_3_5_HAIKU= +# VERTEX_REGION_CLAUDE_HAIKU_4_5= +# VERTEX_REGION_CLAUDE_3_5_SONNET= +# VERTEX_REGION_CLAUDE_3_7_SONNET= + +# Custom Vertex base URL +# ANTHROPIC_VERTEX_BASE_URL= + +# GCP service account credentials JSON file +# GOOGLE_APPLICATION_CREDENTIALS= + +# Skip Vertex auth (for testing without real GCP credentials) +# CLAUDE_CODE_SKIP_VERTEX_AUTH=false + +# ── Azure Foundry ──────────────────────────────────────────────────────────── +# Enable Azure Foundry backend +# CLAUDE_CODE_USE_FOUNDRY=true + +# Azure resource name (creates URL: https://{resource}.services.ai.azure.com/...) +# ANTHROPIC_FOUNDRY_RESOURCE= + +# Alternative: provide full base URL directly instead of resource name +# ANTHROPIC_FOUNDRY_BASE_URL= + +# Foundry API key (if not set, uses Azure AD / DefaultAzureCredential) +# ANTHROPIC_FOUNDRY_API_KEY= + +# Skip Foundry auth (for testing without real Azure credentials) +# CLAUDE_CODE_SKIP_FOUNDRY_AUTH=false + # ── Shell & Environment ───────────────────────────────────────────────────── # Override shell used for BashTool (default: auto-detected) # CLAUDE_CODE_SHELL=/bin/bash @@ -75,7 +172,7 @@ ANTHROPIC_API_KEY= # NODE_OPTIONS=--max-old-space-size=8192 # ── Features & Modes ──────────────────────────────────────────────────────── -# Enable simplified/worker mode +# Enable simplified/worker mode (also set by --bare flag) # CLAUDE_CODE_SIMPLE=true # Enable coordinator (multi-agent) mode @@ -119,10 +216,24 @@ ANTHROPIC_API_KEY= # Environment kind (e.g. bridge) # CLAUDE_CODE_ENVIRONMENT_KIND= -# OAuth tokens for bridge +# OAuth token injected by bridge/CCR +# CLAUDE_CODE_OAUTH_TOKEN= + +# OAuth refresh token for bridge # CLAUDE_CODE_OAUTH_REFRESH_TOKEN= + +# OAuth scopes # CLAUDE_CODE_OAUTH_SCOPES= +# Session access token for remote +# CLAUDE_CODE_SESSION_ACCESS_TOKEN= + +# Unix socket for SSH remote auth +# ANTHROPIC_UNIX_SOCKET= + +# Entrypoint identifier (cli, mcp, remote, sdk-*, etc.) +# CLAUDE_CODE_ENTRYPOINT=cli + # ── Debugging ──────────────────────────────────────────────────────────────── # Debug log level (error, warn, info, debug, trace) # CLAUDE_CODE_DEBUG_LOG_LEVEL=info @@ -150,7 +261,37 @@ ANTHROPIC_API_KEY= # Custom SSL certificate # SSL_CERT_FILE= -# Unix socket for Anthropic API -# ANTHROPIC_UNIX_SOCKET= +# ============================================================================= +# ─── OAUTH STUB NOTES (Part D) ───────────────────────────────────────────── +# ============================================================================= +# +# The OAuth flow (src/services/oauth/) requires a browser and Anthropic's +# OAuth endpoints. For development, you do NOT need to stub anything — +# just set ANTHROPIC_API_KEY and the auth system will skip OAuth automatically. +# +# How it works: +# src/utils/auth.ts — isAnthropicAuthEnabled() checks whether OAuth should +# be attempted. When it detects ANTHROPIC_API_KEY in the environment (and +# the context is not a "managed OAuth" context like Claude Desktop), it +# returns false, which causes the entire OAuth path to be bypassed. +# +# Decision chain: +# 1. isAnthropicAuthEnabled() in src/utils/auth.ts (line ~111) +# → returns false if external API key is detected +# 2. isClaudeAISubscriber() in src/utils/auth.ts (line ~1715) +# → checks OAuth tokens for user:inference scope (never reached if #1 is false) +# 3. getAnthropicApiKey() in src/utils/auth.ts (line ~201) +# → resolves API key from env/keychain/config +# +# If you needed to FORCE bypass OAuth in all cases (e.g. for testing the +# OAuth code paths themselves), you could: +# - Use --bare flag (sets CLAUDE_CODE_SIMPLE=1, disables OAuth in isBareMode()) +# - Set CLAUDE_CODE_USE_BEDROCK=true + CLAUDE_CODE_SKIP_BEDROCK_AUTH=true +# (routes around OAuth entirely but creates a Bedrock-shaped client) +# - Directly modify isAnthropicAuthEnabled() to return false (not recommended +# for production) +# +# Bottom line: export ANTHROPIC_API_KEY="sk-ant-..." is sufficient for dev. +# ============================================================================= diff --git a/.gitignore b/.gitignore index 33c493d..f49c47c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ coverage/ # Bun bun.lockb +# MCP registry tokens +.mcpregistry_* + # Temporary files tmp/ temp/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a972652 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "claude-code-explorer": { + "command": "node", + "args": ["mcp-server/dist/index.js"], + "env": { + "CLAUDE_CODE_SRC_ROOT": "./src" + } + } + } +} diff --git a/Dockerfile b/Dockerfile index 47885b5..6f5a111 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,45 @@ # ───────────────────────────────────────────────────────────── -# Claude Code CLI — Development Container +# Claude Code CLI — Production Container # ───────────────────────────────────────────────────────────── -# This image provides a ready-to-explore environment for the -# leaked source. It does NOT produce a runnable build (the -# original build tooling was not included in the leak). +# Multi-stage build: builds a production bundle, then copies +# only the output into a minimal runtime image. +# +# Usage: +# docker build -t claude-code . +# docker run --rm -e ANTHROPIC_API_KEY=sk-... claude-code -p "hello" # ───────────────────────────────────────────────────────────── -FROM oven/bun:1-alpine AS base +# Stage 1: Build +FROM oven/bun:1-alpine AS builder WORKDIR /app -# Install OS-level dependencies used at runtime -RUN apk add --no-cache git ripgrep - # Copy manifests first for layer caching COPY package.json bun.lockb* ./ -# Install npm packages +# Install all dependencies (including devDependencies for build) RUN bun install --frozen-lockfile || bun install # Copy source COPY . . -# Typecheck (optional — fails loudly if deps are wrong) -# RUN bun run typecheck +# Build production bundle +RUN bun run build:prod -# Default: drop into a shell for exploration -CMD ["sh"] +# Stage 2: Runtime +FROM oven/bun:1-alpine + +WORKDIR /app + +# Install OS-level runtime dependencies +RUN apk add --no-cache git ripgrep + +# Copy only the bundled output from the builder +COPY --from=builder /app/dist/cli.mjs /app/cli.mjs + +# Make it executable +RUN chmod +x /app/cli.mjs + +ENTRYPOINT ["bun", "/app/cli.mjs"] diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..74f1c3d --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +# bunfig.toml — Bun configuration for development mode +# The plugin intercepts `bun:bundle` imports → src/shims/bun-bundle.ts + +preload = ["./scripts/bun-plugin-shims.ts"] diff --git a/docs/bridge.md b/docs/bridge.md new file mode 100644 index 0000000..f909496 --- /dev/null +++ b/docs/bridge.md @@ -0,0 +1,239 @@ +# Bridge Layer (VS Code / JetBrains IDE Integration) + +## Architecture Overview + +The bridge (`src/bridge/`, ~31 files) connects Claude Code CLI sessions to +remote IDE extensions (VS Code, JetBrains) and the claude.ai web UI. It is +gated behind `feature('BRIDGE_MODE')` which defaults to `false`. + +### Protocols + +The bridge uses **two transport generations**: + +| Version | Read Path | Write Path | Negotiation | +|---------|-----------|------------|-------------| +| **v1 (env-based)** | WebSocket to Session-Ingress (`ws(s)://.../v1/session_ingress/ws/{sessionId}`) | HTTP POST to Session-Ingress | Environments API poll/ack/dispatch | +| **v2 (env-less)** | SSE stream via `SSETransport` | `CCRClient` → `/worker/*` endpoints | Direct `POST /v1/code/sessions/{id}/bridge` → worker JWT | + +Both wrapped behind `ReplBridgeTransport` interface (`replBridgeTransport.ts`). + +The v1 path: register environment → poll for work → acknowledge → spawn session. +The v2 path: create session → POST `/bridge` for JWT → SSE + CCRClient directly. + +### Authentication + +1. **OAuth tokens** — claude.ai subscription required (`isClaudeAISubscriber()`) +2. **JWT** — Session-Ingress tokens (`sk-ant-si-` prefixed) with `exp` claims. + `jwtUtils.ts` decodes and schedules proactive refresh before expiry. +3. **Trusted Device token** — `X-Trusted-Device-Token` header for elevated + security tier sessions. Enrolled via `trustedDevice.ts`. +4. **Environment secret** — base64url-encoded `WorkSecret` containing + `session_ingress_token`, `api_base_url`, git sources, auth tokens. + +Dev override: `CLAUDE_BRIDGE_OAUTH_TOKEN` and `CLAUDE_BRIDGE_BASE_URL` +(ant-only, `process.env.USER_TYPE === 'ant'`). + +### Message Flow (IDE ↔ CLI) + +``` +IDE / claude.ai ──WebSocket/SSE──→ Session-Ingress ──→ CLI (replBridge) + ←── POST / CCRClient writes ──── Session-Ingress ←── CLI +``` + +**Inbound** (server → CLI): +- `user` messages (prompts from web UI) → `handleIngressMessage()` → enqueued to REPL +- `control_request` (initialize, set_model, interrupt, set_permission_mode, set_max_thinking_tokens) +- `control_response` (permission decisions from IDE) + +**Outbound** (CLI → server): +- `assistant` messages (Claude's responses) +- `user` messages (echoed for sync) +- `result` messages (turn completion) +- System events, tool starts, activities + +Dedup: `BoundedUUIDSet` tracks recent posted/inbound UUIDs to reject echoes +and re-deliveries. + +### Lifecycle + +1. **Entitlement check**: `isBridgeEnabled()` / `isBridgeEnabledBlocking()` → + GrowthBook gate `tengu_ccr_bridge` + OAuth subscriber check +2. **Session creation**: `createBridgeSession()` → POST to API +3. **Transport init**: v1 `HybridTransport` or v2 `SSETransport` + `CCRClient` +4. **Message pump**: Read inbound via transport, write outbound via batch +5. **Token refresh**: Proactive JWT refresh via `createTokenRefreshScheduler()` +6. **Teardown**: `teardown()` → flush pending → close transport → archive session + +Spawn modes for `claude remote-control`: +- `single-session`: One session in cwd, bridge tears down when it ends +- `worktree`: Persistent server, each session gets an isolated git worktree +- `same-dir`: Persistent server, sessions share cwd + +### Key Types + +- `BridgeConfig` — Full bridge configuration (dir, auth, URLs, spawn mode, timeouts) +- `WorkSecret` — Decoded work payload (token, API URL, git sources, MCP config) +- `SessionHandle` — Running session (kill, activities, stdin, token update) +- `ReplBridgeHandle` — REPL bridge API (write messages, control requests, teardown) +- `BridgeState` — `'ready' | 'connected' | 'reconnecting' | 'failed'` +- `SpawnMode` — `'single-session' | 'worktree' | 'same-dir'` + +--- + +## Feature Gate Analysis + +### Must Work (currently works correctly) + +The `feature('BRIDGE_MODE')` gate in `src/shims/bun-bundle.ts` defaults to +`false` (reads `CLAUDE_CODE_BRIDGE_MODE` env var). All critical code paths +are properly guarded: + +| Location | Guard | +|----------|-------| +| `src/entrypoints/cli.tsx:112` | `feature('BRIDGE_MODE') && args[0] === 'remote-control'` | +| `src/main.tsx:2246` | `feature('BRIDGE_MODE') && remoteControlOption !== undefined` | +| `src/main.tsx:3866` | `if (feature('BRIDGE_MODE'))` (Commander subcommand) | +| `src/hooks/useReplBridge.tsx:79-88` | All `useAppState` calls gated by `feature('BRIDGE_MODE')` ternary | +| `src/hooks/useReplBridge.tsx:99` | `useEffect` body gated by `feature('BRIDGE_MODE')` | +| `src/components/PromptInput/PromptInputFooter.tsx:160` | `if (!feature('BRIDGE_MODE')) return null` | +| `src/components/Settings/Config.tsx:930` | `feature('BRIDGE_MODE') && isBridgeEnabled()` spread | +| `src/tools/BriefTool/upload.ts:99` | `if (feature('BRIDGE_MODE'))` | +| `src/tools/ConfigTool/supportedSettings.ts:153` | `feature('BRIDGE_MODE')` spread | + +### Can Defer (full bridge functionality) + +All of the following are behind the feature gate and inactive: +- `runBridgeLoop()` — Full bridge orchestration in `bridgeMain.ts` +- `initReplBridge()` — REPL bridge initialization +- `initBridgeCore()` / `initEnvLessBridgeCore()` — Transport negotiation +- `createBridgeApiClient()` — Environments API calls +- `BridgeUI` — Bridge status display and QR codes +- Token refresh scheduling +- Multi-session management (worktree mode) +- Permission delegation to IDE + +### Won't Break + +Static imports of bridge modules from outside `src/bridge/` do NOT crash because: + +1. **All bridge files exist** — they're in the repo, so imports resolve. +2. **No side effects at import time** — bridge modules define functions/types + but don't execute bridge logic on import. +3. **Runtime guards** — Functions like `isBridgeEnabled()` return `false` + when `feature('BRIDGE_MODE')` is false. `getReplBridgeHandle()` returns + `null`. `useReplBridge` short-circuits via ternary operators. + +Files with unguarded static imports (safe because files exist): +- `src/hooks/useReplBridge.tsx` — imports types and utils from bridge +- `src/components/Settings/Config.tsx` — imports `isBridgeEnabled` (returns false) +- `src/components/PromptInput/PromptInputFooter.tsx` — early-returns null +- `src/tools/SendMessageTool/SendMessageTool.ts` — `getReplBridgeHandle()` returns null +- `src/tools/BriefTool/upload.ts` — guarded at call site +- `src/commands/logout/logout.tsx` — `clearTrustedDeviceTokenCache` is a no-op + +--- + +## Bridge Stub + +Created `src/bridge/stub.ts` with: +- `isBridgeAvailable()` → always returns `false` +- `noopBridgeHandle` — silent no-op `ReplBridgeHandle` +- `noopBridgeLogger` — silent no-op `BridgeLogger` + +Available for any future code that needs a safe fallback when bridge is off. + +--- + +## Bridge Activation (Future Work) + +To enable the bridge: + +### 1. Environment Variable +```bash +export CLAUDE_CODE_BRIDGE_MODE=true +``` + +### 2. Authentication Requirements +- Must be logged in to claude.ai with an active subscription + (`isClaudeAISubscriber()` must return `true`) +- OAuth tokens obtained via `claude auth login` (needs `user:profile` scope) +- GrowthBook gate `tengu_ccr_bridge` must be enabled for the user's org + +### 3. IDE Extension +- VS Code: Claude Code extension (connects via the bridge's Session-Ingress layer) +- JetBrains: Similar integration (same protocol) +- Web: `claude.ai/code?bridge={environmentId}` URL + +### 4. Network / Ports +- **Session-Ingress**: WebSocket (`wss://`) or SSE for reads; HTTPS POST for writes +- **API base**: Production `api.claude.ai` (configured via OAuth config) +- Dev overrides: `CLAUDE_BRIDGE_BASE_URL`, localhost uses `ws://` and `/v2/` paths +- QR code displayed in terminal links to `claude.ai/code?bridge={envId}` + +### 5. Running Remote Control +```bash +# Single session (tears down when session ends) +claude remote-control + +# Named session +claude remote-control "my-project" + +# With specific spawn mode (requires tengu_ccr_bridge_multi_session gate) +claude remote-control --spawn worktree +claude remote-control --spawn same-dir +``` + +### 6. Additional Flags +- `--remote-control [name]` / `--rc [name]` — Start REPL with bridge pre-enabled +- `--debug-file ` — Write debug log to file +- `--session-id ` — Resume an existing session + +--- + +## Chrome Extension Bridge + +### `--claude-in-chrome-mcp` (cli.tsx:72) + +Launches a **Claude-in-Chrome MCP server** via `runClaudeInChromeMcpServer()` from +`src/utils/claudeInChrome/mcpServer.ts`. This: +- Creates a `StdioServerTransport` (MCP over stdin/stdout) +- Uses `@ant/claude-for-chrome-mcp` package to create an MCP server +- Bridges between Claude Code and the Chrome extension +- Supports both native socket (local) and WebSocket bridge (`wss://bridge.claudeusercontent.com`) +- Gated by `tengu_copper_bridge` GrowthBook flag (or `USER_TYPE=ant`) + +**Not gated by `feature('BRIDGE_MODE')`** — this is a separate subsystem. It only +runs when explicitly invoked with `--claude-in-chrome-mcp` flag. + +### `--chrome-native-host` (cli.tsx:79) + +Launches the **Chrome Native Messaging Host** via `runChromeNativeHost()` from +`src/utils/claudeInChrome/chromeNativeHost.ts`. This: +- Implements Chrome's native messaging protocol (4-byte length prefix + JSON over stdin/stdout) +- Creates a Unix domain socket server at a secure path +- Proxies MCP messages between Chrome extension and local Claude Code instances +- Has its own debug logging to `~/.claude/debug/chrome-native-host.txt` (ant-only) + +**Not gated by `feature('BRIDGE_MODE')`** — separate entry point. Only activated +when Chrome calls the registered native messaging host binary. + +### Safety + +Both Chrome paths: +- Are **dynamic imports** — only loaded when the specific flag is passed +- Return immediately after their own `await` — no side effects on normal CLI startup +- Cannot crash normal operation because they're entirely separate code paths +- Have no dependency on the bridge feature flag + +--- + +## Verification Summary + +| Check | Status | +|-------|--------| +| `feature('BRIDGE_MODE')` returns `false` by default | ✅ Verified in `src/shims/bun-bundle.ts` | +| Bridge code not executed when disabled | ✅ All call sites use `feature()` guard | +| No bridge-related errors on startup | ✅ Imports resolve (files exist), no side effects | +| CLI works in terminal-only mode | ✅ Bridge is purely additive | +| Chrome paths don't crash | ✅ Separate dynamic imports, only on explicit flags | +| Stub available for safety | ✅ Created `src/bridge/stub.ts` | diff --git a/mcp-server/.npmignore b/mcp-server/.npmignore new file mode 100644 index 0000000..3d47613 --- /dev/null +++ b/mcp-server/.npmignore @@ -0,0 +1,4 @@ +.mcpregistry_* +src/ +tsconfig.json +*.ts.new diff --git a/mcp-server/README.md b/mcp-server/README.md index ece4096..013eebe 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -1,10 +1,18 @@ # Claude Code Explorer — MCP Server -A standalone [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets any MCP-compatible client explore the Claude Code source code. +A standalone [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets any MCP-compatible client explore the Claude Code source code. Supports **STDIO**, **Streamable HTTP**, and **SSE** transports. ## What It Does -Exposes 7 tools, 3 resources, and 5 prompts for navigating the ~1,900-file, 512K+ line Claude Code codebase: +Exposes 8 tools, 3 resources, and 5 prompts for navigating the ~1,900-file, 512K+ line Claude Code codebase: + +### Transports + +| Transport | Endpoint | Best For | +|-----------|----------|----------| +| **STDIO** | `node dist/index.js` | Claude Desktop, local Claude Code, VS Code | +| **Streamable HTTP** | `POST/GET /mcp` | Modern MCP clients, remote hosting | +| **Legacy SSE** | `GET /sse` + `POST /messages` | Older MCP clients | ### Tools @@ -46,6 +54,30 @@ npm install npm run build ``` +### Run Locally (STDIO) + +```bash +npm start +# or with custom source path: +CLAUDE_CODE_SRC_ROOT=/path/to/src npm start +``` + +### Run Locally (HTTP) + +```bash +npm run start:http +# Streamable HTTP at http://localhost:3000/mcp +# Legacy SSE at http://localhost:3000/sse +# Health check at http://localhost:3000/health +``` + +### With Authentication + +```bash +MCP_API_KEY=your-secret-token npm run start:http +# Clients must include: Authorization: Bearer your-secret-token +``` + ## Configuration ### Claude Desktop @@ -108,6 +140,65 @@ Add to `~/.cursor/mcp.json`: | Variable | Default | Description | |----------|---------|-------------| | `CLAUDE_CODE_SRC_ROOT` | `../src` (relative to dist/) | Path to the Claude Code `src/` directory | +| `PORT` | `3000` | HTTP server port (HTTP mode only) | +| `MCP_API_KEY` | _(none)_ | Bearer token for HTTP auth (optional) | + +## Remote HTTP Client Configuration + +For Claude Desktop connecting to a remote server: + +```json +{ + "mcpServers": { + "claude-code-explorer": { + "url": "https://your-deployment.railway.app/mcp", + "headers": { + "Authorization": "Bearer your-secret-key" + } + } + } +} +``` + +## Deployment + +### Railway + +1. Connect your GitHub repo to [Railway](https://railway.app) +2. Railway automatically detects the `mcp-server/Dockerfile` +3. Set environment variables in the Railway dashboard: + - `MCP_API_KEY` — a secret bearer token + - `PORT` is set automatically by Railway +4. Deploy — available at `your-app.railway.app` + +Or via CLI: + +```bash +railway init +railway up +``` + +### Vercel + +```bash +npx vercel +``` + +Set environment variables in the Vercel dashboard: +- `CLAUDE_CODE_SRC_ROOT` — path where src/ files are bundled +- `MCP_API_KEY` — bearer token + +> **Note**: Vercel functions are stateless with execution time limits (10s hobby / 60s pro). Best for simple tool calls. For persistent SSE streams, use Railway or Docker. + +### Docker + +```bash +# From repo root +docker build -f mcp-server/Dockerfile -t claude-code-mcp . +docker run -p 3000:3000 -e MCP_API_KEY=your-secret claude-code-mcp +``` + +Works on any Docker host: Fly.io, Render, AWS ECS, Google Cloud Run, etc. ## Prompts @@ -162,9 +253,27 @@ Registry name: `io.github.nirholas/claude-code-explorer-mcp` ```bash npm install -npm run dev # Run directly with tsx (no build needed) +npm run dev # Watch mode TypeScript compilation npm run build # Compile TypeScript to dist/ -npm start # Run compiled server +npm start # Run STDIO server +npm run start:http # Run HTTP server +``` + +## Architecture + +``` +mcp-server/ +├── src/ +│ ├── server.ts — Shared MCP server (tools, resources, prompts) — transport-agnostic +│ ├── index.ts — STDIO entrypoint (local) +│ └── http.ts — HTTP + SSE entrypoint (remote) +├── api/ +│ ├── index.ts — Vercel serverless function +│ └── vercelApp.ts — Express app for Vercel +├── Dockerfile — Docker build (Railway, Fly.io, etc.) +├── railway.json — Railway deployment config +├── package.json +└── tsconfig.json ``` diff --git a/mcp-server/server.json b/mcp-server/server.json index 5413d19..f8a5ae6 100644 --- a/mcp-server/server.json +++ b/mcp-server/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.nirholas/claude-code-explorer-mcp", "title": "Claude Code Explorer MCP", - "description": "MCP server for exploring the Claude Code CLI source code — browse 40+ tools, 50+ commands, search the full 512K-line codebase, and get architecture overviews.", + "description": "Explore the Claude Code CLI source — browse tools, commands, search code, and more.", "repository": { "url": "https://github.com/nirholas/claude-code", "source": "github", diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json index 4e7ed2d..a4435a7 100644 --- a/mcp-server/tsconfig.json +++ b/mcp-server/tsconfig.json @@ -4,7 +4,7 @@ "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", - "rootDir": "./src", + "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -14,7 +14,7 @@ "declarationMap": true, "sourceMap": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "api/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/scripts/build-bundle.ts b/scripts/build-bundle.ts new file mode 100644 index 0000000..0a7ddb4 --- /dev/null +++ b/scripts/build-bundle.ts @@ -0,0 +1,138 @@ +// scripts/build-bundle.ts +// Usage: bun scripts/build-bundle.ts [--watch] [--minify] [--no-sourcemap] +// +// Production build: bun scripts/build-bundle.ts --minify +// Dev build: bun scripts/build-bundle.ts +// Watch mode: bun scripts/build-bundle.ts --watch + +import * as esbuild from 'esbuild' +import { resolve } from 'path' +import { chmodSync, readFileSync } from 'fs' + +const ROOT = resolve(import.meta.dir, '..') +const watch = process.argv.includes('--watch') +const minify = process.argv.includes('--minify') +const noSourcemap = process.argv.includes('--no-sourcemap') + +// Read version from package.json for MACRO injection +const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')) +const version = pkg.version || '0.0.0-dev' + +const buildOptions: esbuild.BuildOptions = { + entryPoints: [resolve(ROOT, 'src/entrypoints/cli.tsx')], + bundle: true, + platform: 'node', + target: ['node20', 'es2022'], + format: 'esm', + outdir: resolve(ROOT, 'dist'), + outExtension: { '.js': '.mjs' }, + + // Single-file output — no code splitting for CLI tools + splitting: false, + + // Inject the MACRO global before all other code + inject: [resolve(ROOT, 'src/shims/macro.ts')], + + // Alias bun:bundle to our runtime shim + alias: { + 'bun:bundle': resolve(ROOT, 'src/shims/bun-bundle.ts'), + }, + + // Don't bundle node built-ins or problematic native packages + external: [ + // Node built-ins (with and without node: prefix) + 'fs', 'path', 'os', 'crypto', 'child_process', 'http', 'https', + 'net', 'tls', 'url', 'util', 'stream', 'events', 'buffer', + 'querystring', 'readline', 'zlib', 'assert', 'tty', 'worker_threads', + 'perf_hooks', 'async_hooks', 'dns', 'dgram', 'cluster', + 'string_decoder', 'module', 'vm', 'constants', 'domain', + 'console', 'process', 'v8', 'inspector', + 'node:*', + // Native addons that can't be bundled + 'fsevents', + ], + + jsx: 'automatic', + + // Source maps for production debugging (external .map files) + sourcemap: noSourcemap ? false : 'external', + + // Minification for production + minify, + + // Tree shaking (on by default, explicit for clarity) + treeShaking: true, + + // Define replacements — inline constants at build time + // Eliminates process.env.USER_TYPE === 'ant' branches (Anthropic-internal code) + // Sets NODE_ENV to production for production builds + define: { + 'process.env.USER_TYPE': '"external"', + 'process.env.NODE_ENV': minify ? '"production"' : '"development"', + }, + + // Banner: shebang for direct CLI execution + banner: { + js: '#!/usr/bin/env node\n', + }, + + // Handle the .js → .ts resolution that the codebase uses + resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json'], + + logLevel: 'info', + + // Metafile for bundle analysis + metafile: true, +} + +async function main() { + if (watch) { + const ctx = await esbuild.context(buildOptions) + await ctx.watch() + console.log('Watching for changes...') + } else { + const startTime = Date.now() + const result = await esbuild.build(buildOptions) + + if (result.errors.length > 0) { + console.error('Build failed') + process.exit(1) + } + + // Make the output executable + const outPath = resolve(ROOT, 'dist/cli.mjs') + try { + chmodSync(outPath, 0o755) + } catch { + // chmod may fail on some platforms, non-fatal + } + + const elapsed = Date.now() - startTime + + // Print bundle size info + if (result.metafile) { + const text = await esbuild.analyzeMetafile(result.metafile, { verbose: false }) + const outFiles = Object.entries(result.metafile.outputs) + for (const [file, info] of outFiles) { + if (file.endsWith('.mjs')) { + const sizeMB = (info.bytes / 1024 / 1024).toFixed(2) + console.log(`\n ${file}: ${sizeMB} MB`) + } + } + console.log(`\nBuild complete in ${elapsed}ms → dist/`) + + // Write metafile for further analysis + const { writeFileSync } = await import('fs') + writeFileSync( + resolve(ROOT, 'dist/meta.json'), + JSON.stringify(result.metafile), + ) + console.log(' Metafile written to dist/meta.json') + } + } +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/bun-plugin-shims.ts b/scripts/bun-plugin-shims.ts new file mode 100644 index 0000000..dbc682f --- /dev/null +++ b/scripts/bun-plugin-shims.ts @@ -0,0 +1,18 @@ +// scripts/bun-plugin-shims.ts +// Bun preload plugin — intercepts `bun:bundle` imports at runtime +// and resolves them to our local shim so the CLI can run without +// the production Bun bundler pass. + +import { plugin } from 'bun' +import { resolve } from 'path' + +plugin({ + name: 'bun-bundle-shim', + setup(build) { + const shimPath = resolve(import.meta.dir, '../src/shims/bun-bundle.ts') + + build.onResolve({ filter: /^bun:bundle$/ }, () => ({ + path: shimPath, + })) + }, +}) diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh new file mode 100644 index 0000000..61a9d95 --- /dev/null +++ b/scripts/ci-build.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────── +# ci-build.sh — CI/CD build pipeline +# ───────────────────────────────────────────────────────────── +# Runs the full build pipeline: install, typecheck, lint, build, +# and verify the output. Intended for CI environments. +# +# Usage: +# ./scripts/ci-build.sh +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +echo "=== Installing dependencies ===" +bun install + +echo "=== Type checking ===" +bun run typecheck + +echo "=== Linting ===" +bun run lint + +echo "=== Building production bundle ===" +bun run build:prod + +echo "=== Verifying build output ===" + +# Check that the bundle was produced +if [ ! -f dist/cli.mjs ]; then + echo "ERROR: dist/cli.mjs not found" + exit 1 +fi + +# Print bundle size +SIZE=$(ls -lh dist/cli.mjs | awk '{print $5}') +echo " Bundle size: $SIZE" + +# Verify the bundle runs with Node.js +if command -v node &>/dev/null; then + VERSION=$(node dist/cli.mjs --version 2>&1 || true) + echo " node dist/cli.mjs --version → $VERSION" +fi + +# Verify the bundle runs with Bun +if command -v bun &>/dev/null; then + VERSION=$(bun dist/cli.mjs --version 2>&1 || true) + echo " bun dist/cli.mjs --version → $VERSION" +fi + +echo "=== Done ===" diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..0d4a63b --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,15 @@ +// scripts/dev.ts +// Development launcher — runs the CLI directly via Bun's TS runtime. +// +// Usage: +// bun scripts/dev.ts [args...] +// bun run dev [args...] +// +// The bun:bundle shim is loaded automatically via bunfig.toml preload. +// Bun automatically reads .env files from the project root. + +// Load MACRO global (version, package url, etc.) before any app code +import '../src/shims/macro.js' + +// Launch the CLI entrypoint +await import('../src/entrypoints/cli.js') diff --git a/scripts/package-npm.ts b/scripts/package-npm.ts new file mode 100644 index 0000000..0c12d49 --- /dev/null +++ b/scripts/package-npm.ts @@ -0,0 +1,85 @@ +// scripts/package-npm.ts +// Generate a publishable npm package in dist/npm/ +// +// Usage: bun scripts/package-npm.ts +// +// Prerequisites: run `bun run build:prod` first to generate dist/cli.mjs + +import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, chmodSync } from 'fs' +import { resolve } from 'path' + +const ROOT = resolve(import.meta.dir, '..') +const DIST = resolve(ROOT, 'dist') +const NPM_DIR = resolve(DIST, 'npm') +const CLI_BUNDLE = resolve(DIST, 'cli.mjs') + +function main() { + // Verify the bundle exists + if (!existsSync(CLI_BUNDLE)) { + console.error('Error: dist/cli.mjs not found. Run `bun run build:prod` first.') + process.exit(1) + } + + // Read source package.json + const srcPkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')) + + // Create npm output directory + mkdirSync(NPM_DIR, { recursive: true }) + + // Copy the bundled CLI + copyFileSync(CLI_BUNDLE, resolve(NPM_DIR, 'cli.mjs')) + chmodSync(resolve(NPM_DIR, 'cli.mjs'), 0o755) + + // Copy source map if it exists + const sourceMap = resolve(DIST, 'cli.mjs.map') + if (existsSync(sourceMap)) { + copyFileSync(sourceMap, resolve(NPM_DIR, 'cli.mjs.map')) + } + + // Generate a publishable package.json + const npmPkg = { + name: srcPkg.name || '@anthropic-ai/claude-code', + version: srcPkg.version || '0.0.0', + description: srcPkg.description || 'Anthropic Claude Code CLI', + license: 'MIT', + type: 'module', + main: './cli.mjs', + bin: { + claude: './cli.mjs', + }, + engines: { + node: '>=20.0.0', + }, + os: ['darwin', 'linux', 'win32'], + files: [ + 'cli.mjs', + 'cli.mjs.map', + 'README.md', + ], + } + + writeFileSync( + resolve(NPM_DIR, 'package.json'), + JSON.stringify(npmPkg, null, 2) + '\n', + ) + + // Copy README if it exists + const readme = resolve(ROOT, 'README.md') + if (existsSync(readme)) { + copyFileSync(readme, resolve(NPM_DIR, 'README.md')) + } + + // Summary + const bundleSize = readFileSync(CLI_BUNDLE).byteLength + const sizeMB = (bundleSize / 1024 / 1024).toFixed(2) + + console.log('npm package generated in dist/npm/') + console.log(` package: ${npmPkg.name}@${npmPkg.version}`) + console.log(` bundle: cli.mjs (${sizeMB} MB)`) + console.log(` bin: claude → ./cli.mjs`) + console.log('') + console.log('To publish:') + console.log(' cd dist/npm && npm publish') +} + +main() diff --git a/scripts/test-auth.ts b/scripts/test-auth.ts new file mode 100644 index 0000000..cd17fc0 --- /dev/null +++ b/scripts/test-auth.ts @@ -0,0 +1,26 @@ +// scripts/test-auth.ts +// Quick test that the API key is configured and can reach Anthropic +// Usage: bun scripts/test-auth.ts + +import Anthropic from '@anthropic-ai/sdk' + +const client = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, +}) + +async function main() { + try { + const msg = await client.messages.create({ + model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514', + max_tokens: 50, + messages: [{ role: 'user', content: 'Say "hello" and nothing else.' }], + }) + console.log('✅ API connection successful!') + console.log('Response:', msg.content[0].type === 'text' ? msg.content[0].text : msg.content[0]) + } catch (err: any) { + console.error('❌ API connection failed:', err.message) + process.exit(1) + } +} + +main() diff --git a/scripts/test-mcp.ts b/scripts/test-mcp.ts new file mode 100644 index 0000000..7326fe2 --- /dev/null +++ b/scripts/test-mcp.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env npx tsx +/** + * scripts/test-mcp.ts + * Test MCP client/server roundtrip using the standalone mcp-server sub-project. + * + * Usage: + * cd mcp-server && npm install && npm run build && cd .. + * npx tsx scripts/test-mcp.ts + * + * What it does: + * 1. Spawns mcp-server/dist/index.js as a child process (stdio transport) + * 2. Creates an MCP client using @modelcontextprotocol/sdk + * 3. Connects client to server + * 4. Lists available tools + * 5. Calls list_tools and read_source_file tools + * 6. Lists resources and reads one + * 7. Prints results and exits + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const PROJECT_ROOT = resolve(__dirname, ".."); + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function section(title: string) { + console.log(`\n${"─".repeat(60)}`); + console.log(` ${title}`); + console.log(`${"─".repeat(60)}`); +} + +function jsonPretty(obj: unknown): string { + return JSON.stringify(obj, null, 2); +} + +// ── Main ────────────────────────────────────────────────────────────────── + +async function main() { + const serverScript = resolve(PROJECT_ROOT, "mcp-server", "dist", "index.js"); + const srcRoot = resolve(PROJECT_ROOT, "src"); + + section("1. Spawning MCP server (stdio transport)"); + console.log(` Server: ${serverScript}`); + console.log(` SRC_ROOT: ${srcRoot}`); + + const transport = new StdioClientTransport({ + command: "node", + args: [serverScript], + env: { + ...process.env, + CLAUDE_CODE_SRC_ROOT: srcRoot, + } as Record, + stderr: "pipe", + }); + + // Log stderr from the server process + if (transport.stderr) { + transport.stderr.on("data", (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) console.log(` [server stderr] ${msg}`); + }); + } + + section("2. Creating MCP client"); + const client = new Client( + { + name: "test-mcp-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + + section("3. Connecting client → server"); + await client.connect(transport); + console.log(" ✓ Connected successfully"); + + // ── List Tools ────────────────────────────────────────────────────────── + section("4. Listing available tools"); + const toolsResult = await client.listTools(); + console.log(` Found ${toolsResult.tools.length} tool(s):`); + for (const tool of toolsResult.tools) { + console.log(` • ${tool.name} — ${tool.description?.slice(0, 80)}`); + } + + // ── Call list_tools ───────────────────────────────────────────────────── + section("5. Calling tool: list_tools"); + const listToolsResult = await client.callTool({ + name: "list_tools", + arguments: {}, + }); + const listToolsContent = listToolsResult.content as Array<{ + type: string; + text: string; + }>; + const listToolsText = listToolsContent + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + // Show first 500 chars + console.log( + ` Result (first 500 chars):\n${listToolsText.slice(0, 500)}${listToolsText.length > 500 ? "\n …(truncated)" : ""}` + ); + + // ── Call read_source_file ─────────────────────────────────────────────── + section("6. Calling tool: read_source_file (path: 'main.tsx', lines 1-20)"); + const readResult = await client.callTool({ + name: "read_source_file", + arguments: { path: "main.tsx", startLine: 1, endLine: 20 }, + }); + const readContent = readResult.content as Array<{ + type: string; + text: string; + }>; + const readText = readContent + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + console.log(` Result:\n${readText.slice(0, 600)}`); + + // ── List Resources ────────────────────────────────────────────────────── + section("7. Listing resources"); + try { + const resourcesResult = await client.listResources(); + console.log(` Found ${resourcesResult.resources.length} resource(s):`); + for (const res of resourcesResult.resources.slice(0, 10)) { + console.log(` • ${res.name} (${res.uri})`); + } + if (resourcesResult.resources.length > 10) { + console.log( + ` …and ${resourcesResult.resources.length - 10} more` + ); + } + + // Read the first resource + if (resourcesResult.resources.length > 0) { + const firstRes = resourcesResult.resources[0]!; + section(`8. Reading resource: ${firstRes.name}`); + const resContent = await client.readResource({ uri: firstRes.uri }); + const resText = resContent.contents + .filter((c): c is { uri: string; text: string; mimeType?: string } => "text" in c) + .map((c) => c.text) + .join("\n"); + console.log( + ` Content (first 400 chars):\n${resText.slice(0, 400)}${resText.length > 400 ? "\n …(truncated)" : ""}` + ); + } + } catch (err) { + console.log(` Resources not supported or error: ${err}`); + } + + // ── List Prompts ──────────────────────────────────────────────────────── + section("9. Listing prompts"); + try { + const promptsResult = await client.listPrompts(); + console.log(` Found ${promptsResult.prompts.length} prompt(s):`); + for (const p of promptsResult.prompts) { + console.log(` • ${p.name} — ${p.description?.slice(0, 80)}`); + } + } catch (err) { + console.log(` Prompts not supported or error: ${err}`); + } + + // ── Cleanup ───────────────────────────────────────────────────────────── + section("Done ✓"); + console.log(" All tests passed. Closing connection."); + await client.close(); + process.exit(0); +} + +main().catch((err) => { + console.error("\n✗ Test failed:", err); + process.exit(1); +}); diff --git a/scripts/test-services.ts b/scripts/test-services.ts new file mode 100644 index 0000000..576ff9f --- /dev/null +++ b/scripts/test-services.ts @@ -0,0 +1,233 @@ +// scripts/test-services.ts +// Test that all services initialize without crashing +// Usage: bun scripts/test-services.ts + +import '../src/shims/preload.js' + +// Ensure we don't accidentally talk to real servers +process.env.NODE_ENV = process.env.NODE_ENV || 'test' + +type TestResult = { name: string; status: 'pass' | 'fail' | 'skip'; detail?: string } +const results: TestResult[] = [] + +function pass(name: string, detail?: string) { + results.push({ name, status: 'pass', detail }) + console.log(` ✅ ${name}${detail ? ` — ${detail}` : ''}`) +} + +function fail(name: string, detail: string) { + results.push({ name, status: 'fail', detail }) + console.log(` ❌ ${name} — ${detail}`) +} + +function skip(name: string, detail: string) { + results.push({ name, status: 'skip', detail }) + console.log(` ⏭️ ${name} — ${detail}`) +} + +async function testGrowthBook() { + console.log('\n--- GrowthBook (Feature Flags) ---') + try { + const gb = await import('../src/services/analytics/growthbook.js') + + // Test cached feature value returns default when GrowthBook is unavailable + const boolResult = gb.getFeatureValue_CACHED_MAY_BE_STALE('nonexistent_feature', false) + if (boolResult === false) { + pass('getFeatureValue_CACHED_MAY_BE_STALE (bool)', 'returns default false') + } else { + fail('getFeatureValue_CACHED_MAY_BE_STALE (bool)', `expected false, got ${boolResult}`) + } + + const strResult = gb.getFeatureValue_CACHED_MAY_BE_STALE('nonexistent_str', 'default_val') + if (strResult === 'default_val') { + pass('getFeatureValue_CACHED_MAY_BE_STALE (str)', 'returns default string') + } else { + fail('getFeatureValue_CACHED_MAY_BE_STALE (str)', `expected "default_val", got "${strResult}"`) + } + + // Test Statsig gate check returns false + const gateResult = gb.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('nonexistent_gate') + if (gateResult === false) { + pass('checkStatsigFeatureGate_CACHED_MAY_BE_STALE', 'returns false for unknown gate') + } else { + fail('checkStatsigFeatureGate_CACHED_MAY_BE_STALE', `expected false, got ${gateResult}`) + } + } catch (err: any) { + fail('GrowthBook import', err.message) + } +} + +async function testAnalyticsSink() { + console.log('\n--- Analytics Sink ---') + try { + const analytics = await import('../src/services/analytics/index.js') + + // logEvent should queue without crashing when no sink is attached + analytics.logEvent('test_event', { test_key: 1 }) + pass('logEvent (no sink)', 'queues without crash') + + await analytics.logEventAsync('test_async_event', { test_key: 2 }) + pass('logEventAsync (no sink)', 'queues without crash') + } catch (err: any) { + fail('Analytics sink', err.message) + } +} + +async function testPolicyLimits() { + console.log('\n--- Policy Limits ---') + try { + const pl = await import('../src/services/policyLimits/index.js') + + // isPolicyAllowed should return true (fail open) when no restrictions loaded + const result = pl.isPolicyAllowed('allow_remote_sessions') + if (result === true) { + pass('isPolicyAllowed (no cache)', 'fails open — returns true') + } else { + fail('isPolicyAllowed (no cache)', `expected true (fail open), got ${result}`) + } + + // isPolicyLimitsEligible should return false without valid auth + const eligible = pl.isPolicyLimitsEligible() + pass('isPolicyLimitsEligible', `returns ${eligible} (expected false in test env)`) + } catch (err: any) { + fail('Policy limits', err.message) + } +} + +async function testRemoteManagedSettings() { + console.log('\n--- Remote Managed Settings ---') + try { + const rms = await import('../src/services/remoteManagedSettings/index.js') + + // isEligibleForRemoteManagedSettings should return false without auth + const eligible = rms.isEligibleForRemoteManagedSettings() + pass('isEligibleForRemoteManagedSettings', `returns ${eligible} (expected false in test env)`) + + // waitForRemoteManagedSettingsToLoad should resolve immediately if not eligible + await rms.waitForRemoteManagedSettingsToLoad() + pass('waitForRemoteManagedSettingsToLoad', 'resolves immediately when not eligible') + } catch (err: any) { + fail('Remote managed settings', err.message) + } +} + +async function testBootstrapData() { + console.log('\n--- Bootstrap Data ---') + try { + const bootstrap = await import('../src/services/api/bootstrap.js') + + // fetchBootstrapData should not crash — just skip when no auth + await bootstrap.fetchBootstrapData() + pass('fetchBootstrapData', 'completes without crash (skips when no auth)') + } catch (err: any) { + // fetchBootstrapData catches its own errors, so this means an import-level issue + fail('Bootstrap data', err.message) + } +} + +async function testSessionMemoryUtils() { + console.log('\n--- Session Memory ---') + try { + const smUtils = await import('../src/services/SessionMemory/sessionMemoryUtils.js') + + // Default config should be sensible + const config = smUtils.DEFAULT_SESSION_MEMORY_CONFIG + if (config.minimumMessageTokensToInit > 0 && config.minimumTokensBetweenUpdate > 0) { + pass('DEFAULT_SESSION_MEMORY_CONFIG', `init=${config.minimumMessageTokensToInit} tokens, update=${config.minimumTokensBetweenUpdate} tokens`) + } else { + fail('DEFAULT_SESSION_MEMORY_CONFIG', 'unexpected config values') + } + + // getLastSummarizedMessageId should return undefined initially + const lastId = smUtils.getLastSummarizedMessageId() + if (lastId === undefined) { + pass('getLastSummarizedMessageId', 'returns undefined initially') + } else { + fail('getLastSummarizedMessageId', `expected undefined, got ${lastId}`) + } + } catch (err: any) { + fail('Session memory utils', err.message) + } +} + +async function testCostTracker() { + console.log('\n--- Cost Tracking ---') + try { + const ct = await import('../src/cost-tracker.js') + + // Total cost should start at 0 + const cost = ct.getTotalCost() + if (cost === 0) { + pass('getTotalCost', 'starts at $0.00') + } else { + pass('getTotalCost', `current: $${cost.toFixed(4)} (non-zero means restored session)`) + } + + // Duration should be available + const duration = ct.getTotalDuration() + pass('getTotalDuration', `${duration}ms`) + + // Token counters should be available + const inputTokens = ct.getTotalInputTokens() + const outputTokens = ct.getTotalOutputTokens() + pass('Token counters', `input=${inputTokens}, output=${outputTokens}`) + + // Lines changed + const added = ct.getTotalLinesAdded() + const removed = ct.getTotalLinesRemoved() + pass('Lines changed', `+${added} -${removed}`) + } catch (err: any) { + fail('Cost tracker', err.message) + } +} + +async function testInit() { + console.log('\n--- Init (entrypoint) ---') + try { + const { init } = await import('../src/entrypoints/init.js') + await init() + pass('init()', 'completed successfully') + } catch (err: any) { + fail('init()', err.message) + } +} + +async function main() { + console.log('=== Services Layer Smoke Test ===') + console.log(`Environment: NODE_ENV=${process.env.NODE_ENV}`) + console.log(`Auth: ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY ? '(set)' : '(not set)'}`) + + // Test individual services first (order: least-dependent → most-dependent) + await testAnalyticsSink() + await testGrowthBook() + await testPolicyLimits() + await testRemoteManagedSettings() + await testBootstrapData() + await testSessionMemoryUtils() + await testCostTracker() + + // Then test the full init sequence + await testInit() + + // Summary + console.log('\n=== Summary ===') + const passed = results.filter(r => r.status === 'pass').length + const failed = results.filter(r => r.status === 'fail').length + const skipped = results.filter(r => r.status === 'skip').length + console.log(` ${passed} passed, ${failed} failed, ${skipped} skipped`) + + if (failed > 0) { + console.log('\nFailed tests:') + for (const r of results.filter(r => r.status === 'fail')) { + console.log(` ❌ ${r.name}: ${r.detail}`) + } + process.exit(1) + } + + console.log('\n✅ All services handle graceful degradation correctly') +} + +main().catch(err => { + console.error('Fatal error in smoke test:', err) + process.exit(1) +}) diff --git a/server.json b/server.json index 50ce6e7..7e0f4e0 100644 --- a/server.json +++ b/server.json @@ -8,5 +8,17 @@ "source": "github", "subfolder": "mcp-server" }, - "version": "1.1.0" + "version": "1.1.0", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "claude-code-explorer-mcp", + "version": "1.1.0", + "transport": { + "type": "stdio" + }, + "runtimeHint": "node" + } + ] } diff --git a/src/bridge/stub.ts b/src/bridge/stub.ts new file mode 100644 index 0000000..de22c45 --- /dev/null +++ b/src/bridge/stub.ts @@ -0,0 +1,66 @@ +/** + * Bridge stub — no-op implementations for when BRIDGE_MODE is disabled. + * + * The bridge files themselves are safe to import even when bridge is off + * (no side effects at import time), and all call sites guard execution + * with `feature('BRIDGE_MODE')` checks. This stub exists as a safety net + * for any future code path that might reference bridge functionality + * outside the feature gate. + * + * Usage: + * import { isBridgeAvailable, noopBridgeHandle } from './stub.js' + */ + +import type { ReplBridgeHandle } from './replBridge.js' + +/** Returns false — bridge is not available in this build/configuration. */ +export function isBridgeAvailable(): false { + return false +} + +/** + * A no-op ReplBridgeHandle that silently discards all messages. + * Use this when code expects a handle but bridge is disabled. + */ +export const noopBridgeHandle: ReplBridgeHandle = { + bridgeSessionId: '', + environmentId: '', + sessionIngressUrl: '', + writeMessages() {}, + writeSdkMessages() {}, + sendControlRequest() {}, + sendControlResponse() {}, + sendControlCancelRequest() {}, + sendResult() {}, + async teardown() {}, +} + +/** + * No-op bridge logger that silently drops all output. + */ +export const noopBridgeLogger = { + printBanner() {}, + logSessionStart() {}, + logSessionComplete() {}, + logSessionFailed() {}, + logStatus() {}, + logVerbose() {}, + logError() {}, + logReconnected() {}, + updateIdleStatus() {}, + updateReconnectingStatus() {}, + updateSessionStatus() {}, + clearStatus() {}, + setRepoInfo() {}, + setDebugLogPath() {}, + setAttached() {}, + updateFailedStatus() {}, + toggleQr() {}, + updateSessionCount() {}, + setSpawnModeDisplay() {}, + addSession() {}, + updateSessionActivity() {}, + setSessionTitle() {}, + removeSession() {}, + refreshDisplay() {}, +} diff --git a/src/commands/x402/index.ts b/src/commands/x402/index.ts index c1d83bc..b8c5457 100644 --- a/src/commands/x402/index.ts +++ b/src/commands/x402/index.ts @@ -6,6 +6,7 @@ const x402 = { aliases: ['wallet', 'pay'], description: 'Configure x402 crypto payments (USDC on Base)', argumentHint: '[setup|status|enable|disable|set-limit|remove]', + supportsNonInteractive: true, load: () => import('./x402.js'), } satisfies Command diff --git a/src/commands/x402/x402.ts b/src/commands/x402/x402.ts index 09efd17..0f5e858 100644 --- a/src/commands/x402/x402.ts +++ b/src/commands/x402/x402.ts @@ -174,7 +174,7 @@ function handleRemove(): string { return 'Wallet removed and x402 payments disabled.\nPrivate key has been deleted from config.' } -export const call: LocalCommandCall = async (_onDone, _context, args) => { +export const call: LocalCommandCall = async (args) => { const subcommand = (args ?? '').trim().split(/\s+/)[0]?.toLowerCase() let value: string