Compare commits
10 Commits
c0b205208d
...
3a854557e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a854557e0 | ||
|
|
da6c5e1ed7 | ||
|
|
38648ae5f4 | ||
|
|
d31c2bec03 | ||
|
|
cf482195ff | ||
|
|
ed9c151933 | ||
|
|
78dd88afd1 | ||
|
|
1fc26c8125 | ||
|
|
754fea0e82 | ||
|
|
d35ea47ba7 |
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# Dependencies (rebuilt in container)
|
||||
node_modules
|
||||
|
||||
# Git metadata
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
|
||||
# Build output (rebuilt in container)
|
||||
dist
|
||||
|
||||
# Env files — never bake secrets into the image
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Logs and debug
|
||||
*.log
|
||||
npm-debug.log*
|
||||
bun-debug.log*
|
||||
|
||||
# Test artifacts
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Docker context itself
|
||||
docker
|
||||
155
.env.example
155
.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.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -12,6 +12,9 @@ build/
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Prompts
|
||||
prompts/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -32,6 +35,9 @@ coverage/
|
||||
# Bun
|
||||
bun.lockb
|
||||
|
||||
# MCP registry tokens
|
||||
.mcpregistry_*
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-code-explorer": {
|
||||
"command": "node",
|
||||
"args": ["mcp-server/dist/index.js"],
|
||||
"env": {
|
||||
"CLAUDE_CODE_SRC_ROOT": "./src"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
Dockerfile
40
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"]
|
||||
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -9,6 +9,7 @@
|
||||
[](#tech-stack)
|
||||
[](#directory-structure)
|
||||
[](#-explore-with-mcp-server)
|
||||
[](https://www.npmjs.com/package/claude-code-explorer-mcp)
|
||||
|
||||
> The original unmodified leaked source is preserved in the [`backup` branch](https://github.com/nirholas/claude-code/tree/backup).
|
||||
|
||||
@@ -83,7 +84,16 @@ Also see: [CONTRIBUTING.md](CONTRIBUTING.md) · [MCP Server README](mcp-server/R
|
||||
|
||||
This repo ships an [MCP server](https://modelcontextprotocol.io/) that lets any MCP-compatible client (Claude Code, Claude Desktop, VS Code Copilot, Cursor) explore the full source interactively.
|
||||
|
||||
### One-liner setup
|
||||
### Install from npm
|
||||
|
||||
The MCP server is published as [`claude-code-explorer-mcp`](https://www.npmjs.com/package/claude-code-explorer-mcp) on npm — no need to clone the repo:
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add claude-code-explorer -- npx -y claude-code-explorer-mcp
|
||||
```
|
||||
|
||||
### One-liner setup (from source)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nirholas/claude-code.git ~/claude-code \
|
||||
|
||||
635
bun.lock
Normal file
635
bun.lock
Normal file
@@ -0,0 +1,635 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@anthropic-ai/claude-code",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@commander-js/extra-typings": "^13.1.0",
|
||||
"@growthbook/growthbook": "^1.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.57.0",
|
||||
"@opentelemetry/core": "^1.30.0",
|
||||
"@opentelemetry/sdk-logs": "^0.57.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.30.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.7.0",
|
||||
"chalk": "^5.4.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"cli-boxes": "^3.0.0",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"execa": "^9.5.0",
|
||||
"figures": "^6.1.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"highlight.js": "^11.11.0",
|
||||
"ignore": "^6.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"p-map": "^7.0.0",
|
||||
"picomatch": "^4.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-reconciler": "^0.31.0",
|
||||
"semver": "^7.6.0",
|
||||
"stack-utils": "^2.0.6",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"supports-hyperlinks": "^3.1.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
"type-fest": "^4.30.0",
|
||||
"undici": "^7.3.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yaml": "^2.6.0",
|
||||
"zod": "^3.24.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.0",
|
||||
"@types/diff": "^7.0.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/picomatch": "^3.0.0",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/stack-utils": "^2.0.3",
|
||||
"@types/ws": "^8.5.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.7.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
|
||||
|
||||
"@commander-js/extra-typings": ["@commander-js/extra-typings@13.1.0", "", { "peerDependencies": { "commander": "~13.1.0" } }, "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
|
||||
|
||||
"@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
|
||||
|
||||
"@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="],
|
||||
|
||||
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg=="],
|
||||
|
||||
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@types/diff": ["@types/diff@7.0.2", "", {}, "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||
|
||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||
|
||||
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
|
||||
|
||||
"@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="],
|
||||
|
||||
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
|
||||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
|
||||
|
||||
"@xterm/addon-search": ["@xterm/addon-search@0.15.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg=="],
|
||||
|
||||
"@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.8.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q=="],
|
||||
|
||||
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="],
|
||||
|
||||
"@xterm/addon-webgl": ["@xterm/addon-webgl@0.18.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w=="],
|
||||
|
||||
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||
|
||||
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"dom-mutator": ["dom-mutator@0.6.0", "", {}, "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
|
||||
|
||||
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"get-intrinsic": ["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.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"http-errors": ["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" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": "bin/marked.js" }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": "bin/qrcode" }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"send": ["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.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": "cli.js" }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"yaml": ["yaml@2.8.3", "", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||
|
||||
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
4
bunfig.toml
Normal file
4
bunfig.toml
Normal file
@@ -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"]
|
||||
35
docker/.dockerignore
Normal file
35
docker/.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
||||
# NOTE: Docker reads .dockerignore from the build context root.
|
||||
# The canonical copy lives at /.dockerignore — keep both in sync.
|
||||
|
||||
# Dependencies (rebuilt in container)
|
||||
node_modules
|
||||
|
||||
# Git metadata
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
|
||||
# Build output (rebuilt in container)
|
||||
dist
|
||||
|
||||
# Env files — never bake secrets into the image
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Logs and debug
|
||||
*.log
|
||||
npm-debug.log*
|
||||
bun-debug.log*
|
||||
|
||||
# Test artifacts
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Docker context itself
|
||||
docker
|
||||
83
docker/Dockerfile
Normal file
83
docker/Dockerfile
Normal file
@@ -0,0 +1,83 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Claude Web Terminal — Production Container
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Multi-stage build: compiles node-pty native module and bundles
|
||||
# the Claude CLI, then copies artifacts into a slim runtime image.
|
||||
#
|
||||
# Usage:
|
||||
# docker build -f docker/Dockerfile -t claude-web .
|
||||
# docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Stage 1: Build ────────────────────────────────────────────
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build tools required to compile node-pty's native C++ addon
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy manifests first for layer caching
|
||||
COPY package.json bun.lockb* ./
|
||||
|
||||
# Install all deps (triggers node-pty native compilation)
|
||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
|
||||
# Copy source tree
|
||||
COPY . .
|
||||
|
||||
# Bundle the Claude CLI (produces dist/cli.mjs)
|
||||
RUN bun run build:prod
|
||||
|
||||
# ── Stage 2: Runtime ──────────────────────────────────────────
|
||||
FROM oven/bun:1 AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# curl for health checks; no build tools needed at runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user that PTY sessions will run under
|
||||
RUN groupadd -r claude && useradd -r -g claude -m -d /home/claude claude
|
||||
|
||||
# Compiled node_modules (includes native node-pty binary)
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Bundled Claude CLI
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# PTY server source (bun runs TypeScript natively)
|
||||
COPY --from=builder /app/src/server ./src/server
|
||||
|
||||
# TypeScript config needed for bun's module resolution
|
||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||
|
||||
# Thin wrapper so the PTY server can exec `claude` as a subprocess
|
||||
RUN printf '#!/bin/sh\nexec bun /app/dist/cli.mjs "$@"\n' \
|
||||
> /usr/local/bin/claude && chmod +x /usr/local/bin/claude
|
||||
|
||||
# Entrypoint script
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Allow the claude user to write its config into its home dir
|
||||
RUN chown -R claude:claude /home/claude
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
MAX_SESSIONS=5 \
|
||||
CLAUDE_BIN=claude
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER claude
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-3000}/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
28
docker/docker-compose.yml
Normal file
28
docker/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
claude-web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- AUTH_TOKEN=${AUTH_TOKEN:-}
|
||||
- MAX_SESSIONS=${MAX_SESSIONS:-5}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||
volumes:
|
||||
# Persist Claude's config and session data across restarts
|
||||
- claude-data:/home/claude/.claude
|
||||
tmpfs:
|
||||
# PTY processes write temp files here; no persistent storage needed
|
||||
- /tmp:mode=1777
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
claude-data:
|
||||
28
docker/entrypoint.sh
Normal file
28
docker/entrypoint.sh
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Validate required env vars ────────────────────────────────
|
||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "ERROR: ANTHROPIC_API_KEY is not set." >&2
|
||||
echo "" >&2
|
||||
echo " docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web" >&2
|
||||
echo "" >&2
|
||||
echo " Or via docker-compose with a .env file:" >&2
|
||||
echo " ANTHROPIC_API_KEY=sk-ant-... docker-compose up" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The API key is forwarded to child PTY processes via process.env,
|
||||
# so the claude CLI will pick it up automatically — no config file needed.
|
||||
|
||||
echo "Claude Web Terminal starting on port ${PORT:-3000}..."
|
||||
if [ -n "$AUTH_TOKEN" ]; then
|
||||
echo " Auth token protection: enabled"
|
||||
fi
|
||||
if [ -n "$ALLOWED_ORIGINS" ]; then
|
||||
echo " Allowed origins: $ALLOWED_ORIGINS"
|
||||
fi
|
||||
echo " Max sessions: ${MAX_SESSIONS:-5}"
|
||||
|
||||
# Hand off to the PTY WebSocket server
|
||||
exec bun /app/src/server/web/pty-server.ts
|
||||
239
docs/bridge.md
Normal file
239
docs/bridge.md
Normal file
@@ -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 <path>` — Write debug log to file
|
||||
- `--session-id <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` |
|
||||
4
mcp-server/.npmignore
Normal file
4
mcp-server/.npmignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.mcpregistry_*
|
||||
src/
|
||||
tsconfig.json
|
||||
*.ts.new
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
|
||||
985
package-lock.json
generated
985
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -10,6 +10,12 @@
|
||||
"claude": "src/entrypoints/cli.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun scripts/build-bundle.ts",
|
||||
"build:watch": "bun scripts/build-bundle.ts --watch",
|
||||
"build:prod": "bun scripts/build-bundle.ts --minify",
|
||||
"build:web": "bun scripts/build-web.ts",
|
||||
"build:web:watch": "bun scripts/build-web.ts --watch",
|
||||
"build:web:prod": "bun scripts/build-web.ts --minify",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check --write src/",
|
||||
@@ -27,6 +33,13 @@
|
||||
"@opentelemetry/sdk-logs": "^0.57.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.30.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.7.0",
|
||||
"chalk": "^5.4.0",
|
||||
"chokidar": "^4.0.0",
|
||||
@@ -35,13 +48,18 @@
|
||||
"diff": "^7.0.0",
|
||||
"execa": "^9.5.0",
|
||||
"figures": "^6.1.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"highlight.js": "^11.11.0",
|
||||
"ignore": "^6.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"p-map": "^7.0.0",
|
||||
"picomatch": "^4.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-reconciler": "^0.31.0",
|
||||
"semver": "^7.6.0",
|
||||
"stack-utils": "^2.0.6",
|
||||
"strip-ansi": "^7.1.0",
|
||||
@@ -49,6 +67,7 @@
|
||||
"tree-kill": "^1.2.2",
|
||||
"type-fest": "^4.30.0",
|
||||
"undici": "^7.3.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yaml": "^2.6.0",
|
||||
@@ -65,6 +84,7 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/stack-utils": "^2.0.3",
|
||||
"@types/ws": "^8.5.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -72,5 +92,3 @@
|
||||
},
|
||||
"packageManager": "bun@1.1.0"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,3 +43,43 @@ Currently `npm run build` (which runs `tsc`) fails with TypeScript errors.
|
||||
1. `cd mcp-server && npm run build` succeeds with zero errors
|
||||
2. `ls mcp-server/dist/` shows compiled `.js` files
|
||||
3. `node mcp-server/dist/index.js` starts without immediate crash
|
||||
|
||||
---
|
||||
|
||||
## Fix: GitHub Push Protection Blocked Push
|
||||
|
||||
`git push origin main` is rejected because commits `5e99ad6` and `7adaa5d` contain a **GitHub OAuth token** in `.mcpregistry_github_token`. The file was deleted from disk but still exists in git history. You must rewrite history to remove it.
|
||||
|
||||
### Step 1: Scrub secrets from history
|
||||
|
||||
```bash
|
||||
git filter-branch --force --index-filter \
|
||||
'git rm --cached --ignore-unmatch .mcpregistry_github_token .mcpregistry_registry_token mcp-server/.mcpregistry_github_token mcp-server/.mcpregistry_registry_token' \
|
||||
--prune-empty HEAD~5..HEAD
|
||||
```
|
||||
|
||||
### Step 2: Push
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Alternative: Interactive rebase
|
||||
|
||||
```bash
|
||||
git rebase -i HEAD~5
|
||||
# Change "pick" to "edit" for commits 5e99ad6 and 7adaa5d
|
||||
# At each stop, run:
|
||||
git rm --cached .mcpregistry_github_token .mcpregistry_registry_token 2>/dev/null
|
||||
git rm --cached mcp-server/.mcpregistry_github_token mcp-server/.mcpregistry_registry_token 2>/dev/null
|
||||
git commit --amend --no-edit
|
||||
git rebase --continue
|
||||
```
|
||||
|
||||
### Step 3: Prevent future leaks
|
||||
|
||||
```bash
|
||||
echo ".mcpregistry_github_token" >> .gitignore
|
||||
echo ".mcpregistry_registry_token" >> .gitignore
|
||||
git add .gitignore && git commit -m "chore: gitignore token files"
|
||||
```
|
||||
|
||||
197
scripts/build-bundle.ts
Normal file
197
scripts/build-bundle.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// 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, dirname } from 'path'
|
||||
import { chmodSync, readFileSync, existsSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// Bun: import.meta.dir — Node 21+: import.meta.dirname — fallback
|
||||
const __dir: string =
|
||||
(import.meta as any).dir ??
|
||||
(import.meta as any).dirname ??
|
||||
dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const ROOT = resolve(__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'
|
||||
|
||||
// ── Plugin: resolve bare 'src/' imports (tsconfig baseUrl: ".") ──
|
||||
// The codebase uses `import ... from 'src/foo/bar.js'` which relies on
|
||||
// TypeScript's baseUrl resolution. This plugin maps those to real TS files.
|
||||
const srcResolverPlugin: esbuild.Plugin = {
|
||||
name: 'src-resolver',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^src\// }, (args) => {
|
||||
const basePath = resolve(ROOT, args.path)
|
||||
|
||||
// Already exists as-is
|
||||
if (existsSync(basePath)) {
|
||||
return { path: basePath }
|
||||
}
|
||||
|
||||
// Strip .js/.jsx and try TypeScript extensions
|
||||
const withoutExt = basePath.replace(/\.(js|jsx)$/, '')
|
||||
for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
|
||||
const candidate = withoutExt + ext
|
||||
if (existsSync(candidate)) {
|
||||
return { path: candidate }
|
||||
}
|
||||
}
|
||||
|
||||
// Try as directory with index file
|
||||
const dirPath = basePath.replace(/\.(js|jsx)$/, '')
|
||||
for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
|
||||
const candidate = resolve(dirPath, 'index' + ext)
|
||||
if (existsSync(candidate)) {
|
||||
return { path: candidate }
|
||||
}
|
||||
}
|
||||
|
||||
// Let esbuild handle it (will error if truly missing)
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
plugins: [srcResolverPlugin],
|
||||
|
||||
// Use tsconfig for baseUrl / paths resolution (complements plugin above)
|
||||
tsconfig: resolve(ROOT, 'tsconfig.json'),
|
||||
|
||||
// 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',
|
||||
'sharp',
|
||||
'image-processor-napi',
|
||||
// Anthropic-internal packages (not published externally)
|
||||
'@anthropic-ai/sandbox-runtime',
|
||||
'@anthropic-ai/claude-agent-sdk',
|
||||
// Anthropic-internal (@ant/) packages — gated behind USER_TYPE === 'ant'
|
||||
'@ant/*',
|
||||
],
|
||||
|
||||
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
|
||||
// MACRO.* — originally inlined by Bun's bundler at compile time
|
||||
// process.env.USER_TYPE — eliminates 'ant' (Anthropic-internal) code branches
|
||||
define: {
|
||||
'MACRO.VERSION': JSON.stringify(version),
|
||||
'MACRO.PACKAGE_URL': JSON.stringify('@anthropic-ai/claude-code'),
|
||||
'MACRO.ISSUES_EXPLAINER': JSON.stringify(
|
||||
'report issues at https://github.com/anthropics/claude-code/issues'
|
||||
),
|
||||
'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 as { bytes: number }).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)
|
||||
})
|
||||
58
scripts/build-web.ts
Normal file
58
scripts/build-web.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// scripts/build-web.ts
|
||||
// Bundles the browser-side terminal frontend.
|
||||
//
|
||||
// Usage:
|
||||
// bun scripts/build-web.ts # dev build
|
||||
// bun scripts/build-web.ts --watch # watch mode
|
||||
// bun scripts/build-web.ts --minify # production (minified)
|
||||
|
||||
import * as esbuild from 'esbuild'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dir: string =
|
||||
(import.meta as any).dir ??
|
||||
(import.meta as any).dirname ??
|
||||
dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const ROOT = resolve(__dir, '..')
|
||||
const ENTRY = resolve(ROOT, 'src/server/web/terminal.ts')
|
||||
const OUT_DIR = resolve(ROOT, 'src/server/web/public')
|
||||
|
||||
const watch = process.argv.includes('--watch')
|
||||
const minify = process.argv.includes('--minify')
|
||||
|
||||
const buildOptions: esbuild.BuildOptions = {
|
||||
entryPoints: [ENTRY],
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
target: ['es2020', 'chrome90', 'firefox90', 'safari14'],
|
||||
format: 'esm',
|
||||
outdir: OUT_DIR,
|
||||
// CSS imported from JS is auto-emitted alongside the JS output
|
||||
loader: { '.css': 'css' },
|
||||
minify,
|
||||
sourcemap: minify ? false : 'inline',
|
||||
tsconfig: resolve(ROOT, 'src/server/web/tsconfig.json'),
|
||||
logLevel: 'info',
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (watch) {
|
||||
const ctx = await esbuild.context(buildOptions)
|
||||
await ctx.watch()
|
||||
console.log('Watching src/server/web/terminal.ts...')
|
||||
} else {
|
||||
const start = Date.now()
|
||||
const result = await esbuild.build(buildOptions)
|
||||
if (result.errors.length > 0) {
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Web build complete in ${Date.now() - start}ms → ${OUT_DIR}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
18
scripts/bun-plugin-shims.ts
Normal file
18
scripts/bun-plugin-shims.ts
Normal file
@@ -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,
|
||||
}))
|
||||
},
|
||||
})
|
||||
49
scripts/ci-build.sh
Normal file
49
scripts/ci-build.sh
Normal file
@@ -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 ==="
|
||||
15
scripts/dev.ts
Normal file
15
scripts/dev.ts
Normal file
@@ -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')
|
||||
91
scripts/package-npm.ts
Normal file
91
scripts/package-npm.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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'
|
||||
|
||||
// Bun: import.meta.dir — Node 21+: import.meta.dirname — fallback
|
||||
const __dir: string =
|
||||
(import.meta as ImportMeta & { dir?: string; dirname?: string }).dir ??
|
||||
(import.meta as ImportMeta & { dir?: string; dirname?: string }).dirname ??
|
||||
new URL('.', import.meta.url).pathname
|
||||
|
||||
const ROOT = resolve(__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()
|
||||
26
scripts/test-auth.ts
Normal file
26
scripts/test-auth.ts
Normal file
@@ -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()
|
||||
64
scripts/test-commands.ts
Normal file
64
scripts/test-commands.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// scripts/test-commands.ts
|
||||
// Verify all commands load without errors
|
||||
// Usage: bun scripts/test-commands.ts
|
||||
//
|
||||
// The bun:bundle shim is loaded automatically via bunfig.toml preload.
|
||||
|
||||
// Load MACRO global before any app code
|
||||
import '../src/shims/macro.js'
|
||||
|
||||
async function main() {
|
||||
const { getCommands } = await import('../src/commands.js')
|
||||
|
||||
const cwd = process.cwd()
|
||||
const commands = await getCommands(cwd)
|
||||
|
||||
console.log(`Loaded ${commands.length} commands:\n`)
|
||||
|
||||
// Group commands by type for readability
|
||||
const byType: Record<string, typeof commands> = {}
|
||||
for (const cmd of commands) {
|
||||
const t = cmd.type
|
||||
if (!byType[t]) byType[t] = []
|
||||
byType[t]!.push(cmd)
|
||||
}
|
||||
|
||||
for (const [type, cmds] of Object.entries(byType)) {
|
||||
console.log(` [${type}] (${cmds.length} commands)`)
|
||||
for (const cmd of cmds) {
|
||||
const aliases = cmd.aliases?.length ? ` (aliases: ${cmd.aliases.join(', ')})` : ''
|
||||
const hidden = cmd.isHidden ? ' [hidden]' : ''
|
||||
const source = cmd.type === 'prompt' ? ` (source: ${cmd.source})` : ''
|
||||
console.log(` /${cmd.name} — ${cmd.description || '(no description)'}${aliases}${hidden}${source}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
// Verify essential commands are present
|
||||
const essential = ['help', 'config', 'init', 'commit', 'review']
|
||||
const commandNames = new Set(commands.map(c => c.name))
|
||||
const missing = essential.filter(n => !commandNames.has(n))
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`❌ Missing essential commands: ${missing.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`✅ All ${essential.length} essential commands present: ${essential.join(', ')}`)
|
||||
|
||||
// Check moved-to-plugin commands
|
||||
const movedToPlugin = commands.filter(
|
||||
c => c.type === 'prompt' && c.description && c.name
|
||||
).filter(c => ['security-review', 'pr-comments'].includes(c.name))
|
||||
|
||||
if (movedToPlugin.length > 0) {
|
||||
console.log(`✅ Moved-to-plugin commands present and loadable: ${movedToPlugin.map(c => c.name).join(', ')}`)
|
||||
}
|
||||
|
||||
console.log(`\n✅ Command system loaded successfully (${commands.length} commands)`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ Command loading failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
180
scripts/test-mcp.ts
Normal file
180
scripts/test-mcp.ts
Normal file
@@ -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<string, string>,
|
||||
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);
|
||||
});
|
||||
233
scripts/test-services.ts
Normal file
233
scripts/test-services.ts
Normal file
@@ -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)
|
||||
})
|
||||
15
scripts/tsconfig.json
Normal file
15
scripts/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./types.d.ts"]
|
||||
}
|
||||
86
scripts/types.d.ts
vendored
Normal file
86
scripts/types.d.ts
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
// Local type declarations for scripts/ — avoids depending on installed packages
|
||||
// for type checking in build scripts.
|
||||
|
||||
// ── esbuild (minimal surface used by build-bundle.ts) ──
|
||||
declare module 'esbuild' {
|
||||
export interface Plugin {
|
||||
name: string
|
||||
setup(build: PluginBuild): void
|
||||
}
|
||||
|
||||
export interface PluginBuild {
|
||||
onResolve(
|
||||
options: { filter: RegExp },
|
||||
callback: (args: OnResolveArgs) => OnResolveResult | undefined | null,
|
||||
): void
|
||||
}
|
||||
|
||||
export interface OnResolveArgs {
|
||||
path: string
|
||||
importer: string
|
||||
namespace: string
|
||||
resolveDir: string
|
||||
kind: string
|
||||
pluginData: unknown
|
||||
}
|
||||
|
||||
export interface OnResolveResult {
|
||||
path?: string
|
||||
external?: boolean
|
||||
namespace?: string
|
||||
pluginData?: unknown
|
||||
}
|
||||
|
||||
export interface BuildOptions {
|
||||
entryPoints?: string[]
|
||||
bundle?: boolean
|
||||
platform?: string
|
||||
target?: string[]
|
||||
format?: string
|
||||
outdir?: string
|
||||
outExtension?: Record<string, string>
|
||||
splitting?: boolean
|
||||
plugins?: Plugin[]
|
||||
tsconfig?: string
|
||||
alias?: Record<string, string>
|
||||
external?: string[]
|
||||
jsx?: string
|
||||
sourcemap?: boolean | string
|
||||
minify?: boolean
|
||||
treeShaking?: boolean
|
||||
define?: Record<string, string>
|
||||
banner?: Record<string, string>
|
||||
resolveExtensions?: string[]
|
||||
logLevel?: string
|
||||
metafile?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Metafile {
|
||||
inputs: Record<string, { bytes: number; imports: unknown[] }>
|
||||
outputs: Record<string, { bytes: number; inputs: unknown[]; exports: string[] }>
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
errors: { text: string }[]
|
||||
warnings: { text: string }[]
|
||||
metafile?: Metafile
|
||||
}
|
||||
|
||||
export interface BuildContext {
|
||||
watch(): Promise<void>
|
||||
serve(options?: unknown): Promise<unknown>
|
||||
rebuild(): Promise<BuildResult>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
export function build(options: BuildOptions): Promise<BuildResult>
|
||||
export function context(options: BuildOptions): Promise<BuildContext>
|
||||
export function analyzeMetafile(metafile: Metafile, options?: { verbose?: boolean }): Promise<string>
|
||||
}
|
||||
|
||||
// ── Bun's ImportMeta extensions ──
|
||||
interface ImportMeta {
|
||||
dir: string
|
||||
dirname: string
|
||||
}
|
||||
16
server.json
16
server.json
@@ -1,4 +1,4 @@
|
||||
{
|
||||
cl{
|
||||
"$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",
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
66
src/bridge/stub.ts
Normal file
66
src/bridge/stub.ts
Normal file
@@ -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() {},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
22
src/constants/querySource.ts
Normal file
22
src/constants/querySource.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* QuerySource identifies where a query originated from.
|
||||
* Used for analytics, retry logic, and cache control decisions.
|
||||
*/
|
||||
export type QuerySource =
|
||||
| 'repl_main_thread'
|
||||
| 'sdk'
|
||||
| 'compact'
|
||||
| 'side_question'
|
||||
| 'agent'
|
||||
| 'agent:custom'
|
||||
| 'agent:explore'
|
||||
| 'agent:plan'
|
||||
| 'tool_use_summary'
|
||||
| 'advisor'
|
||||
| 'hook'
|
||||
| 'session_memory'
|
||||
| 'magic_docs'
|
||||
| 'skill_search'
|
||||
| 'classifier'
|
||||
| 'bridge'
|
||||
| (string & {}) // Allow other string values for extensibility
|
||||
37
src/query/transitions.ts
Normal file
37
src/query/transitions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Transition types for the query loop.
|
||||
*
|
||||
* Terminal: why the loop exited (returned).
|
||||
* Continue: why the loop continued to the next iteration (not returned).
|
||||
*/
|
||||
|
||||
/** Terminal transition — the query loop returned. */
|
||||
export type Terminal = {
|
||||
reason:
|
||||
| 'completed'
|
||||
| 'blocking_limit'
|
||||
| 'image_error'
|
||||
| 'model_error'
|
||||
| 'aborted_streaming'
|
||||
| 'aborted_tools'
|
||||
| 'prompt_too_long'
|
||||
| 'stop_hook_prevented'
|
||||
| 'hook_stopped'
|
||||
| 'max_turns'
|
||||
| (string & {})
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
/** Continue transition — the loop will iterate again. */
|
||||
export type Continue = {
|
||||
reason:
|
||||
| 'tool_use'
|
||||
| 'reactive_compact_retry'
|
||||
| 'max_output_tokens_recovery'
|
||||
| 'max_output_tokens_escalate'
|
||||
| 'collapse_drain_retry'
|
||||
| 'stop_hook_blocking'
|
||||
| 'token_budget_continuation'
|
||||
| 'queued_command'
|
||||
| (string & {})
|
||||
}
|
||||
76
src/server/web/__tests__/auth.test.ts
Normal file
76
src/server/web/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { ConnectionRateLimiter, validateAuthToken } from "../auth.js";
|
||||
|
||||
function mockReq(url: string, host = "localhost:3000"): IncomingMessage {
|
||||
return { url, headers: { host } } as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
describe("validateAuthToken", () => {
|
||||
const originalEnv = process.env.AUTH_TOKEN;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.AUTH_TOKEN;
|
||||
} else {
|
||||
process.env.AUTH_TOKEN = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("allows all connections when AUTH_TOKEN is not set", () => {
|
||||
delete process.env.AUTH_TOKEN;
|
||||
assert.equal(validateAuthToken(mockReq("/ws")), true);
|
||||
});
|
||||
|
||||
it("rejects connections without token when AUTH_TOKEN is set", () => {
|
||||
process.env.AUTH_TOKEN = "secret123";
|
||||
assert.equal(validateAuthToken(mockReq("/ws")), false);
|
||||
});
|
||||
|
||||
it("rejects connections with wrong token", () => {
|
||||
process.env.AUTH_TOKEN = "secret123";
|
||||
assert.equal(validateAuthToken(mockReq("/ws?token=wrong")), false);
|
||||
});
|
||||
|
||||
it("accepts connections with correct token", () => {
|
||||
process.env.AUTH_TOKEN = "secret123";
|
||||
assert.equal(validateAuthToken(mockReq("/ws?token=secret123")), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionRateLimiter", () => {
|
||||
it("allows connections under the limit", () => {
|
||||
const limiter = new ConnectionRateLimiter(3, 60_000);
|
||||
assert.equal(limiter.allow("1.2.3.4"), true);
|
||||
assert.equal(limiter.allow("1.2.3.4"), true);
|
||||
assert.equal(limiter.allow("1.2.3.4"), true);
|
||||
});
|
||||
|
||||
it("blocks connections over the limit", () => {
|
||||
const limiter = new ConnectionRateLimiter(2, 60_000);
|
||||
assert.equal(limiter.allow("1.2.3.4"), true);
|
||||
assert.equal(limiter.allow("1.2.3.4"), true);
|
||||
assert.equal(limiter.allow("1.2.3.4"), false);
|
||||
});
|
||||
|
||||
it("tracks IPs independently", () => {
|
||||
const limiter = new ConnectionRateLimiter(1, 60_000);
|
||||
assert.equal(limiter.allow("1.2.3.4"), true);
|
||||
assert.equal(limiter.allow("5.6.7.8"), true);
|
||||
assert.equal(limiter.allow("1.2.3.4"), false);
|
||||
assert.equal(limiter.allow("5.6.7.8"), false);
|
||||
});
|
||||
|
||||
it("cleans up stale entries", () => {
|
||||
const limiter = new ConnectionRateLimiter(1, 1); // 1ms window
|
||||
limiter.allow("1.2.3.4");
|
||||
// Wait for window to expire
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 5) {
|
||||
// busy wait 5ms
|
||||
}
|
||||
limiter.cleanup();
|
||||
assert.equal(limiter.allow("1.2.3.4"), true);
|
||||
});
|
||||
});
|
||||
159
src/server/web/__tests__/session-manager.test.ts
Normal file
159
src/server/web/__tests__/session-manager.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it, mock } from "node:test";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { SessionManager } from "../session-manager.js";
|
||||
import type { IPty } from "node-pty";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
// --- Mock factories ---
|
||||
|
||||
function createMockPty(): IPty & { _dataHandler?: (d: string) => void; _exitHandler?: (e: { exitCode: number; signal: number }) => void } {
|
||||
const mockPty = {
|
||||
onData(handler: (data: string) => void) {
|
||||
mockPty._dataHandler = handler;
|
||||
return { dispose() {} };
|
||||
},
|
||||
onExit(handler: (e: { exitCode: number; signal: number }) => void) {
|
||||
mockPty._exitHandler = handler;
|
||||
return { dispose() {} };
|
||||
},
|
||||
write: mock.fn(),
|
||||
resize: mock.fn(),
|
||||
kill: mock.fn(),
|
||||
pid: 12345,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
process: "claude",
|
||||
handleFlowControl: false,
|
||||
pause: mock.fn(),
|
||||
resume: mock.fn(),
|
||||
clear: mock.fn(),
|
||||
_dataHandler: undefined as ((d: string) => void) | undefined,
|
||||
_exitHandler: undefined as ((e: { exitCode: number; signal: number }) => void) | undefined,
|
||||
};
|
||||
return mockPty as unknown as IPty & { _dataHandler?: (d: string) => void; _exitHandler?: (e: { exitCode: number; signal: number }) => void };
|
||||
}
|
||||
|
||||
function createMockWs(): WebSocket & EventEmitter {
|
||||
const emitter = new EventEmitter();
|
||||
const ws = Object.assign(emitter, {
|
||||
OPEN: 1,
|
||||
CONNECTING: 0,
|
||||
readyState: 1,
|
||||
send: mock.fn(),
|
||||
close: mock.fn(),
|
||||
});
|
||||
return ws as unknown as WebSocket & EventEmitter;
|
||||
}
|
||||
|
||||
describe("SessionManager", () => {
|
||||
it("creates a session and tracks it", () => {
|
||||
const mockPty = createMockPty();
|
||||
const manager = new SessionManager(5, () => mockPty);
|
||||
const ws = createMockWs();
|
||||
|
||||
const session = manager.create(ws);
|
||||
assert.ok(session);
|
||||
assert.equal(manager.activeCount, 1);
|
||||
assert.ok(session.id);
|
||||
assert.equal(session.ws, ws);
|
||||
assert.equal(session.pty, mockPty);
|
||||
});
|
||||
|
||||
it("enforces max sessions limit", () => {
|
||||
const manager = new SessionManager(1, () => createMockPty());
|
||||
|
||||
const session1 = manager.create(createMockWs());
|
||||
assert.ok(session1);
|
||||
|
||||
const ws2 = createMockWs();
|
||||
const session2 = manager.create(ws2);
|
||||
assert.equal(session2, null);
|
||||
assert.equal(manager.activeCount, 1);
|
||||
});
|
||||
|
||||
it("forwards PTY data to WebSocket", () => {
|
||||
const mockPty = createMockPty();
|
||||
const manager = new SessionManager(5, () => mockPty);
|
||||
const ws = createMockWs();
|
||||
|
||||
manager.create(ws);
|
||||
|
||||
// Simulate PTY output
|
||||
mockPty._dataHandler?.("hello world");
|
||||
assert.equal((ws.send as ReturnType<typeof mock.fn>).mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("forwards WebSocket input to PTY", () => {
|
||||
const mockPty = createMockPty();
|
||||
const manager = new SessionManager(5, () => mockPty);
|
||||
const ws = createMockWs();
|
||||
|
||||
manager.create(ws);
|
||||
|
||||
// Simulate WebSocket input
|
||||
ws.emit("message", Buffer.from("ls\n"));
|
||||
assert.equal((mockPty.write as ReturnType<typeof mock.fn>).mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("handles resize messages", () => {
|
||||
const mockPty = createMockPty();
|
||||
const manager = new SessionManager(5, () => mockPty);
|
||||
const ws = createMockWs();
|
||||
|
||||
manager.create(ws);
|
||||
|
||||
ws.emit("message", JSON.stringify({ type: "resize", cols: 120, rows: 40 }));
|
||||
assert.equal((mockPty.resize as ReturnType<typeof mock.fn>).mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("handles ping messages with pong response", () => {
|
||||
const mockPty = createMockPty();
|
||||
const manager = new SessionManager(5, () => mockPty);
|
||||
const ws = createMockWs();
|
||||
|
||||
manager.create(ws);
|
||||
|
||||
ws.emit("message", JSON.stringify({ type: "ping" }));
|
||||
// Should have sent connected + pong
|
||||
const calls = (ws.send as ReturnType<typeof mock.fn>).mock.calls;
|
||||
const lastCall = calls[calls.length - 1];
|
||||
assert.ok(lastCall);
|
||||
const parsed = JSON.parse(lastCall.arguments[0] as string);
|
||||
assert.equal(parsed.type, "pong");
|
||||
});
|
||||
|
||||
it("cleans up session on WebSocket close", () => {
|
||||
const mockPty = createMockPty();
|
||||
const manager = new SessionManager(5, () => mockPty);
|
||||
const ws = createMockWs();
|
||||
|
||||
manager.create(ws);
|
||||
assert.equal(manager.activeCount, 1);
|
||||
|
||||
ws.emit("close");
|
||||
assert.equal(manager.activeCount, 0);
|
||||
});
|
||||
|
||||
it("handles PTY spawn failure gracefully", () => {
|
||||
const manager = new SessionManager(5, () => {
|
||||
throw new Error("no pty available");
|
||||
});
|
||||
const ws = createMockWs();
|
||||
|
||||
const session = manager.create(ws);
|
||||
assert.equal(session, null);
|
||||
assert.equal((ws.close as ReturnType<typeof mock.fn>).mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("destroyAll cleans up all sessions", () => {
|
||||
const manager = new SessionManager(5, () => createMockPty());
|
||||
|
||||
manager.create(createMockWs());
|
||||
manager.create(createMockWs());
|
||||
assert.equal(manager.activeCount, 2);
|
||||
|
||||
manager.destroyAll();
|
||||
assert.equal(manager.activeCount, 0);
|
||||
});
|
||||
});
|
||||
188
src/server/web/admin.ts
Normal file
188
src/server/web/admin.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Router } from "express";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import type { Request, Response } from "express";
|
||||
import type { AuthenticatedRequest } from "./auth/adapter.js";
|
||||
import type { SessionManager } from "./session-manager.js";
|
||||
import type { UserStore } from "./user-store.js";
|
||||
|
||||
/**
|
||||
* Admin dashboard routes.
|
||||
*
|
||||
* All routes under /admin require the requesting user to have isAdmin = true.
|
||||
* The caller (pty-server.ts) is responsible for applying the auth middleware
|
||||
* before mounting this router.
|
||||
*/
|
||||
|
||||
function requireAdmin(req: Request, res: Response, next: () => void): void {
|
||||
const user = (req as AuthenticatedRequest).user;
|
||||
if (!user?.isAdmin) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function createAdminRouter(
|
||||
sessionManager: SessionManager,
|
||||
userStore: UserStore,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// All admin routes require admin role.
|
||||
router.use(requireAdmin);
|
||||
|
||||
// ── Dashboard UI ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
try {
|
||||
const p = join(import.meta.dirname, "public/admin.html");
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.send(readFileSync(p, "utf8"));
|
||||
} catch {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.send(INLINE_ADMIN_HTML);
|
||||
}
|
||||
});
|
||||
|
||||
// ── API: all active sessions ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /admin/sessions
|
||||
* Returns all active sessions across all users.
|
||||
*/
|
||||
router.get("/sessions", (_req, res) => {
|
||||
const sessions = sessionManager.getAllSessions().map((s) => ({
|
||||
id: s.token,
|
||||
userId: s.userId,
|
||||
createdAt: s.created,
|
||||
ageMs: Date.now() - new Date(s.created).getTime(),
|
||||
alive: s.alive,
|
||||
}));
|
||||
res.json({ sessions });
|
||||
});
|
||||
|
||||
// ── API: all connected users ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /admin/users
|
||||
* Returns all users that currently have at least one active session.
|
||||
*/
|
||||
router.get("/users", (_req, res) => {
|
||||
res.json({ users: userStore.list() });
|
||||
});
|
||||
|
||||
// ── API: force-kill a session ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* DELETE /admin/sessions/:token
|
||||
* Force-kills the specified session regardless of which user owns it.
|
||||
*/
|
||||
router.delete("/sessions/:token", (req, res) => {
|
||||
const { token } = req.params;
|
||||
const session = sessionManager.getSession(token);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
sessionManager.destroySession(token);
|
||||
res.json({ ok: true, destroyed: token });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ── Inline admin dashboard HTML ───────────────────────────────────────────────
|
||||
|
||||
const INLINE_ADMIN_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Claude Code — Admin</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 2rem; }
|
||||
h2 { font-size: 1rem; margin: 1.5rem 0 0.75rem; color: #8b949e; text-transform: uppercase; letter-spacing: .05em; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
th { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid #21262d; color: #8b949e; font-weight: 500; }
|
||||
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #161b22; }
|
||||
tr:hover td { background: #161b22; }
|
||||
button.kill {
|
||||
background: #da3633; border: 1px solid #f85149; color: #fff;
|
||||
padding: 0.2rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
button.kill:hover { background: #f85149; }
|
||||
.badge {
|
||||
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px;
|
||||
font-size: 0.75rem; background: #21262d; color: #8b949e;
|
||||
}
|
||||
.refresh { float: right; background: #21262d; border: 1px solid #30363d; color: #8b949e;
|
||||
padding: 0.3rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
|
||||
.refresh:hover { color: #e6edf3; }
|
||||
#msg { margin-top: 1rem; font-size: 0.875rem; color: #3fb950; min-height: 1.2em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Dashboard</h1>
|
||||
<p class="subtitle">Claude Code — multi-user session management</p>
|
||||
|
||||
<button class="refresh" onclick="load()">↻ Refresh</button>
|
||||
|
||||
<h2>Connected Users</h2>
|
||||
<table id="users-table">
|
||||
<thead><tr><th>User ID</th><th>Email / Name</th><th>Sessions</th><th>First seen</th></tr></thead>
|
||||
<tbody id="users-body"><tr><td colspan="4">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<h2>Active Sessions</h2>
|
||||
<table id="sessions-table">
|
||||
<thead><tr><th>Session ID</th><th>User ID</th><th>Age</th><th>Action</th></tr></thead>
|
||||
<tbody id="sessions-body"><tr><td colspan="4">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<div id="msg"></div>
|
||||
|
||||
<script>
|
||||
const msg = document.getElementById('msg');
|
||||
function fmt(ms) {
|
||||
if (ms < 60000) return Math.round(ms/1000) + 's';
|
||||
if (ms < 3600000) return Math.round(ms/60000) + 'm';
|
||||
return Math.round(ms/3600000) + 'h';
|
||||
}
|
||||
async function load() {
|
||||
const [{ users }, { sessions }] = await Promise.all([
|
||||
fetch('/admin/users').then(r => r.json()),
|
||||
fetch('/admin/sessions').then(r => r.json()),
|
||||
]);
|
||||
const ub = document.getElementById('users-body');
|
||||
ub.innerHTML = users.length === 0 ? '<tr><td colspan="4">No connected users</td></tr>' :
|
||||
users.map(u => \`<tr>
|
||||
<td><code>\${u.id}</code></td>
|
||||
<td>\${u.email || u.name || '—'}</td>
|
||||
<td><span class="badge">\${u.sessionCount}</span></td>
|
||||
<td>\${new Date(u.firstSeenAt).toLocaleTimeString()}</td>
|
||||
</tr>\`).join('');
|
||||
const sb = document.getElementById('sessions-body');
|
||||
sb.innerHTML = sessions.length === 0 ? '<tr><td colspan="4">No active sessions</td></tr>' :
|
||||
sessions.map(s => \`<tr>
|
||||
<td><code>\${s.id.slice(0,8)}…</code></td>
|
||||
<td><code>\${s.userId}</code></td>
|
||||
<td>\${fmt(s.ageMs)}</td>
|
||||
<td><button class="kill" onclick="kill('\${s.id}')">Kill</button></td>
|
||||
</tr>\`).join('');
|
||||
}
|
||||
async function kill(id) {
|
||||
if (!confirm('Kill session ' + id.slice(0,8) + '…?')) return;
|
||||
const r = await fetch('/admin/sessions/' + id, { method: 'DELETE' });
|
||||
const j = await r.json();
|
||||
msg.textContent = j.ok ? 'Session ' + id.slice(0,8) + '… destroyed.' : j.error;
|
||||
load();
|
||||
}
|
||||
load();
|
||||
setInterval(load, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
65
src/server/web/auth.ts
Normal file
65
src/server/web/auth.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
/**
|
||||
* Validates the auth token from a WebSocket upgrade request.
|
||||
* If AUTH_TOKEN is not set, all connections are allowed.
|
||||
*/
|
||||
export function validateAuthToken(req: IncomingMessage): boolean {
|
||||
const authToken = process.env.AUTH_TOKEN;
|
||||
if (!authToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const token = url.searchParams.get("token");
|
||||
return token === authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple per-IP rate limiter for new connections.
|
||||
*/
|
||||
export class ConnectionRateLimiter {
|
||||
private attempts = new Map<string, number[]>();
|
||||
private readonly maxPerWindow: number;
|
||||
private readonly windowMs: number;
|
||||
|
||||
constructor(maxPerWindow = 5, windowMs = 60_000) {
|
||||
this.maxPerWindow = maxPerWindow;
|
||||
this.windowMs = windowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the connection should be allowed.
|
||||
*/
|
||||
allow(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const timestamps = this.attempts.get(ip) ?? [];
|
||||
|
||||
// Prune old entries
|
||||
const recent = timestamps.filter((t) => now - t < this.windowMs);
|
||||
|
||||
if (recent.length >= this.maxPerWindow) {
|
||||
this.attempts.set(ip, recent);
|
||||
return false;
|
||||
}
|
||||
|
||||
recent.push(now);
|
||||
this.attempts.set(ip, recent);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically clean up stale entries.
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [ip, timestamps] of this.attempts) {
|
||||
const recent = timestamps.filter((t) => now - t < this.windowMs);
|
||||
if (recent.length === 0) {
|
||||
this.attempts.delete(ip);
|
||||
} else {
|
||||
this.attempts.set(ip, recent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
src/server/web/auth/adapter.ts
Normal file
220
src/server/web/auth/adapter.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { createHmac, createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import type { Application, Request, Response, NextFunction } from "express";
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
isAdmin: boolean;
|
||||
/** Decrypted Anthropic API key (only present for apikey auth provider) */
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
userId: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
isAdmin: boolean;
|
||||
/** AES-256-GCM encrypted Anthropic API key */
|
||||
encryptedApiKey?: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Augmented Express request with authenticated user attached. */
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
/** Auth adapter — pluggable authentication strategy. */
|
||||
export interface AuthAdapter {
|
||||
/**
|
||||
* Authenticate an IncomingMessage (HTTP or WebSocket upgrade).
|
||||
* Returns null when the request is unauthenticated.
|
||||
*/
|
||||
authenticate(req: IncomingMessage): AuthUser | null;
|
||||
|
||||
/**
|
||||
* Register login/callback/logout routes on the Express app.
|
||||
* Called once during server startup before any requests arrive.
|
||||
*/
|
||||
setupRoutes(app: Application): void;
|
||||
|
||||
/**
|
||||
* Express middleware that rejects unauthenticated requests.
|
||||
* For browser clients it redirects to /auth/login; for API clients it
|
||||
* returns 401 JSON.
|
||||
*/
|
||||
requireAuth(req: Request, res: Response, next: NextFunction): void;
|
||||
}
|
||||
|
||||
// ── Cookie helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const COOKIE_NAME = "cc_session";
|
||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
|
||||
|
||||
function parseCookies(header: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = part.slice(0, eq).trim();
|
||||
const val = part.slice(eq + 1).trim();
|
||||
if (key) out[key] = decodeURIComponent(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── SessionStore ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* In-memory server-side session store.
|
||||
*
|
||||
* Sessions are identified by a random UUID stored in a signed HttpOnly cookie.
|
||||
* Sensitive values (API keys) are stored encrypted with AES-256-GCM.
|
||||
*/
|
||||
export class SessionStore {
|
||||
private readonly sessions = new Map<string, SessionData>();
|
||||
/** 32-byte key derived from the session secret. */
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(secret: string) {
|
||||
// Derive a stable 32-byte key so the same secret always produces the
|
||||
// same key (important if the process restarts while cookies are live).
|
||||
const hmac = createHmac("sha256", secret);
|
||||
hmac.update("cc-session-key-v1");
|
||||
this.key = hmac.digest();
|
||||
|
||||
// Purge expired sessions every 5 minutes.
|
||||
setInterval(() => this.cleanup(), 5 * 60_000).unref();
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
create(data: Omit<SessionData, "createdAt" | "expiresAt">): string {
|
||||
const id = crypto.randomUUID();
|
||||
this.sessions.set(id, {
|
||||
...data,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
get(id: string): SessionData | undefined {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return undefined;
|
||||
if (Date.now() > s.expiresAt) {
|
||||
this.sessions.delete(id);
|
||||
return undefined;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
|
||||
// ── Cookie signing ────────────────────────────────────────────────────────
|
||||
|
||||
sign(id: string): string {
|
||||
const hmac = createHmac("sha256", this.key);
|
||||
hmac.update(id);
|
||||
return `${id}.${hmac.digest("base64url")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the HMAC and returns the raw session ID, or null on failure.
|
||||
* Uses constant-time comparison to prevent timing attacks.
|
||||
*/
|
||||
unsign(signed: string): string | null {
|
||||
const dot = signed.lastIndexOf(".");
|
||||
if (dot === -1) return null;
|
||||
const id = signed.slice(0, dot);
|
||||
const provided = signed.slice(dot + 1);
|
||||
|
||||
const hmac = createHmac("sha256", this.key);
|
||||
hmac.update(id);
|
||||
const expected = hmac.digest("base64url");
|
||||
|
||||
if (provided.length !== expected.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < provided.length; i++) {
|
||||
diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||
}
|
||||
return diff === 0 ? id : null;
|
||||
}
|
||||
|
||||
// ── Request / response helpers ────────────────────────────────────────────
|
||||
|
||||
/** Returns the session data for the current request, or null. */
|
||||
getFromRequest(req: IncomingMessage): SessionData | null {
|
||||
const cookies = parseCookies(req.headers.cookie ?? "");
|
||||
const signed = cookies[COOKIE_NAME];
|
||||
if (!signed) return null;
|
||||
const id = this.unsign(signed);
|
||||
if (!id) return null;
|
||||
return this.get(id) ?? null;
|
||||
}
|
||||
|
||||
/** Returns the raw session ID from the request cookie, or null. */
|
||||
getIdFromRequest(req: IncomingMessage): string | null {
|
||||
const cookies = parseCookies(req.headers.cookie ?? "");
|
||||
const signed = cookies[COOKIE_NAME];
|
||||
if (!signed) return null;
|
||||
return this.unsign(signed);
|
||||
}
|
||||
|
||||
setCookie(res: ServerResponse, sessionId: string): void {
|
||||
const signed = this.sign(sessionId);
|
||||
const maxAge = Math.floor(SESSION_TTL_MS / 1000);
|
||||
res.setHeader("Set-Cookie", [
|
||||
`${COOKIE_NAME}=${encodeURIComponent(signed)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`,
|
||||
]);
|
||||
}
|
||||
|
||||
clearCookie(res: ServerResponse): void {
|
||||
res.setHeader("Set-Cookie", [
|
||||
`${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Encryption ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Encrypts a plaintext string with AES-256-GCM for session storage. */
|
||||
encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Layout: iv(12) | tag(16) | ciphertext
|
||||
return Buffer.concat([iv, tag, ciphertext]).toString("base64url");
|
||||
}
|
||||
|
||||
/** Decrypts a value produced by {@link encrypt}. Returns null on failure. */
|
||||
decrypt(encoded: string): string | null {
|
||||
try {
|
||||
const buf = Buffer.from(encoded, "base64url");
|
||||
const iv = buf.subarray(0, 12);
|
||||
const tag = buf.subarray(12, 28);
|
||||
const ciphertext = buf.subarray(28);
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (now > s.expiresAt) this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/server/web/auth/apikey-auth.ts
Normal file
230
src/server/web/auth/apikey-auth.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { createHash } from "crypto";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Application, Request, Response, NextFunction } from "express";
|
||||
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
|
||||
import { SessionStore } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* API-key authentication adapter.
|
||||
*
|
||||
* Each user provides their own Anthropic API key on the login page.
|
||||
* The key is stored encrypted in the server-side session and is injected
|
||||
* as `ANTHROPIC_API_KEY` into every PTY spawned for that user.
|
||||
* The plaintext key is never sent to the browser after the login form POST.
|
||||
*
|
||||
* User identity is derived from the key itself (SHA-256 prefix), so two
|
||||
* sessions using the same key share the same userId and home directory.
|
||||
*
|
||||
* Optional env vars:
|
||||
* ADMIN_USERS — comma-separated user IDs (SHA-256 prefixes) or API-key
|
||||
* prefixes that receive the admin role
|
||||
*/
|
||||
export class ApiKeyAdapter implements AuthAdapter {
|
||||
private readonly store: SessionStore;
|
||||
private readonly adminUsers: ReadonlySet<string>;
|
||||
|
||||
constructor(store: SessionStore) {
|
||||
this.store = store;
|
||||
this.adminUsers = new Set(
|
||||
(process.env.ADMIN_USERS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
authenticate(req: IncomingMessage): AuthUser | null {
|
||||
const session = this.store.getFromRequest(req);
|
||||
if (!session || !session.encryptedApiKey) return null;
|
||||
|
||||
const apiKey = this.store.decrypt(session.encryptedApiKey);
|
||||
if (!apiKey) return null;
|
||||
|
||||
return {
|
||||
id: session.userId,
|
||||
email: session.email,
|
||||
name: session.name,
|
||||
isAdmin:
|
||||
session.isAdmin ||
|
||||
this.adminUsers.has(session.userId),
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
setupRoutes(app: Application): void {
|
||||
const loginHtml = this.loadLoginPage();
|
||||
|
||||
// GET /auth/login — serve the API key login form
|
||||
app.get("/auth/login", (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.send(loginHtml);
|
||||
});
|
||||
|
||||
// POST /auth/login — validate key, create encrypted session
|
||||
app.post(
|
||||
"/auth/login",
|
||||
// express.urlencoded is registered in pty-server.ts before setupRoutes
|
||||
(req: Request, res: Response) => {
|
||||
const apiKey = (req.body as Record<string, string>)?.api_key?.trim() ?? "";
|
||||
|
||||
if (!apiKey.startsWith("sk-ant-")) {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.status(400).send(
|
||||
loginHtml.replace(
|
||||
"<!--ERROR-->",
|
||||
`<p class="error">Invalid API key format. Keys must start with <code>sk-ant-</code>.</p>`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = deriveUserId(apiKey);
|
||||
const isAdmin = this.adminUsers.has(userId);
|
||||
const encryptedApiKey = this.store.encrypt(apiKey);
|
||||
|
||||
const sessionId = this.store.create({
|
||||
userId,
|
||||
isAdmin,
|
||||
encryptedApiKey,
|
||||
});
|
||||
|
||||
this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId);
|
||||
|
||||
const next = (req.query as Record<string, string>)?.next;
|
||||
res.redirect(next && next.startsWith("/") ? next : "/");
|
||||
},
|
||||
);
|
||||
|
||||
// POST /auth/logout — destroy session
|
||||
app.post("/auth/logout", (req, res) => {
|
||||
const id = this.store.getIdFromRequest(req as unknown as IncomingMessage);
|
||||
if (id) this.store.delete(id);
|
||||
this.store.clearCookie(res as unknown as import("http").ServerResponse);
|
||||
res.redirect("/auth/login");
|
||||
});
|
||||
}
|
||||
|
||||
requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const user = this.authenticate(req as unknown as IncomingMessage);
|
||||
if (!user) {
|
||||
const accept = req.headers["accept"] ?? "";
|
||||
if (accept.includes("application/json")) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
} else {
|
||||
res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
(req as AuthenticatedRequest).user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private loadLoginPage(): string {
|
||||
// Serve from the public directory at build time; fall back to inline HTML.
|
||||
try {
|
||||
const p = join(import.meta.dirname, "../public/login.html");
|
||||
return readFileSync(p, "utf8");
|
||||
} catch {
|
||||
return INLINE_LOGIN_HTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable, opaque user ID from an API key.
|
||||
* Uses the first 16 hex chars of SHA-256(key) — short enough to be readable,
|
||||
* long enough to be unique.
|
||||
*/
|
||||
function deriveUserId(apiKey: string): string {
|
||||
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
// Fallback inline login page used when public/login.html is not present.
|
||||
const INLINE_LOGIN_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Claude Code — Sign In</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
||||
p.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 1.5rem; }
|
||||
label { display: block; font-size: 0.875rem; margin-bottom: 0.4rem; color: #8b949e; }
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #2ea043; }
|
||||
.error { color: #f85149; font-size: 0.875rem; margin-bottom: 1rem; }
|
||||
code { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.8rem; }
|
||||
.hint { color: #8b949e; font-size: 0.75rem; margin-top: 1rem; }
|
||||
.hint a { color: #58a6ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Claude Code</h1>
|
||||
<p class="subtitle">Enter your Anthropic API key to start a session.</p>
|
||||
<!--ERROR-->
|
||||
<form method="POST" action="/auth/login">
|
||||
<label for="api_key">Anthropic API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
placeholder="sk-ant-..."
|
||||
autocomplete="off"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Your key is stored encrypted on the server and never sent to the browser.
|
||||
Get a key at <a href="https://console.anthropic.com" target="_blank" rel="noopener">console.anthropic.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
276
src/server/web/auth/oauth-auth.ts
Normal file
276
src/server/web/auth/oauth-auth.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { request as nodeRequest } from "https";
|
||||
import { request as nodeHttpRequest } from "http";
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Application, Request, Response, NextFunction } from "express";
|
||||
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
|
||||
import { SessionStore } from "./adapter.js";
|
||||
|
||||
interface OIDCDiscovery {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
id_token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UserInfoResponse {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
}
|
||||
|
||||
/** Minimal JSON fetch over https/http. */
|
||||
function fetchJSON<T>(url: string, opts?: { method?: string; body?: string; headers?: Record<string, string> }): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const req = (isHttps ? nodeRequest : nodeHttpRequest)(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (isHttps ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: opts?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
...(opts?.headers ?? {}),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (c: Buffer) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")) as T);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (opts?.body) req.write(opts.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 / OIDC authentication adapter.
|
||||
*
|
||||
* Performs a server-side authorization code flow against any OIDC-compliant
|
||||
* identity provider (Google, GitHub, Okta, Auth0, …).
|
||||
*
|
||||
* Required env vars:
|
||||
* OAUTH_CLIENT_ID — client ID registered with the provider
|
||||
* OAUTH_CLIENT_SECRET — client secret
|
||||
* OAUTH_ISSUER — issuer base URL, e.g. https://accounts.google.com
|
||||
*
|
||||
* Optional:
|
||||
* OAUTH_CALLBACK_URL — full redirect URI (default: http://localhost:3000/auth/callback)
|
||||
* OAUTH_SCOPES — space-separated scopes (default: openid email profile)
|
||||
* ADMIN_USERS — comma-separated user IDs or emails that get admin role
|
||||
*/
|
||||
export class OAuthAdapter implements AuthAdapter {
|
||||
private readonly store: SessionStore;
|
||||
private readonly clientId: string;
|
||||
private readonly clientSecret: string;
|
||||
private readonly issuer: string;
|
||||
private readonly callbackUrl: string;
|
||||
private readonly scopes: string;
|
||||
private readonly adminUsers: ReadonlySet<string>;
|
||||
/** Cached OIDC discovery document. */
|
||||
private discovery: OIDCDiscovery | null = null;
|
||||
/** In-flight state tokens to prevent CSRF. */
|
||||
private readonly pendingStates = new Map<string, number>();
|
||||
|
||||
constructor(store: SessionStore) {
|
||||
this.store = store;
|
||||
this.clientId = process.env.OAUTH_CLIENT_ID ?? "";
|
||||
this.clientSecret = process.env.OAUTH_CLIENT_SECRET ?? "";
|
||||
this.issuer = (process.env.OAUTH_ISSUER ?? "").replace(/\/$/, "");
|
||||
this.callbackUrl =
|
||||
process.env.OAUTH_CALLBACK_URL ?? "http://localhost:3000/auth/callback";
|
||||
this.scopes = process.env.OAUTH_SCOPES ?? "openid email profile";
|
||||
this.adminUsers = new Set(
|
||||
(process.env.ADMIN_USERS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// Periodically prune stale state tokens (>10 min old).
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 10 * 60_000;
|
||||
for (const [s, t] of this.pendingStates) {
|
||||
if (t < cutoff) this.pendingStates.delete(s);
|
||||
}
|
||||
}, 5 * 60_000).unref();
|
||||
}
|
||||
|
||||
authenticate(req: IncomingMessage): AuthUser | null {
|
||||
const session = this.store.getFromRequest(req);
|
||||
if (!session) return null;
|
||||
return {
|
||||
id: session.userId,
|
||||
email: session.email,
|
||||
name: session.name,
|
||||
isAdmin:
|
||||
session.isAdmin ||
|
||||
this.adminUsers.has(session.userId) ||
|
||||
(session.email ? this.adminUsers.has(session.email) : false),
|
||||
};
|
||||
}
|
||||
|
||||
setupRoutes(app: Application): void {
|
||||
// GET /auth/login — redirect to the identity provider
|
||||
app.get("/auth/login", async (_req, res) => {
|
||||
try {
|
||||
const disc = await this.getDiscovery();
|
||||
const state = randomBytes(16).toString("hex");
|
||||
this.pendingStates.set(state, Date.now());
|
||||
|
||||
const authUrl = new URL(disc.authorization_endpoint);
|
||||
authUrl.searchParams.set("client_id", this.clientId);
|
||||
authUrl.searchParams.set("redirect_uri", this.callbackUrl);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("scope", this.scopes);
|
||||
authUrl.searchParams.set("state", state);
|
||||
|
||||
// Store state in a short-lived cookie for CSRF validation
|
||||
res.setHeader("Set-Cookie", [
|
||||
`oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=600`,
|
||||
]);
|
||||
res.redirect(authUrl.toString());
|
||||
} catch (err) {
|
||||
console.error("[oauth] Failed to initiate login:", err);
|
||||
res.status(500).send("Authentication provider unavailable.");
|
||||
}
|
||||
});
|
||||
|
||||
// GET /auth/callback — exchange code for tokens, create session
|
||||
app.get("/auth/callback", async (req, res) => {
|
||||
const { code, state, error } = req.query as Record<string, string | undefined>;
|
||||
|
||||
if (error) {
|
||||
console.warn("[oauth] Provider error:", error);
|
||||
res.status(400).send(`Provider returned error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.status(400).send("Missing code or state.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate state against cookie
|
||||
const storedState = this.extractStateCookie(req as unknown as IncomingMessage);
|
||||
if (!storedState || storedState !== state || !this.pendingStates.has(state)) {
|
||||
res.status(400).send("Invalid state parameter.");
|
||||
return;
|
||||
}
|
||||
this.pendingStates.delete(state);
|
||||
// Clear state cookie
|
||||
res.setHeader("Set-Cookie", [
|
||||
`oauth_state=; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=0`,
|
||||
]);
|
||||
|
||||
try {
|
||||
const disc = await this.getDiscovery();
|
||||
const tokens = await this.exchangeCode(disc.token_endpoint, code);
|
||||
const userInfo = await this.getUserInfo(disc.userinfo_endpoint, tokens.access_token);
|
||||
|
||||
const isAdmin =
|
||||
this.adminUsers.has(userInfo.sub) ||
|
||||
(userInfo.email ? this.adminUsers.has(userInfo.email) : false);
|
||||
|
||||
const sessionId = this.store.create({
|
||||
userId: userInfo.sub,
|
||||
email: userInfo.email,
|
||||
name: userInfo.name ?? userInfo.preferred_username,
|
||||
isAdmin,
|
||||
});
|
||||
|
||||
this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId);
|
||||
res.redirect("/");
|
||||
} catch (err) {
|
||||
console.error("[oauth] Callback error:", err);
|
||||
res.status(500).send("Authentication failed. Please try again.");
|
||||
}
|
||||
});
|
||||
|
||||
// POST /auth/logout — destroy session and redirect to login
|
||||
app.post("/auth/logout", (req, res) => {
|
||||
const id = this.store.getIdFromRequest(req as unknown as IncomingMessage);
|
||||
if (id) this.store.delete(id);
|
||||
this.store.clearCookie(res as unknown as import("http").ServerResponse);
|
||||
res.redirect("/auth/login");
|
||||
});
|
||||
}
|
||||
|
||||
requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const user = this.authenticate(req as unknown as IncomingMessage);
|
||||
if (!user) {
|
||||
const accept = req.headers["accept"] ?? "";
|
||||
if (accept.includes("application/json")) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
} else {
|
||||
res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
(req as AuthenticatedRequest).user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
// ── OIDC helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private async getDiscovery(): Promise<OIDCDiscovery> {
|
||||
if (this.discovery) return this.discovery;
|
||||
const url = `${this.issuer}/.well-known/openid-configuration`;
|
||||
const doc = await fetchJSON<OIDCDiscovery>(url);
|
||||
this.discovery = doc;
|
||||
return doc;
|
||||
}
|
||||
|
||||
private async exchangeCode(tokenEndpoint: string, code: string): Promise<TokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: this.callbackUrl,
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
}).toString();
|
||||
|
||||
const result = await fetchJSON<TokenResponse>(tokenEndpoint, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (result.error) throw new Error(`Token exchange failed: ${result.error}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getUserInfo(userinfoEndpoint: string, accessToken: string): Promise<UserInfoResponse> {
|
||||
return fetchJSON<UserInfoResponse>(userinfoEndpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
private extractStateCookie(req: IncomingMessage): string | null {
|
||||
const cookieHeader = req.headers.cookie ?? "";
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
if (part.slice(0, eq).trim() === "oauth_state") {
|
||||
return decodeURIComponent(part.slice(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
81
src/server/web/auth/token-auth.ts
Normal file
81
src/server/web/auth/token-auth.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Application, Request, Response, NextFunction } from "express";
|
||||
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* Token auth — the original single-token mode.
|
||||
*
|
||||
* If `AUTH_TOKEN` is set, callers must supply a matching token via the
|
||||
* `?token=` query parameter (WebSocket) or `Authorization: Bearer <token>`
|
||||
* header (HTTP). When `AUTH_TOKEN` is unset every caller is admitted as the
|
||||
* built-in "default" admin user.
|
||||
*
|
||||
* All callers share the same user identity. Use this provider for single-
|
||||
* user or trusted-network deployments where you just want a simple password.
|
||||
*/
|
||||
export class TokenAuthAdapter implements AuthAdapter {
|
||||
private readonly token: string | undefined;
|
||||
private readonly adminUsers: ReadonlySet<string>;
|
||||
|
||||
constructor() {
|
||||
this.token = process.env.AUTH_TOKEN;
|
||||
this.adminUsers = new Set(
|
||||
(process.env.ADMIN_USERS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
authenticate(req: IncomingMessage): AuthUser | null {
|
||||
if (!this.token) {
|
||||
return { id: "default", isAdmin: true };
|
||||
}
|
||||
if (this.extractToken(req) === this.token) {
|
||||
return {
|
||||
id: "default",
|
||||
isAdmin: this.adminUsers.size === 0 || this.adminUsers.has("default"),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setupRoutes(_app: Application): void {
|
||||
// Token auth needs no login/callback/logout routes.
|
||||
}
|
||||
|
||||
requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const user = this.authenticate(req as unknown as IncomingMessage);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
(req as AuthenticatedRequest).user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private extractToken(req: IncomingMessage): string | null {
|
||||
// 1. ?token= query param (used by WebSocket clients and simple links)
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const qp = url.searchParams.get("token");
|
||||
if (qp) return qp;
|
||||
|
||||
// 2. Authorization: Bearer <token>
|
||||
const auth = req.headers["authorization"];
|
||||
if (auth?.startsWith("Bearer ")) return auth.slice(7);
|
||||
|
||||
// 3. Cookie cc_token (set by token-auth login page if any)
|
||||
const cookieHeader = req.headers.cookie ?? "";
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
if (part.slice(0, eq).trim() === "cc_token") {
|
||||
return decodeURIComponent(part.slice(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
326
src/server/web/pty-server.ts
Normal file
326
src/server/web/pty-server.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import express from "express";
|
||||
import { createServer } from "http";
|
||||
import { mkdirSync } from "fs";
|
||||
import path from "path";
|
||||
import { spawn } from "node-pty";
|
||||
import { WebSocketServer } from "ws";
|
||||
import type { IncomingMessage } from "http";
|
||||
import { ConnectionRateLimiter } from "./auth.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { UserStore } from "./user-store.js";
|
||||
import { createAdminRouter } from "./admin.js";
|
||||
import { SessionStore } from "./auth/adapter.js";
|
||||
import { TokenAuthAdapter } from "./auth/token-auth.js";
|
||||
import { OAuthAdapter } from "./auth/oauth-auth.js";
|
||||
import { ApiKeyAdapter } from "./auth/apikey-auth.js";
|
||||
import type { AuthAdapter, AuthUser } from "./auth/adapter.js";
|
||||
|
||||
// ── Configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? "3000", 10);
|
||||
const HOST = process.env.HOST ?? "0.0.0.0";
|
||||
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10);
|
||||
const MAX_SESSIONS_PER_USER = parseInt(process.env.MAX_SESSIONS_PER_USER ?? "3", 10);
|
||||
const MAX_SESSIONS_PER_HOUR = parseInt(process.env.MAX_SESSIONS_PER_HOUR ?? "10", 10);
|
||||
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
|
||||
const GRACE_PERIOD_MS = parseInt(process.env.SESSION_GRACE_MS ?? String(5 * 60_000), 10);
|
||||
const SCROLLBACK_BYTES = parseInt(process.env.SCROLLBACK_BYTES ?? String(100 * 1024), 10);
|
||||
const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
|
||||
const AUTH_PROVIDER = process.env.AUTH_PROVIDER ?? "token";
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET ?? crypto.randomUUID();
|
||||
const USER_HOME_BASE = process.env.USER_HOME_BASE ?? "/home/claude/users";
|
||||
|
||||
// ── Auth adapter ──────────────────────────────────────────────────────────────
|
||||
|
||||
const sessionStore = new SessionStore(SESSION_SECRET);
|
||||
|
||||
let authAdapter: AuthAdapter;
|
||||
switch (AUTH_PROVIDER) {
|
||||
case "oauth":
|
||||
authAdapter = new OAuthAdapter(sessionStore);
|
||||
break;
|
||||
case "apikey":
|
||||
authAdapter = new ApiKeyAdapter(sessionStore);
|
||||
break;
|
||||
default:
|
||||
authAdapter = new TokenAuthAdapter();
|
||||
}
|
||||
|
||||
// ── Express app ───────────────────────────────────────────────────────────────
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
const server = createServer(app);
|
||||
|
||||
// Register auth routes (login, callback, logout) before static files so they
|
||||
// take priority over any index.html fallback.
|
||||
authAdapter.setupRoutes(app);
|
||||
|
||||
// ── User store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const userStore = new UserStore();
|
||||
|
||||
// ── Session Manager ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the user-specific home directory, creating it if needed. */
|
||||
function userHomeDir(userId: string): string {
|
||||
const dir = path.join(USER_HOME_BASE, userId);
|
||||
try {
|
||||
mkdirSync(path.join(dir, ".claude"), { recursive: true });
|
||||
} catch {
|
||||
// Already exists or no permission — fail silently; PTY spawn will surface any real issue.
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
MAX_SESSIONS,
|
||||
(cols, rows, user?: AuthUser) => {
|
||||
const userId = user?.id ?? "default";
|
||||
const home = userHomeDir(userId);
|
||||
return spawn(CLAUDE_BIN, [], {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
cwd: process.env.WORK_DIR ?? home,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor",
|
||||
HOME: home,
|
||||
// Inject the user's own API key when using apikey auth provider.
|
||||
...(user?.apiKey ? { ANTHROPIC_API_KEY: user.apiKey } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
GRACE_PERIOD_MS,
|
||||
SCROLLBACK_BYTES,
|
||||
MAX_SESSIONS_PER_USER,
|
||||
MAX_SESSIONS_PER_HOUR,
|
||||
);
|
||||
|
||||
// ── HTTP routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
activeSessions: sessionManager.activeCount,
|
||||
maxSessions: MAX_SESSIONS,
|
||||
authProvider: AUTH_PROVIDER,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/sessions — list the current user's sessions.
|
||||
* Requires authentication. Admins see all sessions.
|
||||
*/
|
||||
app.get("/api/sessions", authAdapter.requireAuth.bind(authAdapter), (req, res) => {
|
||||
const user = (req as express.Request & { user: AuthUser }).user;
|
||||
const sessions = user.isAdmin
|
||||
? sessionManager.getAllSessions()
|
||||
: sessionManager.getUserSessions(user.id);
|
||||
res.json(sessions);
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/sessions/:token — kill a session.
|
||||
* Users may only kill their own sessions; admins may kill any session.
|
||||
*/
|
||||
app.delete(
|
||||
"/api/sessions/:token",
|
||||
authAdapter.requireAuth.bind(authAdapter),
|
||||
(req, res) => {
|
||||
const { token } = req.params;
|
||||
const user = (req as express.Request & { user: AuthUser }).user;
|
||||
const session = sessionManager.getSession(token);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.isAdmin && session.userId !== user.id) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
sessionManager.destroySession(token);
|
||||
res.status(204).end();
|
||||
},
|
||||
);
|
||||
|
||||
// Admin routes — protected by admin-role check inside the router.
|
||||
app.use(
|
||||
"/admin",
|
||||
authAdapter.requireAuth.bind(authAdapter),
|
||||
createAdminRouter(sessionManager, userStore),
|
||||
);
|
||||
|
||||
// Static frontend (served last so auth/admin routes win).
|
||||
const publicDir = path.join(import.meta.dirname, "public");
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
app.get("/", authAdapter.requireAuth.bind(authAdapter), (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, "index.html"));
|
||||
});
|
||||
|
||||
// ── WebSocket server ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extend IncomingMessage to carry the authenticated user through from
|
||||
* verifyClient to the connection handler without re-authenticating.
|
||||
*/
|
||||
interface AuthedRequest extends IncomingMessage {
|
||||
_authUser?: AuthUser;
|
||||
}
|
||||
|
||||
const rateLimiter = new ConnectionRateLimiter();
|
||||
const rateLimiterCleanup = setInterval(() => rateLimiter.cleanup(), 5 * 60_000);
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ws",
|
||||
verifyClient: ({ req, origin }, callback) => {
|
||||
// Origin check
|
||||
if (ALLOWED_ORIGINS.length > 0 && !ALLOWED_ORIGINS.includes(origin)) {
|
||||
console.warn(`Rejected connection from origin: ${origin}`);
|
||||
callback(false, 403, "Forbidden origin");
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticate the user
|
||||
const user = authAdapter.authenticate(req as IncomingMessage);
|
||||
if (!user) {
|
||||
console.warn("Rejected WebSocket connection: unauthenticated");
|
||||
callback(false, 401, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// IP-level rate limit (guards against connection floods from a single IP)
|
||||
const ip =
|
||||
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
|
||||
req.socket.remoteAddress ??
|
||||
"unknown";
|
||||
if (!rateLimiter.allow(ip)) {
|
||||
console.warn(`Rate limited connection from ${ip}`);
|
||||
callback(false, 429, "Too many connections");
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-user rate limit (hourly new-session quota)
|
||||
if (sessionManager.isUserRateLimited(user.id)) {
|
||||
const retryAfter = sessionManager.retryAfterSeconds(user.id);
|
||||
console.warn(`Per-user rate limit for ${user.id}`);
|
||||
callback(false, 429, "Too Many Requests", { "Retry-After": String(retryAfter) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-user concurrent session limit
|
||||
if (sessionManager.isUserAtConcurrentLimit(user.id)) {
|
||||
console.warn(`Concurrent session limit reached for ${user.id}`);
|
||||
callback(false, 429, "Session limit reached");
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach user to request for the connection handler
|
||||
(req as AuthedRequest)._authUser = user;
|
||||
callback(true);
|
||||
},
|
||||
});
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
const user = (req as AuthedRequest)._authUser;
|
||||
if (!user) {
|
||||
// Should never happen — verifyClient already checked, but be safe.
|
||||
ws.close(1008, "Unauthenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
const ip =
|
||||
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
|
||||
req.socket.remoteAddress ??
|
||||
"unknown";
|
||||
console.log(`New WebSocket connection from ${ip} (user: ${user.id})`);
|
||||
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const cols = parseInt(url.searchParams.get("cols") ?? "80", 10);
|
||||
const rows = parseInt(url.searchParams.get("rows") ?? "24", 10);
|
||||
const resumeToken = url.searchParams.get("resume");
|
||||
|
||||
// Try to resume an existing session owned by this user
|
||||
if (resumeToken) {
|
||||
const stored = sessionManager.getSession(resumeToken);
|
||||
// Users may only resume their own sessions (admins can resume any)
|
||||
if (stored && (user.isAdmin || stored.userId === user.id)) {
|
||||
const resumed = sessionManager.resume(resumeToken, ws, cols, rows);
|
||||
if (resumed) return;
|
||||
}
|
||||
console.log(
|
||||
`[resume] Session ${resumeToken.slice(0, 8)}… not found or not owned — starting fresh`,
|
||||
);
|
||||
}
|
||||
|
||||
// Global capacity check
|
||||
if (sessionManager.isFull) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Max sessions reached. Try again later." }));
|
||||
ws.close(1013, "Max sessions reached");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = sessionManager.create(ws, cols, rows, user);
|
||||
if (token) {
|
||||
// Track the user in the user store
|
||||
userStore.touch(user.id, { email: user.email, name: user.name });
|
||||
|
||||
// Release the user slot when this session ends
|
||||
const stored = sessionManager.getSession(token);
|
||||
if (stored) {
|
||||
stored.pty.onExit(() => userStore.release(user.id));
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: "session", token }));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
console.log("Shutting down...");
|
||||
clearInterval(rateLimiterCleanup);
|
||||
sessionManager.destroyAll();
|
||||
wss.close(() => {
|
||||
server.close(() => {
|
||||
console.log("Server closed.");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.error("Forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`PTY server listening on http://${HOST}:${PORT}`);
|
||||
console.log(` WebSocket: ws://${HOST}:${PORT}/ws`);
|
||||
console.log(` Max sessions: ${MAX_SESSIONS} (${MAX_SESSIONS_PER_USER} per user)`);
|
||||
console.log(` Session grace period: ${GRACE_PERIOD_MS / 1000}s`);
|
||||
console.log(` Scrollback buffer: ${Math.round(SCROLLBACK_BYTES / 1024)}KB per session`);
|
||||
console.log(` Auth provider: ${AUTH_PROVIDER}`);
|
||||
if (AUTH_PROVIDER === "token" && process.env.AUTH_TOKEN) {
|
||||
console.log(" Auth: token required");
|
||||
}
|
||||
if (process.env.ADMIN_USERS) {
|
||||
console.log(` Admins: ${process.env.ADMIN_USERS}`);
|
||||
}
|
||||
});
|
||||
|
||||
export { app, server, sessionManager, wss };
|
||||
5
src/server/web/public/favicon.svg
Normal file
5
src/server/web/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1b26"/>
|
||||
<path d="M8 22l4-12h1.5L9.5 22H8zm7 0l4-12h1.5L16.5 22H15z" fill="#7aa2f7"/>
|
||||
<rect x="7" y="23" width="18" height="2" rx="1" fill="#c0caf5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
48
src/server/web/public/index.html
Normal file
48
src/server/web/public/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#1a1b26" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#f5f5f5" media="(prefers-color-scheme: light)">
|
||||
<title>Claude Code</title>
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<!-- terminal.css is emitted by esbuild: xterm.css + styles.css bundled together -->
|
||||
<link rel="stylesheet" href="terminal.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top bar -->
|
||||
<div id="top-bar">
|
||||
<div class="left">
|
||||
<span id="status-dot" class="status-dot connecting"></span>
|
||||
<span>Claude Code</span>
|
||||
<span id="latency">--</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<button id="bar-btn">Reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Re-expand button shown when top bar is collapsed -->
|
||||
<button id="toggle-bar" title="Show top bar">▾</button>
|
||||
|
||||
<!-- Terminal mount point -->
|
||||
<div id="terminal-container"></div>
|
||||
|
||||
<!-- Loading overlay (initial connect) -->
|
||||
<div id="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div class="overlay-msg">Connecting to Claude Code…</div>
|
||||
</div>
|
||||
|
||||
<!-- Reconnect overlay (shown on disconnect) -->
|
||||
<div id="reconnect-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div class="overlay-msg">Connection lost. Reconnecting…</div>
|
||||
<div class="overlay-sub" id="reconnect-sub">Retrying in 1s…</div>
|
||||
</div>
|
||||
|
||||
<!-- terminal.js is the esbuild bundle of terminal.ts + all xterm addons -->
|
||||
<script type="module" src="terminal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
432
src/server/web/public/terminal.css
Normal file
432
src/server/web/public/terminal.css
Normal file
File diff suppressed because one or more lines are too long
8963
src/server/web/public/terminal.js
Normal file
8963
src/server/web/public/terminal.js
Normal file
File diff suppressed because one or more lines are too long
66
src/server/web/scrollback-buffer.ts
Normal file
66
src/server/web/scrollback-buffer.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Circular byte buffer for PTY scrollback replay.
|
||||
* Stores the last `capacity` bytes of PTY output; oldest bytes are silently
|
||||
* discarded when the buffer is full.
|
||||
*/
|
||||
export class ScrollbackBuffer {
|
||||
private buf: Buffer;
|
||||
private writePos = 0; // next write position in the ring
|
||||
private stored = 0; // bytes currently stored (≤ capacity)
|
||||
readonly capacity: number;
|
||||
|
||||
constructor(capacityBytes = 100 * 1024) {
|
||||
this.capacity = capacityBytes;
|
||||
this.buf = Buffer.allocUnsafe(capacityBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append PTY output to the buffer.
|
||||
* Uses 'binary' (latin1) encoding to preserve raw byte values from node-pty.
|
||||
*/
|
||||
write(data: string): void {
|
||||
const src = Buffer.from(data, "binary");
|
||||
const len = src.length;
|
||||
if (len === 0) return;
|
||||
|
||||
if (len >= this.capacity) {
|
||||
// Incoming chunk larger than the whole buffer — keep only the tail
|
||||
src.copy(this.buf, 0, len - this.capacity);
|
||||
this.writePos = 0;
|
||||
this.stored = this.capacity;
|
||||
return;
|
||||
}
|
||||
|
||||
const end = this.writePos + len;
|
||||
if (end <= this.capacity) {
|
||||
src.copy(this.buf, this.writePos);
|
||||
} else {
|
||||
// Wrap around the ring boundary
|
||||
const tailLen = this.capacity - this.writePos;
|
||||
src.copy(this.buf, this.writePos, 0, tailLen);
|
||||
src.copy(this.buf, 0, tailLen);
|
||||
}
|
||||
this.writePos = end % this.capacity;
|
||||
this.stored = Math.min(this.stored + len, this.capacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all buffered bytes in chronological order (oldest first).
|
||||
* The returned Buffer is a copy and safe to send over a WebSocket.
|
||||
*/
|
||||
read(): Buffer {
|
||||
if (this.stored === 0) return Buffer.alloc(0);
|
||||
|
||||
if (this.stored < this.capacity) {
|
||||
// Buffer hasn't wrapped yet — contiguous region starting at index 0
|
||||
return Buffer.from(this.buf.subarray(0, this.stored));
|
||||
}
|
||||
|
||||
// Buffer is full and has wrapped — oldest data starts at writePos
|
||||
const out = Buffer.allocUnsafe(this.capacity);
|
||||
const tailLen = this.capacity - this.writePos;
|
||||
this.buf.copy(out, 0, this.writePos); // tail (oldest)
|
||||
this.buf.copy(out, tailLen, 0, this.writePos); // head (newest)
|
||||
return out;
|
||||
}
|
||||
}
|
||||
328
src/server/web/session-manager.ts
Normal file
328
src/server/web/session-manager.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { IPty } from "node-pty";
|
||||
import type { WebSocket } from "ws";
|
||||
import { SessionStore } from "./session-store.js";
|
||||
import type { AuthUser } from "./auth/adapter.js";
|
||||
|
||||
export type { SessionInfo } from "./session-store.js";
|
||||
|
||||
// ── Per-user hourly rate limiter ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tracks new-session creations per user within a rolling 1-hour window.
|
||||
*
|
||||
* `allow(userId)` is a non-destructive peek so callers can check eligibility
|
||||
* before committing. `record(userId)` commits an attempt (call only on
|
||||
* successful creation).
|
||||
*/
|
||||
export class UserHourlyRateLimiter {
|
||||
private readonly attempts = new Map<string, number[]>();
|
||||
private readonly maxPerHour: number;
|
||||
|
||||
constructor(maxPerHour: number) {
|
||||
this.maxPerHour = maxPerHour;
|
||||
setInterval(() => this.cleanup(), 5 * 60_000).unref();
|
||||
}
|
||||
|
||||
allow(userId: string): boolean {
|
||||
return this.recent(userId).length < this.maxPerHour;
|
||||
}
|
||||
|
||||
record(userId: string): void {
|
||||
const r = this.recent(userId);
|
||||
r.push(Date.now());
|
||||
this.attempts.set(userId, r);
|
||||
}
|
||||
|
||||
/** Seconds until the oldest attempt in the window falls off (for Retry-After). */
|
||||
retryAfterSeconds(userId: string): number {
|
||||
const r = this.recent(userId);
|
||||
if (r.length === 0) return 0;
|
||||
return Math.ceil((Math.min(...r) + 3_600_000 - Date.now()) / 1000);
|
||||
}
|
||||
|
||||
private recent(userId: string): number[] {
|
||||
const cutoff = Date.now() - 3_600_000;
|
||||
const filtered = (this.attempts.get(userId) ?? []).filter((t) => t > cutoff);
|
||||
this.attempts.set(userId, filtered);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const cutoff = Date.now() - 3_600_000;
|
||||
for (const [id, ts] of this.attempts) {
|
||||
const r = ts.filter((t) => t > cutoff);
|
||||
if (r.length === 0) this.attempts.delete(id);
|
||||
else this.attempts.set(id, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SessionManager ────────────────────────────────────────────────────────────
|
||||
|
||||
export class SessionManager {
|
||||
private store: SessionStore;
|
||||
private maxSessions: number;
|
||||
private maxSessionsPerUser: number;
|
||||
private spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty;
|
||||
private rateLimiter: UserHourlyRateLimiter;
|
||||
// Tracks which sessions have already had their PTY event listeners wired,
|
||||
// so we don't double-register on reconnect.
|
||||
private wiredPtys = new Set<string>();
|
||||
|
||||
constructor(
|
||||
maxSessions: number,
|
||||
spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty,
|
||||
gracePeriodMs?: number,
|
||||
scrollbackBytes?: number,
|
||||
maxSessionsPerUser?: number,
|
||||
maxSessionsPerHour?: number,
|
||||
) {
|
||||
this.maxSessions = maxSessions;
|
||||
this.maxSessionsPerUser = maxSessionsPerUser ?? maxSessions;
|
||||
this.spawnPty = spawnPty;
|
||||
this.store = new SessionStore(gracePeriodMs, scrollbackBytes);
|
||||
this.rateLimiter = new UserHourlyRateLimiter(maxSessionsPerHour ?? 100);
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
get isFull(): boolean {
|
||||
return this.store.size >= this.maxSessions;
|
||||
}
|
||||
|
||||
getSession(token: string) {
|
||||
return this.store.get(token);
|
||||
}
|
||||
|
||||
listSessions() {
|
||||
return this.store.list();
|
||||
}
|
||||
|
||||
/** All sessions in the shape expected by the admin dashboard. */
|
||||
getAllSessions(): Array<{ id: string; userId: string; createdAt: number }> {
|
||||
return this.store.getAll().map((s) => ({
|
||||
id: s.token,
|
||||
userId: s.userId,
|
||||
createdAt: s.createdAt.getTime(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sessions owned by a specific user — used by the per-user API. */
|
||||
getUserSessions(userId: string) {
|
||||
return this.store.listByUser(userId);
|
||||
}
|
||||
|
||||
isUserAtConcurrentLimit(userId: string): boolean {
|
||||
return this.store.countByUser(userId) >= this.maxSessionsPerUser;
|
||||
}
|
||||
|
||||
isUserRateLimited(userId: string): boolean {
|
||||
return !this.rateLimiter.allow(userId);
|
||||
}
|
||||
|
||||
retryAfterSeconds(userId: string): number {
|
||||
return this.rateLimiter.retryAfterSeconds(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a new PTY, registers it in the session store, and wires up all
|
||||
* event plumbing between the PTY and the WebSocket.
|
||||
*
|
||||
* Returns the session token, or null if at capacity or PTY spawn fails.
|
||||
* When `user` is provided the session is associated with that user and
|
||||
* per-user limits are enforced.
|
||||
*/
|
||||
create(ws: WebSocket, cols = 80, rows = 24, user?: AuthUser): string | null {
|
||||
if (this.isFull) return null;
|
||||
|
||||
const userId = user?.id ?? "default";
|
||||
|
||||
if (this.isUserAtConcurrentLimit(userId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Session limit reached for your account (max ${this.maxSessionsPerUser}).`,
|
||||
}),
|
||||
);
|
||||
ws.close(1013, "Per-user session limit reached");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isUserRateLimited(userId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Too many sessions created recently. Please wait before starting a new session.",
|
||||
}),
|
||||
);
|
||||
ws.close(1013, "Rate limited");
|
||||
return null;
|
||||
}
|
||||
|
||||
let pty: IPty;
|
||||
try {
|
||||
pty = this.spawnPty(cols, rows, user);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unknown PTY spawn error";
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: `PTY spawn failed: ${message}` }),
|
||||
);
|
||||
ws.close(1011, "PTY spawn failure");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Record the creation only after a successful spawn.
|
||||
this.rateLimiter.record(userId);
|
||||
|
||||
const session = this.store.register(pty, userId);
|
||||
session.ws = ws;
|
||||
const { token } = session;
|
||||
|
||||
this.wirePtyEvents(token, pty);
|
||||
this.wireWsEvents(token, ws, pty);
|
||||
|
||||
console.log(
|
||||
`[session ${token.slice(0, 8)}] Created for user ${userId} (active: ${this.store.size}/${this.maxSessions})`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a new WebSocket to an existing session identified by `token`.
|
||||
*
|
||||
* - Cancels the grace timer
|
||||
* - Sends `{ type: "resumed", token }` to the client
|
||||
* - Replays the scrollback buffer so the user sees their conversation
|
||||
* - Resizes the PTY to the client's current terminal dimensions
|
||||
*
|
||||
* Returns true if the session was found, false otherwise.
|
||||
*/
|
||||
resume(token: string, ws: WebSocket, cols: number, rows: number): boolean {
|
||||
const session = this.store.reattach(token, ws);
|
||||
if (!session) return false;
|
||||
|
||||
console.log(
|
||||
`[session ${token.slice(0, 8)}] Resumed (active: ${this.store.size}/${this.maxSessions})`,
|
||||
);
|
||||
|
||||
// Tell the client it's a resumed session BEFORE sending scrollback bytes.
|
||||
// The client uses this to clear the terminal first.
|
||||
ws.send(JSON.stringify({ type: "resumed", token }));
|
||||
|
||||
// Replay buffered output
|
||||
const scrollback = session.scrollback.read();
|
||||
if (scrollback.length > 0) {
|
||||
ws.send(scrollback);
|
||||
}
|
||||
|
||||
// Sync PTY dimensions to the reconnected client
|
||||
try {
|
||||
session.pty.resize(cols, rows);
|
||||
} catch {
|
||||
// PTY may have exited
|
||||
}
|
||||
|
||||
this.wireWsEvents(token, ws, session.pty);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire PTY → scrollback + WebSocket.
|
||||
* Called once per session lifetime (idempotent via `wiredPtys` guard).
|
||||
*/
|
||||
private wirePtyEvents(token: string, pty: IPty): void {
|
||||
if (this.wiredPtys.has(token)) return;
|
||||
this.wiredPtys.add(token);
|
||||
|
||||
const session = this.store.get(token);
|
||||
if (!session) return;
|
||||
|
||||
pty.onData((data: string) => {
|
||||
// Always capture to scrollback for future replay
|
||||
session.scrollback.write(data);
|
||||
// Forward to the currently attached WebSocket, if any
|
||||
const ws = session.ws;
|
||||
if (ws && ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
this.wiredPtys.delete(token);
|
||||
console.log(
|
||||
`[session ${token.slice(0, 8)}] PTY exited: code=${exitCode}, signal=${signal}`,
|
||||
);
|
||||
const ws = session.ws;
|
||||
if (ws && ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
|
||||
ws.close(1000, "PTY exited");
|
||||
}
|
||||
this.store.destroy(token);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire WebSocket → PTY (input, resize, ping).
|
||||
* On close/error, start the grace period instead of immediately destroying
|
||||
* the session — this keeps the PTY alive for reconnection.
|
||||
* Called once per WebSocket connection (safe to call again on reconnect).
|
||||
*/
|
||||
private wireWsEvents(token: string, ws: WebSocket, pty: IPty): void {
|
||||
ws.on("message", (data: Buffer | string) => {
|
||||
const str = data.toString();
|
||||
if (str.startsWith("{")) {
|
||||
try {
|
||||
const msg = JSON.parse(str) as Record<string, unknown>;
|
||||
if (
|
||||
msg.type === "resize" &&
|
||||
typeof msg.cols === "number" &&
|
||||
typeof msg.rows === "number"
|
||||
) {
|
||||
pty.resize(msg.cols as number, msg.rows as number);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "ping") {
|
||||
if (ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as terminal input
|
||||
}
|
||||
}
|
||||
pty.write(str);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
console.log(`[session ${token.slice(0, 8)}] WebSocket closed`);
|
||||
const session = this.store.get(token);
|
||||
// Only start grace if this WS is still the one attached to the session
|
||||
if (session && session.ws === ws) {
|
||||
this.store.startGrace(token, () => {
|
||||
/* logged inside startGrace */
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("close", handleClose);
|
||||
ws.on("error", (err) => {
|
||||
console.error(`[session ${token.slice(0, 8)}] WebSocket error:`, err.message);
|
||||
handleClose();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill a session immediately (used by the REST API).
|
||||
*/
|
||||
destroySession(token: string): void {
|
||||
this.store.destroy(token);
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
this.store.destroyAll();
|
||||
}
|
||||
}
|
||||
197
src/server/web/session-store.ts
Normal file
197
src/server/web/session-store.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { IPty } from "node-pty";
|
||||
import type { WebSocket } from "ws";
|
||||
import { ScrollbackBuffer } from "./scrollback-buffer.js";
|
||||
|
||||
const DEFAULT_GRACE_MS = 5 * 60_000; // 5 minutes
|
||||
const DEFAULT_SCROLLBACK_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
export type StoredSession = {
|
||||
token: string;
|
||||
/** ID of the user who owns this session. */
|
||||
userId: string;
|
||||
pty: IPty;
|
||||
scrollback: ScrollbackBuffer;
|
||||
ws: WebSocket | null;
|
||||
createdAt: Date;
|
||||
lastActive: Date;
|
||||
graceTimer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
export type SessionInfo = {
|
||||
token: string;
|
||||
/** ID of the user who owns this session. */
|
||||
userId: string;
|
||||
created: string;
|
||||
lastActive: string;
|
||||
alive: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory session store with TTL-based cleanup.
|
||||
*
|
||||
* Sessions survive WebSocket disconnects for `gracePeriodMs` before being
|
||||
* permanently destroyed. This lets clients reconnect and resume their PTY
|
||||
* without losing conversation state.
|
||||
*/
|
||||
export class SessionStore {
|
||||
private sessions = new Map<string, StoredSession>();
|
||||
private readonly gracePeriodMs: number;
|
||||
private readonly scrollbackBytes: number;
|
||||
|
||||
constructor(
|
||||
gracePeriodMs = DEFAULT_GRACE_MS,
|
||||
scrollbackBytes = DEFAULT_SCROLLBACK_BYTES,
|
||||
) {
|
||||
this.gracePeriodMs = gracePeriodMs;
|
||||
this.scrollbackBytes = scrollbackBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a newly spawned PTY under a fresh session token.
|
||||
* @param userId - ID of the owning user (defaults to "default" for single-user deployments).
|
||||
*/
|
||||
register(pty: IPty, userId = "default"): StoredSession {
|
||||
const token = crypto.randomUUID();
|
||||
const session: StoredSession = {
|
||||
token,
|
||||
userId,
|
||||
pty,
|
||||
scrollback: new ScrollbackBuffer(this.scrollbackBytes),
|
||||
ws: null,
|
||||
createdAt: new Date(),
|
||||
lastActive: new Date(),
|
||||
graceTimer: null,
|
||||
};
|
||||
this.sessions.set(token, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
get(token: string): StoredSession | undefined {
|
||||
return this.sessions.get(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a new WebSocket to an existing session.
|
||||
* Cancels any running grace timer.
|
||||
* Returns null if the session does not exist.
|
||||
*/
|
||||
reattach(token: string, ws: WebSocket): StoredSession | null {
|
||||
const session = this.sessions.get(token);
|
||||
if (!session) return null;
|
||||
|
||||
if (session.graceTimer) {
|
||||
clearTimeout(session.graceTimer);
|
||||
session.graceTimer = null;
|
||||
}
|
||||
session.ws = ws;
|
||||
session.lastActive = new Date();
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the WebSocket from a session and start the grace period timer.
|
||||
* After `gracePeriodMs` with no reconnect, `onExpire` is called and the
|
||||
* session is destroyed.
|
||||
*/
|
||||
startGrace(token: string, onExpire: () => void): void {
|
||||
const session = this.sessions.get(token);
|
||||
if (!session) return;
|
||||
|
||||
session.ws = null;
|
||||
session.lastActive = new Date();
|
||||
|
||||
if (session.graceTimer) {
|
||||
clearTimeout(session.graceTimer);
|
||||
}
|
||||
|
||||
const remainingSec = Math.round(this.gracePeriodMs / 1000);
|
||||
console.log(
|
||||
`[session ${token.slice(0, 8)}] Disconnected — grace period: ${remainingSec}s`,
|
||||
);
|
||||
|
||||
session.graceTimer = setTimeout(() => {
|
||||
session.graceTimer = null;
|
||||
console.log(
|
||||
`[session ${token.slice(0, 8)}] Grace period expired — cleaning up`,
|
||||
);
|
||||
this.destroy(token);
|
||||
onExpire();
|
||||
}, this.gracePeriodMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately kill the PTY and remove the session.
|
||||
*/
|
||||
destroy(token: string): void {
|
||||
const session = this.sessions.get(token);
|
||||
if (!session) return;
|
||||
|
||||
this.sessions.delete(token);
|
||||
|
||||
if (session.graceTimer) {
|
||||
clearTimeout(session.graceTimer);
|
||||
session.graceTimer = null;
|
||||
}
|
||||
|
||||
if (
|
||||
session.ws &&
|
||||
session.ws.readyState !== 2 /* CLOSING */ &&
|
||||
session.ws.readyState !== 3 /* CLOSED */
|
||||
) {
|
||||
session.ws.close(1000, "Session destroyed");
|
||||
}
|
||||
|
||||
try {
|
||||
session.pty.kill("SIGHUP");
|
||||
} catch {
|
||||
// PTY may already be dead
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
session.pty.kill("SIGKILL");
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/** Returns summary info for all sessions (used by the REST API). */
|
||||
list(): SessionInfo[] {
|
||||
return [...this.sessions.values()].map((s) => ({
|
||||
token: s.token,
|
||||
userId: s.userId,
|
||||
created: s.createdAt.toISOString(),
|
||||
lastActive: s.lastActive.toISOString(),
|
||||
alive: s.ws !== null && s.ws.readyState === 1 /* OPEN */,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Returns summary info for sessions owned by a specific user. */
|
||||
listByUser(userId: string): SessionInfo[] {
|
||||
return this.list().filter((s) => s.userId === userId);
|
||||
}
|
||||
|
||||
/** How many sessions are owned by the given user. */
|
||||
countByUser(userId: string): number {
|
||||
let n = 0;
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.userId === userId) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Returns all raw StoredSession objects (used internally by SessionManager). */
|
||||
getAll(): StoredSession[] {
|
||||
return [...this.sessions.values()];
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const token of [...this.sessions.keys()]) {
|
||||
this.destroy(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
243
src/server/web/styles.css
Normal file
243
src/server/web/styles.css
Normal file
@@ -0,0 +1,243 @@
|
||||
/* ── CSS custom properties (theme tokens) ───────────────────────────────── */
|
||||
:root {
|
||||
--term-bg: #1a1b26;
|
||||
--term-fg: #c0caf5;
|
||||
--term-cursor: #c0caf5;
|
||||
--term-selection: rgba(130, 170, 255, 0.3);
|
||||
--term-black: #15161e;
|
||||
--term-red: #f7768e;
|
||||
--term-green: #9ece6a;
|
||||
--term-yellow: #e0af68;
|
||||
--term-blue: #7aa2f7;
|
||||
--term-magenta: #bb9af7;
|
||||
--term-cyan: #7dcfff;
|
||||
--term-white: #a9b1d6;
|
||||
--term-bright-black: #414868;
|
||||
--term-bright-white: #c0caf5;
|
||||
--bar-bg: #16161e;
|
||||
--bar-fg: #565f89;
|
||||
--bar-accent: #7aa2f7;
|
||||
--overlay-bg: rgba(26, 27, 38, 0.92);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--term-bg: #f5f5f5;
|
||||
--term-fg: #343b58;
|
||||
--term-cursor: #343b58;
|
||||
--term-selection: rgba(52, 59, 88, 0.2);
|
||||
--term-black: #0f0f14;
|
||||
--term-red: #8c4351;
|
||||
--term-green: #485e30;
|
||||
--term-yellow: #8f5e15;
|
||||
--term-blue: #34548a;
|
||||
--term-magenta: #5a4a78;
|
||||
--term-cyan: #0f4b6e;
|
||||
--term-white: #343b58;
|
||||
--term-bright-black: #9699a3;
|
||||
--term-bright-white: #343b58;
|
||||
--bar-bg: #e8e8e8;
|
||||
--bar-fg: #6c7086;
|
||||
--bar-accent: #34548a;
|
||||
--overlay-bg: rgba(245, 245, 245, 0.92);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Top bar ────────────────────────────────────────────────────────────── */
|
||||
#top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
background: var(--bar-bg);
|
||||
color: var(--bar-fg);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
#top-bar.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#top-bar .left,
|
||||
#top-bar .right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Re-expand button — only visible when bar is collapsed */
|
||||
#toggle-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 20;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--bar-bg);
|
||||
border: 1px solid var(--bar-fg);
|
||||
border-radius: 3px;
|
||||
color: var(--bar-fg);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#top-bar.collapsed ~ #toggle-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── Status dot ─────────────────────────────────────────────────────────── */
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--term-green);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--term-red);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: var(--term-yellow);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Bar button ─────────────────────────────────────────────────────────── */
|
||||
#bar-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--bar-fg);
|
||||
color: var(--bar-fg);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
#bar-btn:hover {
|
||||
color: var(--bar-accent);
|
||||
border-color: var(--bar-accent);
|
||||
}
|
||||
|
||||
/* ── Terminal container ─────────────────────────────────────────────────── */
|
||||
#terminal-container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 32px);
|
||||
background: var(--term-bg);
|
||||
}
|
||||
|
||||
#top-bar.collapsed ~ #terminal-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Give xterm a little breathing room */
|
||||
#terminal-container .xterm {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* ── Overlays (loading + reconnect) ─────────────────────────────────────── */
|
||||
#loading-overlay,
|
||||
#reconnect-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
background: var(--overlay-bg);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#loading-overlay {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
#loading-overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#reconnect-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#reconnect-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--term-bright-black);
|
||||
border-top-color: var(--bar-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.overlay-msg {
|
||||
color: var(--term-fg);
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.overlay-sub {
|
||||
margin-top: 6px;
|
||||
color: var(--bar-fg);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
|
||||
.xterm-viewport::-webkit-scrollbar { width: 8px; }
|
||||
.xterm-viewport::-webkit-scrollbar-track { background: transparent; }
|
||||
.xterm-viewport::-webkit-scrollbar-thumb { background: var(--term-bright-black); border-radius: 4px; }
|
||||
.xterm-viewport::-webkit-scrollbar-thumb:hover { background: var(--bar-fg); }
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
#top-bar {
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#terminal-container {
|
||||
height: calc(100vh - 28px);
|
||||
}
|
||||
|
||||
#top-bar.collapsed ~ #terminal-container {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
372
src/server/web/terminal.ts
Normal file
372
src/server/web/terminal.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Claude Code — terminal-in-browser
|
||||
*
|
||||
* WebSocket protocol (matches src/server/web/session-manager.ts):
|
||||
*
|
||||
* Server → client (text, JSON):
|
||||
* { type: "connected", sessionId: string }
|
||||
* { type: "pong" }
|
||||
* { type: "error", message: string }
|
||||
* { type: "exit", exitCode: number, signal: number | undefined }
|
||||
*
|
||||
* Server → client (text, raw):
|
||||
* PTY output — plain string, written directly to xterm
|
||||
*
|
||||
* Client → server (text):
|
||||
* { type: "resize", cols: number, rows: number }
|
||||
* { type: "ping" }
|
||||
* raw terminal input string
|
||||
*/
|
||||
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import './styles.css'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ServerMessage =
|
||||
| { type: 'connected'; sessionId: string }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'exit'; exitCode: number; signal?: number }
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const RECONNECT_BASE_MS = 1_000
|
||||
const RECONNECT_MAX_MS = 30_000
|
||||
const PING_INTERVAL_MS = 5_000
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let term: Terminal
|
||||
let fitAddon: FitAddon
|
||||
let searchAddon: SearchAddon
|
||||
let reconnectDelay = RECONNECT_BASE_MS
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lastPingSent = 0
|
||||
let connected = false
|
||||
|
||||
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const loadingOverlay = document.getElementById('loading-overlay')!
|
||||
const reconnectOverlay = document.getElementById('reconnect-overlay')!
|
||||
const reconnectSub = document.getElementById('reconnect-sub')!
|
||||
const statusDot = document.getElementById('status-dot')!
|
||||
const latencyEl = document.getElementById('latency')!
|
||||
const barBtn = document.getElementById('bar-btn') as HTMLButtonElement
|
||||
const topBar = document.getElementById('top-bar')!
|
||||
const toggleBarBtn = document.getElementById('toggle-bar') as HTMLButtonElement
|
||||
const terminalContainer = document.getElementById('terminal-container')!
|
||||
|
||||
// ── Theme ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getTheme(): Terminal['options']['theme'] {
|
||||
const s = getComputedStyle(document.documentElement)
|
||||
const v = (prop: string) => s.getPropertyValue(prop).trim()
|
||||
return {
|
||||
background: v('--term-bg'),
|
||||
foreground: v('--term-fg'),
|
||||
cursor: v('--term-cursor'),
|
||||
selectionBackground: v('--term-selection'),
|
||||
black: v('--term-black'),
|
||||
red: v('--term-red'),
|
||||
green: v('--term-green'),
|
||||
yellow: v('--term-yellow'),
|
||||
blue: v('--term-blue'),
|
||||
magenta: v('--term-magenta'),
|
||||
cyan: v('--term-cyan'),
|
||||
white: v('--term-white'),
|
||||
brightBlack: v('--term-bright-black'),
|
||||
brightRed: v('--term-red'),
|
||||
brightGreen: v('--term-green'),
|
||||
brightYellow: v('--term-yellow'),
|
||||
brightBlue: v('--term-blue'),
|
||||
brightMagenta: v('--term-magenta'),
|
||||
brightCyan: v('--term-cyan'),
|
||||
brightWhite: v('--term-bright-white'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terminal initialisation ──────────────────────────────────────────────────
|
||||
|
||||
function initTerminal(): void {
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontFamily:
|
||||
"'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, 'Courier New', monospace",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
theme: getTheme(),
|
||||
allowProposedApi: true,
|
||||
scrollback: 10_000,
|
||||
convertEol: true,
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(new WebLinksAddon())
|
||||
|
||||
searchAddon = new SearchAddon()
|
||||
term.loadAddon(searchAddon)
|
||||
|
||||
const unicode11 = new Unicode11Addon()
|
||||
term.loadAddon(unicode11)
|
||||
term.unicode.activeVersion = '11'
|
||||
|
||||
term.open(terminalContainer)
|
||||
|
||||
// WebGL renderer with canvas fallback
|
||||
try {
|
||||
const webgl = new WebglAddon()
|
||||
webgl.onContextLoss(() => webgl.dispose())
|
||||
term.loadAddon(webgl)
|
||||
} catch {
|
||||
// Canvas renderer is already active — no action needed
|
||||
}
|
||||
|
||||
fitAddon.fit()
|
||||
|
||||
// Keep terminal fitted to container
|
||||
const resizeObserver = new ResizeObserver(() => fitAddon.fit())
|
||||
resizeObserver.observe(terminalContainer)
|
||||
|
||||
// Propagate resize to server
|
||||
term.onResize(({ cols, rows }) => sendJSON({ type: 'resize', cols, rows }))
|
||||
|
||||
// Forward all terminal input to PTY
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
|
||||
})
|
||||
|
||||
// Keyboard intercepts (return false = swallow; return true = pass through)
|
||||
term.attachCustomKeyEventHandler((ev) => {
|
||||
// Ctrl+Shift+F → in-terminal search
|
||||
if (ev.ctrlKey && ev.shiftKey && ev.key === 'F') {
|
||||
if (ev.type === 'keydown') {
|
||||
const query = window.prompt('Search terminal:')
|
||||
if (query) searchAddon.findNext(query, { caseSensitive: false, regex: false })
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Ctrl+Shift+C → copy selection (Linux convention)
|
||||
if (ev.ctrlKey && ev.shiftKey && ev.key === 'C') {
|
||||
if (ev.type === 'keydown') {
|
||||
const sel = term.getSelection()
|
||||
if (sel) navigator.clipboard.writeText(sel)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Ctrl+Shift+V → paste (Linux convention)
|
||||
if (ev.ctrlKey && ev.shiftKey && ev.key === 'V') {
|
||||
if (ev.type === 'keydown') {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(text)
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Update theme when OS preference changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
term.options.theme = getTheme()
|
||||
})
|
||||
}
|
||||
|
||||
// ── WebSocket ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getWsUrl(): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = new URL(`${proto}//${location.host}/ws`)
|
||||
|
||||
// Auth token: URL param wins, then localStorage
|
||||
const params = new URLSearchParams(location.search)
|
||||
const token = params.get('token') ?? localStorage.getItem('claude-terminal-token')
|
||||
if (token) {
|
||||
url.searchParams.set('token', token)
|
||||
localStorage.setItem('claude-terminal-token', token)
|
||||
}
|
||||
|
||||
// Pass current terminal dimensions so the PTY is spawned at the right size
|
||||
url.searchParams.set('cols', String(term.cols))
|
||||
url.searchParams.set('rows', String(term.rows))
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function sendJSON(msg: Record<string, unknown>): void {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
setStatus('connecting')
|
||||
|
||||
ws = new WebSocket(getWsUrl())
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
connected = true
|
||||
reconnectDelay = RECONNECT_BASE_MS
|
||||
setStatus('connected')
|
||||
hideOverlay(loadingOverlay)
|
||||
hideOverlay(reconnectOverlay)
|
||||
// Re-sync size in case the window changed while connecting
|
||||
fitAddon.fit()
|
||||
sendJSON({ type: 'resize', cols: term.cols, rows: term.rows })
|
||||
startPing()
|
||||
})
|
||||
|
||||
ws.addEventListener('message', ({ data }: MessageEvent<string>) => {
|
||||
// All messages from the server are strings.
|
||||
// Try JSON control message first; fall back to raw PTY output.
|
||||
if (data.startsWith('{')) {
|
||||
try {
|
||||
handleControlMessage(JSON.parse(data) as ServerMessage)
|
||||
return
|
||||
} catch {
|
||||
// Not JSON — fall through to write as PTY output
|
||||
}
|
||||
}
|
||||
term.write(data)
|
||||
})
|
||||
|
||||
ws.addEventListener('close', onDisconnect)
|
||||
ws.addEventListener('error', () => {
|
||||
// 'error' always fires before 'close'; let onDisconnect handle reconnect
|
||||
})
|
||||
}
|
||||
|
||||
function handleControlMessage(msg: ServerMessage): void {
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
// Session established — nothing extra needed
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
latencyEl.textContent = `${Date.now() - lastPingSent}ms`
|
||||
break
|
||||
|
||||
case 'error':
|
||||
term.writeln(`\r\n\x1b[31m[error] ${msg.message}\x1b[0m`)
|
||||
break
|
||||
|
||||
case 'exit':
|
||||
term.writeln(
|
||||
`\r\n\x1b[33m[session ended — exit code ${msg.exitCode ?? 0}]\x1b[0m`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function onDisconnect(): void {
|
||||
connected = false
|
||||
ws = null
|
||||
setStatus('disconnected')
|
||||
stopPing()
|
||||
showOverlay(reconnectOverlay)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectSub.textContent = `Retrying in ${Math.round(reconnectDelay / 1_000)}s…`
|
||||
reconnectTimer = setTimeout(() => connect(), reconnectDelay)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS)
|
||||
}
|
||||
|
||||
function manualReconnect(): void {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectDelay = RECONNECT_BASE_MS
|
||||
ws?.close()
|
||||
ws = null
|
||||
term.clear()
|
||||
connect()
|
||||
}
|
||||
|
||||
// ── Ping / latency ───────────────────────────────────────────────────────────
|
||||
|
||||
function startPing(): void {
|
||||
stopPing()
|
||||
pingTimer = setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
lastPingSent = Date.now()
|
||||
sendJSON({ type: 'ping' })
|
||||
}
|
||||
}, PING_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopPing(): void {
|
||||
if (pingTimer) clearInterval(pingTimer)
|
||||
pingTimer = null
|
||||
latencyEl.textContent = '--'
|
||||
}
|
||||
|
||||
// ── UI helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function setStatus(state: 'connected' | 'connecting' | 'disconnected'): void {
|
||||
statusDot.className = 'status-dot'
|
||||
if (state !== 'connected') statusDot.classList.add(state)
|
||||
barBtn.textContent = connected ? 'Disconnect' : 'Reconnect'
|
||||
}
|
||||
|
||||
function showOverlay(el: HTMLElement): void {
|
||||
el.classList.remove('hidden')
|
||||
el.classList.add('visible')
|
||||
}
|
||||
|
||||
function hideOverlay(el: HTMLElement): void {
|
||||
el.classList.remove('visible')
|
||||
el.classList.add('hidden')
|
||||
}
|
||||
|
||||
// ── Top bar collapse ──────────────────────────────────────────────────────────
|
||||
|
||||
function setupBarToggle(): void {
|
||||
const STORAGE_KEY = 'claude-bar-collapsed'
|
||||
|
||||
if (localStorage.getItem(STORAGE_KEY) === 'true') {
|
||||
topBar.classList.add('collapsed')
|
||||
}
|
||||
|
||||
// Show bar button re-expands it
|
||||
toggleBarBtn.addEventListener('click', () => {
|
||||
topBar.classList.remove('collapsed')
|
||||
localStorage.setItem(STORAGE_KEY, 'false')
|
||||
setTimeout(() => fitAddon.fit(), 200)
|
||||
})
|
||||
|
||||
// Double-click bar to collapse
|
||||
topBar.addEventListener('dblclick', () => {
|
||||
topBar.classList.add('collapsed')
|
||||
localStorage.setItem(STORAGE_KEY, 'true')
|
||||
setTimeout(() => fitAddon.fit(), 200)
|
||||
})
|
||||
|
||||
barBtn.addEventListener('click', () => {
|
||||
if (connected) {
|
||||
ws?.close()
|
||||
} else {
|
||||
manualReconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTerminal()
|
||||
setupBarToggle()
|
||||
connect()
|
||||
|
||||
// Keep terminal focused
|
||||
document.addEventListener('click', () => term.focus())
|
||||
term.focus()
|
||||
})
|
||||
8
src/server/web/tsconfig.json
Normal file
8
src/server/web/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["terminal.ts", "styles.css"]
|
||||
}
|
||||
64
src/server/web/user-store.ts
Normal file
64
src/server/web/user-store.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* UserStore — tracks which users are connected and how many sessions each has.
|
||||
*
|
||||
* This is a lightweight in-memory view derived from the SessionManager; it
|
||||
* does not persist across restarts. The admin dashboard and admin API read
|
||||
* from this store to enumerate users and their activity.
|
||||
*/
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
sessionCount: number;
|
||||
}
|
||||
|
||||
export class UserStore {
|
||||
private readonly users = new Map<string, UserRecord>();
|
||||
|
||||
/**
|
||||
* Called when a session is created for a user.
|
||||
* Creates the user record if it doesn't exist yet; increments sessionCount.
|
||||
*/
|
||||
touch(userId: string, meta?: { email?: string; name?: string }): void {
|
||||
const existing = this.users.get(userId);
|
||||
if (existing) {
|
||||
existing.lastSeenAt = Date.now();
|
||||
existing.sessionCount += 1;
|
||||
if (meta?.email && !existing.email) existing.email = meta.email;
|
||||
if (meta?.name && !existing.name) existing.name = meta.name;
|
||||
} else {
|
||||
this.users.set(userId, {
|
||||
id: userId,
|
||||
email: meta?.email,
|
||||
name: meta?.name,
|
||||
firstSeenAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
sessionCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a session is destroyed for a user.
|
||||
* Decrements sessionCount; removes the record when it reaches zero.
|
||||
*/
|
||||
release(userId: string): void {
|
||||
const record = this.users.get(userId);
|
||||
if (!record) return;
|
||||
record.sessionCount = Math.max(0, record.sessionCount - 1);
|
||||
if (record.sessionCount === 0) {
|
||||
this.users.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns all currently connected users (sessionCount > 0). */
|
||||
list(): UserRecord[] {
|
||||
return [...this.users.values()];
|
||||
}
|
||||
|
||||
get(userId: string): UserRecord | undefined {
|
||||
return this.users.get(userId);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
const FEATURE_FLAGS: Record<string, boolean> = {
|
||||
PROACTIVE: envBool('CLAUDE_CODE_PROACTIVE', false),
|
||||
KAIROS: envBool('CLAUDE_CODE_KAIROS', false),
|
||||
KAIROS_BRIEF: envBool('CLAUDE_CODE_KAIROS_BRIEF', false),
|
||||
KAIROS_GITHUB_WEBHOOKS: envBool('CLAUDE_CODE_KAIROS_GITHUB_WEBHOOKS', false),
|
||||
BRIDGE_MODE: envBool('CLAUDE_CODE_BRIDGE_MODE', false),
|
||||
DAEMON: envBool('CLAUDE_CODE_DAEMON', false),
|
||||
VOICE_MODE: envBool('CLAUDE_CODE_VOICE_MODE', false),
|
||||
@@ -15,6 +17,17 @@ const FEATURE_FLAGS: Record<string, boolean> = {
|
||||
ABLATION_BASELINE: false, // always off for external builds
|
||||
DUMP_SYSTEM_PROMPT: envBool('CLAUDE_CODE_DUMP_SYSTEM_PROMPT', false),
|
||||
BG_SESSIONS: envBool('CLAUDE_CODE_BG_SESSIONS', false),
|
||||
HISTORY_SNIP: envBool('CLAUDE_CODE_HISTORY_SNIP', false),
|
||||
WORKFLOW_SCRIPTS: envBool('CLAUDE_CODE_WORKFLOW_SCRIPTS', false),
|
||||
CCR_REMOTE_SETUP: envBool('CLAUDE_CODE_CCR_REMOTE_SETUP', false),
|
||||
EXPERIMENTAL_SKILL_SEARCH: envBool('CLAUDE_CODE_EXPERIMENTAL_SKILL_SEARCH', false),
|
||||
ULTRAPLAN: envBool('CLAUDE_CODE_ULTRAPLAN', false),
|
||||
TORCH: envBool('CLAUDE_CODE_TORCH', false),
|
||||
UDS_INBOX: envBool('CLAUDE_CODE_UDS_INBOX', false),
|
||||
FORK_SUBAGENT: envBool('CLAUDE_CODE_FORK_SUBAGENT', false),
|
||||
BUDDY: envBool('CLAUDE_CODE_BUDDY', false),
|
||||
MCP_SKILLS: envBool('CLAUDE_CODE_MCP_SKILLS', false),
|
||||
REACTIVE_COMPACT: envBool('CLAUDE_CODE_REACTIVE_COMPACT', false),
|
||||
}
|
||||
|
||||
function envBool(key: string, fallback: boolean): boolean {
|
||||
|
||||
10
src/types/bun-globals.d.ts
vendored
Normal file
10
src/types/bun-globals.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// Bun runtime global type augmentations.
|
||||
// The bun-types package provides these when installed; this file acts as a
|
||||
// lightweight fallback so the project type-checks without it.
|
||||
|
||||
interface ImportMeta {
|
||||
/** Bun: absolute path of the directory containing the current file */
|
||||
dir: string
|
||||
/** Node 21+ / Bun: same as dir */
|
||||
dirname: string
|
||||
}
|
||||
415
src/types/message.ts
Normal file
415
src/types/message.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Pure message type definitions extracted to break import cycles.
|
||||
*
|
||||
* This file contains only type definitions with no runtime dependencies.
|
||||
* Message types are discriminated unions based on `.type` field.
|
||||
* System messages are further discriminated by `.subtype`.
|
||||
*/
|
||||
|
||||
import type { UUID } from 'crypto'
|
||||
import type {
|
||||
BetaContentBlock,
|
||||
BetaMessage,
|
||||
BetaRawMessageStreamEvent,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type {
|
||||
ContentBlockParam,
|
||||
} from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import type { APIError } from '@anthropic-ai/sdk'
|
||||
import type { PermissionMode } from './permissions.js'
|
||||
|
||||
// ============================================================================
|
||||
// Scalar / Enum Types
|
||||
// ============================================================================
|
||||
|
||||
/** Where a message originated. undefined = human (typed at keyboard). */
|
||||
export type MessageOrigin =
|
||||
| 'agent'
|
||||
| 'teammate'
|
||||
| 'command'
|
||||
| 'system'
|
||||
| 'hook'
|
||||
| undefined
|
||||
|
||||
/** Direction for partial compact summarization. */
|
||||
export type PartialCompactDirection = 'earlier' | 'later'
|
||||
|
||||
/** System message severity levels. */
|
||||
export type SystemMessageLevel = 'info' | 'warning' | 'error'
|
||||
|
||||
/** Hook execution info for stop hooks. */
|
||||
export interface StopHookInfo {
|
||||
hookName: string
|
||||
executionTime?: number
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Progress Types
|
||||
// ============================================================================
|
||||
|
||||
/** Generic progress data for ongoing tool operations. */
|
||||
export interface Progress {
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Progress message for streaming tool execution updates. */
|
||||
export interface ProgressMessage<P extends Progress = Progress> {
|
||||
type: 'progress'
|
||||
data: P
|
||||
toolUseID: string
|
||||
parentToolUseID: string
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AssistantMessage
|
||||
// ============================================================================
|
||||
|
||||
export interface AssistantMessage {
|
||||
type: 'assistant'
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
message: BetaMessage
|
||||
requestId?: string
|
||||
isMeta?: true
|
||||
isVirtual?: true
|
||||
isApiErrorMessage?: boolean
|
||||
apiError?: string
|
||||
error?: unknown
|
||||
errorDetails?: string
|
||||
advisorModel?: string
|
||||
/** AgentId of the agent that produced this message. */
|
||||
agentId?: string
|
||||
/** Caller info for debugging/display. */
|
||||
caller?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UserMessage
|
||||
// ============================================================================
|
||||
|
||||
export interface UserMessage {
|
||||
type: 'user'
|
||||
message: {
|
||||
role: 'user'
|
||||
content: string | ContentBlockParam[]
|
||||
}
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
isMeta?: true
|
||||
isVisibleInTranscriptOnly?: true
|
||||
isVirtual?: true
|
||||
isCompactSummary?: true
|
||||
toolUseResult?: unknown
|
||||
mcpMeta?: {
|
||||
_meta?: Record<string, unknown>
|
||||
structuredContent?: Record<string, unknown>
|
||||
}
|
||||
imagePasteIds?: number[]
|
||||
sourceToolAssistantUUID?: UUID
|
||||
permissionMode?: PermissionMode
|
||||
summarizeMetadata?: {
|
||||
messagesSummarized: number
|
||||
userContext?: string
|
||||
direction?: PartialCompactDirection
|
||||
}
|
||||
origin?: MessageOrigin
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SystemMessage (base) + all subtypes
|
||||
// ============================================================================
|
||||
|
||||
/** Base fields shared by all system messages. */
|
||||
interface SystemMessageBase {
|
||||
type: 'system'
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
isMeta?: boolean
|
||||
content?: string
|
||||
level?: SystemMessageLevel
|
||||
toolUseID?: string
|
||||
}
|
||||
|
||||
export interface SystemInformationalMessage extends SystemMessageBase {
|
||||
subtype: 'informational'
|
||||
content: string
|
||||
level: SystemMessageLevel
|
||||
preventContinuation?: boolean
|
||||
}
|
||||
|
||||
export interface SystemAPIErrorMessage extends SystemMessageBase {
|
||||
subtype: 'api_error'
|
||||
level: 'error'
|
||||
error: APIError
|
||||
cause?: Error
|
||||
retryInMs: number
|
||||
retryAttempt: number
|
||||
maxRetries: number
|
||||
}
|
||||
|
||||
export interface SystemLocalCommandMessage extends SystemMessageBase {
|
||||
subtype: 'local_command'
|
||||
content: string
|
||||
level?: SystemMessageLevel
|
||||
}
|
||||
|
||||
export interface SystemStopHookSummaryMessage extends SystemMessageBase {
|
||||
subtype: 'stop_hook_summary'
|
||||
hookCount: number
|
||||
hookInfos: StopHookInfo[]
|
||||
hookErrors: string[]
|
||||
preventedContinuation: boolean
|
||||
stopReason: string | undefined
|
||||
hasOutput: boolean
|
||||
level: SystemMessageLevel
|
||||
hookLabel?: string
|
||||
totalDurationMs?: number
|
||||
}
|
||||
|
||||
export interface SystemBridgeStatusMessage extends SystemMessageBase {
|
||||
subtype: 'bridge_status'
|
||||
content: string
|
||||
url: string
|
||||
upgradeNudge?: string
|
||||
}
|
||||
|
||||
export interface SystemTurnDurationMessage extends SystemMessageBase {
|
||||
subtype: 'turn_duration'
|
||||
durationMs: number
|
||||
budgetTokens?: number
|
||||
budgetLimit?: number
|
||||
budgetNudges?: number
|
||||
messageCount?: number
|
||||
}
|
||||
|
||||
export interface SystemThinkingMessage extends SystemMessageBase {
|
||||
subtype: 'thinking'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SystemMemorySavedMessage extends SystemMessageBase {
|
||||
subtype: 'memory_saved'
|
||||
writtenPaths: string[]
|
||||
}
|
||||
|
||||
export interface SystemAwaySummaryMessage extends SystemMessageBase {
|
||||
subtype: 'away_summary'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SystemAgentsKilledMessage extends SystemMessageBase {
|
||||
subtype: 'agents_killed'
|
||||
}
|
||||
|
||||
export interface SystemCompactBoundaryMessage extends SystemMessageBase {
|
||||
subtype: 'compact_boundary'
|
||||
content: string
|
||||
compactMetadata?: {
|
||||
trigger: 'manual' | 'auto'
|
||||
preTokens: number
|
||||
userContext?: string
|
||||
messagesSummarized?: number
|
||||
preservedSegment?: {
|
||||
tailUuid?: string
|
||||
headUuid?: string
|
||||
}
|
||||
}
|
||||
logicalParentUuid?: UUID
|
||||
}
|
||||
|
||||
export interface SystemMicrocompactBoundaryMessage extends SystemMessageBase {
|
||||
subtype: 'microcompact_boundary'
|
||||
content: string
|
||||
microcompactMetadata?: {
|
||||
trigger: 'auto'
|
||||
preTokens: number
|
||||
tokensSaved: number
|
||||
compactedToolIds: string[]
|
||||
clearedAttachmentUUIDs: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface SystemPermissionRetryMessage extends SystemMessageBase {
|
||||
subtype: 'permission_retry'
|
||||
content: string
|
||||
commands: string[]
|
||||
}
|
||||
|
||||
export interface SystemScheduledTaskFireMessage extends SystemMessageBase {
|
||||
subtype: 'scheduled_task_fire'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SystemApiMetricsMessage extends SystemMessageBase {
|
||||
subtype: 'api_metrics'
|
||||
ttftMs: number
|
||||
otps: number
|
||||
isP50?: boolean
|
||||
hookDurationMs?: number
|
||||
turnDurationMs?: number
|
||||
toolDurationMs?: number
|
||||
classifierDurationMs?: number
|
||||
toolCount?: number
|
||||
hookCount?: number
|
||||
classifierCount?: number
|
||||
configWriteCount?: number
|
||||
}
|
||||
|
||||
/** Discriminated union of all system message subtypes. */
|
||||
export type SystemMessage =
|
||||
| SystemInformationalMessage
|
||||
| SystemAPIErrorMessage
|
||||
| SystemLocalCommandMessage
|
||||
| SystemStopHookSummaryMessage
|
||||
| SystemBridgeStatusMessage
|
||||
| SystemTurnDurationMessage
|
||||
| SystemThinkingMessage
|
||||
| SystemMemorySavedMessage
|
||||
| SystemAwaySummaryMessage
|
||||
| SystemAgentsKilledMessage
|
||||
| SystemCompactBoundaryMessage
|
||||
| SystemMicrocompactBoundaryMessage
|
||||
| SystemPermissionRetryMessage
|
||||
| SystemScheduledTaskFireMessage
|
||||
| SystemApiMetricsMessage
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentMessage
|
||||
// ============================================================================
|
||||
|
||||
export interface AttachmentMessage<A extends Record<string, unknown> = Record<string, unknown>> {
|
||||
type: 'attachment'
|
||||
attachment: A & { type: string }
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
isMeta?: true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TombstoneMessage
|
||||
// ============================================================================
|
||||
|
||||
export interface TombstoneMessage {
|
||||
type: 'tombstone'
|
||||
originalType: 'assistant' | 'user' | 'system'
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ToolUseSummaryMessage
|
||||
// ============================================================================
|
||||
|
||||
export interface ToolUseSummaryMessage {
|
||||
type: 'tool_use_summary'
|
||||
summary: string
|
||||
precedingToolUseIds: string[]
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HookResultMessage
|
||||
// ============================================================================
|
||||
|
||||
export interface HookResultMessage {
|
||||
type: 'user'
|
||||
message: {
|
||||
role: 'user'
|
||||
content: ContentBlockParam[]
|
||||
}
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
isMeta?: true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Discriminated Message Union
|
||||
// ============================================================================
|
||||
|
||||
/** Union of all message types used in the conversation history. */
|
||||
export type Message =
|
||||
| AssistantMessage
|
||||
| UserMessage
|
||||
| SystemMessage
|
||||
| AttachmentMessage
|
||||
| ProgressMessage
|
||||
| TombstoneMessage
|
||||
|
||||
// ============================================================================
|
||||
// Grouped / Collapsed display types
|
||||
// ============================================================================
|
||||
|
||||
export interface GroupedToolUseMessage {
|
||||
type: 'assistant'
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
message: BetaMessage
|
||||
toolUseCount: number
|
||||
}
|
||||
|
||||
export interface CollapsedReadSearchGroup {
|
||||
type: 'assistant'
|
||||
uuid: UUID
|
||||
timestamp: string
|
||||
message: BetaMessage
|
||||
collapsedCount: number
|
||||
}
|
||||
|
||||
/** Messages that can be rendered in UI. */
|
||||
export type RenderableMessage =
|
||||
| AssistantMessage
|
||||
| UserMessage
|
||||
| SystemMessage
|
||||
| AttachmentMessage
|
||||
| GroupedToolUseMessage
|
||||
| CollapsedReadSearchGroup
|
||||
|
||||
// ============================================================================
|
||||
// Normalized Message Types
|
||||
// ============================================================================
|
||||
|
||||
/** Normalized assistant message with single content block. */
|
||||
export interface NormalizedAssistantMessage extends AssistantMessage {
|
||||
message: BetaMessage & {
|
||||
content: [BetaContentBlock]
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalized user message with content always as array. */
|
||||
export interface NormalizedUserMessage extends UserMessage {
|
||||
message: {
|
||||
role: 'user'
|
||||
content: ContentBlockParam[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Union of all normalized message types for API processing. */
|
||||
export type NormalizedMessage =
|
||||
| NormalizedAssistantMessage
|
||||
| NormalizedUserMessage
|
||||
| SystemMessage
|
||||
| AttachmentMessage
|
||||
| ProgressMessage
|
||||
| TombstoneMessage
|
||||
|
||||
// ============================================================================
|
||||
// Stream / Event Types
|
||||
// ============================================================================
|
||||
|
||||
/** Event fired at the start of a stream request. */
|
||||
export interface RequestStartEvent {
|
||||
type: 'stream_request_start'
|
||||
}
|
||||
|
||||
/** Wrapper for streaming events from the Anthropic API. */
|
||||
export interface StreamEvent {
|
||||
type: 'stream_event'
|
||||
event: BetaRawMessageStreamEvent
|
||||
ttftMs?: number
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun-types", "node"],
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"bun:bundle": ["./src/types/bun-bundle.d.ts"]
|
||||
|
||||
3
web/.env.example
Normal file
3
web/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:3001
|
||||
ANTHROPIC_API_KEY=
|
||||
3
web/.eslintrc.json
Normal file
3
web/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
36
web/app/api/chat/route.ts
Normal file
36
web/app/api/chat/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(process.env.ANTHROPIC_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Backend request failed" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the response through
|
||||
return new NextResponse(response.body, {
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Chat API error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
85
web/app/api/export/route.ts
Normal file
85
web/app/api/export/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { Conversation, ExportOptions } from "@/lib/types";
|
||||
import { toMarkdown } from "@/lib/export/markdown";
|
||||
import { toJSON } from "@/lib/export/json";
|
||||
import { toHTML } from "@/lib/export/html";
|
||||
import { toPlainText } from "@/lib/export/plaintext";
|
||||
|
||||
interface ExportRequest {
|
||||
conversation: Conversation;
|
||||
options: ExportOptions;
|
||||
}
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
markdown: "text/markdown; charset=utf-8",
|
||||
json: "application/json",
|
||||
html: "text/html; charset=utf-8",
|
||||
plaintext: "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
const EXT: Record<string, string> = {
|
||||
markdown: "md",
|
||||
json: "json",
|
||||
html: "html",
|
||||
plaintext: "txt",
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: ExportRequest;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { conversation, options } = body;
|
||||
if (!conversation || !options) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing conversation or options" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { format } = options;
|
||||
|
||||
if (format === "pdf") {
|
||||
// PDF is handled client-side via window.print()
|
||||
return NextResponse.json(
|
||||
{ error: "PDF export is handled client-side" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let content: string;
|
||||
switch (format) {
|
||||
case "markdown":
|
||||
content = toMarkdown(conversation, options);
|
||||
break;
|
||||
case "json":
|
||||
content = toJSON(conversation, options);
|
||||
break;
|
||||
case "html":
|
||||
content = toHTML(conversation, options);
|
||||
break;
|
||||
case "plaintext":
|
||||
content = toPlainText(conversation, options);
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown format: ${format}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const slug = conversation.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 50);
|
||||
const filename = `${slug || "conversation"}.${EXT[format]}`;
|
||||
|
||||
return new NextResponse(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": MIME[format],
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
55
web/app/api/files/read/route.ts
Normal file
55
web/app/api/files/read/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const filePath = request.nextUrl.searchParams.get("path");
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "path parameter required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
return NextResponse.json({ error: "path is a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = resolvedPath.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
// Binary images: return base64 data URL
|
||||
if (ext in IMAGE_MIME) {
|
||||
const buffer = await fs.readFile(resolvedPath);
|
||||
const base64 = buffer.toString("base64");
|
||||
return NextResponse.json({
|
||||
content: `data:${IMAGE_MIME[ext]};base64,${base64}`,
|
||||
isImage: true,
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Text (including SVG)
|
||||
const content = await fs.readFile(resolvedPath, "utf-8");
|
||||
return NextResponse.json({
|
||||
content,
|
||||
isImage: ext === "svg",
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 404 });
|
||||
}
|
||||
}
|
||||
31
web/app/api/files/write/route.ts
Normal file
31
web/app/api/files/write/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { path?: string; content?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: filePath, content } = body;
|
||||
if (!filePath || content === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "path and content are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
try {
|
||||
await fs.writeFile(resolvedPath, content, "utf-8");
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
return NextResponse.json({ success: true, size: stats.size });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
42
web/app/api/share/[shareId]/route.ts
Normal file
42
web/app/api/share/[shareId]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getShare, revokeShare, verifySharePassword } from "@/lib/share-store";
|
||||
|
||||
interface RouteContext {
|
||||
params: { shareId: string };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: RouteContext) {
|
||||
const { shareId } = params;
|
||||
const share = getShare(shareId);
|
||||
|
||||
if (!share) {
|
||||
return NextResponse.json({ error: "Share not found or expired" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (share.visibility === "password") {
|
||||
const pw = req.headers.get("x-share-password") ?? req.nextUrl.searchParams.get("password");
|
||||
if (!pw || !verifySharePassword(shareId, pw)) {
|
||||
return NextResponse.json({ error: "Password required", requiresPassword: true }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: share.id,
|
||||
title: share.conversation.title,
|
||||
messages: share.conversation.messages,
|
||||
model: share.conversation.model,
|
||||
createdAt: share.conversation.createdAt,
|
||||
shareCreatedAt: share.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
|
||||
const { shareId } = params;
|
||||
const deleted = revokeShare(shareId);
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: "Share not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
50
web/app/api/share/route.ts
Normal file
50
web/app/api/share/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { Conversation } from "@/lib/types";
|
||||
import type { ShareVisibility, ShareExpiry } from "@/lib/share-store";
|
||||
import { createShare } from "@/lib/share-store";
|
||||
|
||||
interface CreateShareRequest {
|
||||
conversation: Conversation;
|
||||
visibility: ShareVisibility;
|
||||
password?: string;
|
||||
expiry: ShareExpiry;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: CreateShareRequest;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { conversation, visibility, password, expiry } = body;
|
||||
if (!conversation || !visibility || !expiry) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (visibility === "password" && !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password required for password-protected shares" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const shareId = nanoid(12);
|
||||
const share = createShare(shareId, { conversation, visibility, password, expiry });
|
||||
|
||||
const origin = req.headers.get("origin") ?? "";
|
||||
const url = `${origin}/share/${shareId}`;
|
||||
|
||||
return NextResponse.json({
|
||||
id: share.id,
|
||||
conversationId: share.conversationId,
|
||||
visibility: share.visibility,
|
||||
hasPassword: !!share.passwordHash,
|
||||
expiry: share.expiry,
|
||||
expiresAt: share.expiresAt,
|
||||
createdAt: share.createdAt,
|
||||
url,
|
||||
});
|
||||
}
|
||||
227
web/app/globals.css
Normal file
227
web/app/globals.css
Normal file
@@ -0,0 +1,227 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* =====================================================
|
||||
DESIGN TOKENS — CSS Custom Properties
|
||||
Dark theme is default; add .light to <html> for light
|
||||
===================================================== */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--color-bg-primary: #09090b;
|
||||
--color-bg-secondary: #18181b;
|
||||
--color-bg-elevated: #27272a;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #fafafa;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-muted: #52525b;
|
||||
|
||||
/* Accent (brand purple) */
|
||||
--color-accent: #8b5cf6;
|
||||
--color-accent-hover: #7c3aed;
|
||||
--color-accent-active: #6d28d9;
|
||||
--color-accent-foreground: #ffffff;
|
||||
|
||||
/* Borders */
|
||||
--color-border: #27272a;
|
||||
--color-border-hover: #3f3f46;
|
||||
|
||||
/* Status */
|
||||
--color-success: #22c55e;
|
||||
--color-success-bg: rgba(34, 197, 94, 0.12);
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-bg: rgba(245, 158, 11, 0.12);
|
||||
--color-error: #ef4444;
|
||||
--color-error-bg: rgba(239, 68, 68, 0.12);
|
||||
--color-info: #3b82f6;
|
||||
--color-info-bg: rgba(59, 130, 246, 0.12);
|
||||
|
||||
/* Code */
|
||||
--color-code-bg: #1f1f23;
|
||||
--color-code-text: #a78bfa;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Animation tokens */
|
||||
--transition-fast: 100ms ease;
|
||||
--transition-normal: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Tailwind / shadcn compat (HSL channel values) */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 3.7% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 3.7% 15.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 263.4 70% 50.4%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 263.4 70% 50.4%;
|
||||
}
|
||||
|
||||
/* Light theme override */
|
||||
.light {
|
||||
--color-bg-primary: #fafafa;
|
||||
--color-bg-secondary: #f4f4f5;
|
||||
--color-bg-elevated: #ffffff;
|
||||
|
||||
--color-text-primary: #09090b;
|
||||
--color-text-secondary: #52525b;
|
||||
--color-text-muted: #a1a1aa;
|
||||
|
||||
--color-accent: #7c3aed;
|
||||
--color-accent-hover: #6d28d9;
|
||||
--color-accent-active: #5b21b6;
|
||||
--color-accent-foreground: #ffffff;
|
||||
|
||||
--color-border: #e4e4e7;
|
||||
--color-border-hover: #d4d4d8;
|
||||
|
||||
--color-success: #16a34a;
|
||||
--color-success-bg: rgba(22, 163, 74, 0.1);
|
||||
--color-warning: #d97706;
|
||||
--color-warning-bg: rgba(217, 119, 6, 0.1);
|
||||
--color-error: #dc2626;
|
||||
--color-error-bg: rgba(220, 38, 38, 0.1);
|
||||
--color-info: #2563eb;
|
||||
--color-info-bg: rgba(37, 99, 235, 0.1);
|
||||
|
||||
--color-code-bg: #f4f4f5;
|
||||
--color-code-text: #7c3aed;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 262.1 83.3% 57.8%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 262.1 83.3% 57.8%;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Focus visible ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
ANIMATIONS
|
||||
===================================================== */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(8px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideDown {
|
||||
from { transform: translateY(-8px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideDownOut {
|
||||
from { transform: translateY(0); opacity: 1; }
|
||||
to { transform: translateY(8px); opacity: 0; }
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes scaleOut {
|
||||
from { transform: scale(1); opacity: 1; }
|
||||
to { transform: scale(0.95); opacity: 0; }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes progress {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
SCROLLBAR
|
||||
===================================================== */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-hover);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
56
web/app/layout.tsx
Normal file
56
web/app/layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/layout/ThemeProvider";
|
||||
import { ToastProvider } from "@/components/notifications/ToastProvider";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jetbrainsMono = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../public/fonts/JetBrainsMono-Regular.woff2",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../public/fonts/JetBrainsMono-Medium.woff2",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "monospace"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Claude Code",
|
||||
description: "Claude Code — AI-powered development assistant",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
web/app/page.tsx
Normal file
5
web/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChatLayout } from "@/components/chat/ChatLayout";
|
||||
|
||||
export default function Home() {
|
||||
return <ChatLayout />;
|
||||
}
|
||||
68
web/components/a11y/Announcer.tsx
Normal file
68
web/components/a11y/Announcer.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
interface AnnouncerContextValue {
|
||||
announce: (message: string, politeness?: "polite" | "assertive") => void;
|
||||
}
|
||||
|
||||
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Provides a programmatic screen-reader announcement API via context.
|
||||
* Place <AnnouncerProvider> near the root of the app, then call `useAnnouncer()`
|
||||
* anywhere to imperatively announce status changes.
|
||||
*
|
||||
* @example
|
||||
* const { announce } = useAnnouncer();
|
||||
* announce("File uploaded successfully");
|
||||
* announce("Error: request failed", "assertive");
|
||||
*/
|
||||
export function AnnouncerProvider({ children }: { children: ReactNode }) {
|
||||
const [politeMsg, setPoliteMsg] = useState("");
|
||||
const [assertiveMsg, setAssertiveMsg] = useState("");
|
||||
const politeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const assertiveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const announce = useCallback((message: string, politeness: "polite" | "assertive" = "polite") => {
|
||||
if (politeness === "assertive") {
|
||||
setAssertiveMsg("");
|
||||
if (assertiveTimer.current) clearTimeout(assertiveTimer.current);
|
||||
assertiveTimer.current = setTimeout(() => setAssertiveMsg(message), 50);
|
||||
} else {
|
||||
setPoliteMsg("");
|
||||
if (politeTimer.current) clearTimeout(politeTimer.current);
|
||||
politeTimer.current = setTimeout(() => setPoliteMsg(message), 50);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const srStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0,0,0,0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnnouncerContext.Provider value={{ announce }}>
|
||||
{children}
|
||||
<div aria-live="polite" aria-atomic="true" style={srStyle}>
|
||||
{politeMsg}
|
||||
</div>
|
||||
<div aria-live="assertive" aria-atomic="true" style={srStyle}>
|
||||
{assertiveMsg}
|
||||
</div>
|
||||
</AnnouncerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnnouncer() {
|
||||
const ctx = useContext(AnnouncerContext);
|
||||
if (!ctx) throw new Error("useAnnouncer must be used within <AnnouncerProvider>");
|
||||
return ctx;
|
||||
}
|
||||
72
web/components/a11y/FocusTrap.tsx
Normal file
72
web/components/a11y/FocusTrap.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
interface FocusTrapProps {
|
||||
children: ReactNode;
|
||||
/** When false, the trap is inactive (e.g. when the panel is hidden) */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTORS = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(", ");
|
||||
|
||||
/**
|
||||
* Traps keyboard focus within its children when `active` is true.
|
||||
* Use for modals, drawers, and other overlay patterns.
|
||||
* Note: Radix Dialog handles focus trapping natively — use this only for
|
||||
* custom overlay components that don't use Radix primitives.
|
||||
*/
|
||||
export function FocusTrap({ children, active = true }: FocusTrapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const focusable = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)).filter(
|
||||
(el) => !el.closest("[aria-hidden='true']")
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const els = focusable();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Move focus into the trap on mount
|
||||
const els = focusable();
|
||||
if (els.length > 0 && !container.contains(document.activeElement)) {
|
||||
els[0].focus();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [active]);
|
||||
|
||||
return <div ref={containerRef}>{children}</div>;
|
||||
}
|
||||
55
web/components/a11y/LiveRegion.tsx
Normal file
55
web/components/a11y/LiveRegion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface LiveRegionProps {
|
||||
/** The message to announce. Changing this value triggers an announcement. */
|
||||
message: string;
|
||||
/**
|
||||
* "polite" — waits for user to be idle (new chat messages, status updates)
|
||||
* "assertive" — interrupts immediately (errors, critical alerts)
|
||||
*/
|
||||
politeness?: "polite" | "assertive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed aria-live region that announces dynamic content to screen readers.
|
||||
* Clears after 500 ms to ensure repeated identical messages are re-announced.
|
||||
*/
|
||||
export function LiveRegion({ message, politeness = "polite" }: LiveRegionProps) {
|
||||
const [announced, setAnnounced] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return;
|
||||
|
||||
// Clear first to force re-announcement of identical messages
|
||||
setAnnounced("");
|
||||
timerRef.current = setTimeout(() => setAnnounced(message), 50);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live={politeness}
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0, 0, 0, 0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
}}
|
||||
>
|
||||
{announced}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
web/components/a11y/SkipToContent.tsx
Normal file
18
web/components/a11y/SkipToContent.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
export function SkipToContent() {
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className={[
|
||||
"sr-only focus:not-sr-only",
|
||||
"focus:fixed focus:top-4 focus:left-4 focus:z-50",
|
||||
"focus:px-4 focus:py-2 focus:rounded-md",
|
||||
"focus:bg-brand-600 focus:text-white focus:font-medium focus:text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-300 focus:ring-offset-2",
|
||||
].join(" ")}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
31
web/components/a11y/VisuallyHidden.tsx
Normal file
31
web/components/a11y/VisuallyHidden.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface VisuallyHiddenProps {
|
||||
children: ReactNode;
|
||||
/** When true, renders as a span inline; defaults to span */
|
||||
as?: "span" | "div" | "p";
|
||||
}
|
||||
|
||||
/**
|
||||
* Visually hides content while keeping it accessible to screen readers.
|
||||
* Use for icon-only buttons, supplemental context, etc.
|
||||
*/
|
||||
export function VisuallyHidden({ children, as: Tag = "span" }: VisuallyHiddenProps) {
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0, 0, 0, 0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
170
web/components/adapted/Markdown.tsx
Normal file
170
web/components/adapted/Markdown.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Web-adapted Markdown renderer.
|
||||
*
|
||||
* The terminal Markdown (src/components/Markdown.tsx) uses marked + Ink's
|
||||
* <Ansi> / <Box> to render tokenised markdown as coloured ANSI output.
|
||||
* This web version uses react-markdown + remark-gfm + rehype-highlight, which
|
||||
* are already present in the web package, to render proper HTML with Tailwind
|
||||
* prose styles.
|
||||
*
|
||||
* Props are intentionally compatible with the terminal version so callers can
|
||||
* swap between them via the platform conditional.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MarkdownProps {
|
||||
/** Markdown source string — matches the terminal component's children prop. */
|
||||
children: string;
|
||||
/** When true, render all text as visually dimmed (muted colour). */
|
||||
dimColor?: boolean;
|
||||
/** Extra class names applied to the prose wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Inline code / pre renderers ─────────────────────────────────────────────
|
||||
|
||||
function InlineCode({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<code className="px-1 py-0.5 rounded text-xs font-mono bg-surface-850 text-brand-300 border border-surface-700">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Pre({ children }: PreProps) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-md bg-surface-900 border border-surface-700 p-3 my-2 text-xs font-mono leading-relaxed">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Markdown({ children, dimColor = false, className }: MarkdownProps) {
|
||||
// Memoised to avoid re-parsing on every parent render.
|
||||
const content = useMemo(() => children, [children]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"markdown-body text-sm leading-relaxed font-mono",
|
||||
dimColor ? "text-surface-500" : "text-surface-100",
|
||||
|
||||
// Headings
|
||||
"[&_h1]:text-base [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-surface-50",
|
||||
"[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mb-1.5 [&_h2]:mt-2.5 [&_h2]:text-surface-100",
|
||||
"[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_h3]:text-surface-200",
|
||||
|
||||
// Paragraphs
|
||||
"[&_p]:my-1 [&_p]:leading-relaxed",
|
||||
|
||||
// Lists
|
||||
"[&_ul]:my-1 [&_ul]:pl-4 [&_ul]:list-disc",
|
||||
"[&_ol]:my-1 [&_ol]:pl-4 [&_ol]:list-decimal",
|
||||
"[&_li]:my-0.5",
|
||||
|
||||
// Blockquote
|
||||
"[&_blockquote]:border-l-2 [&_blockquote]:border-brand-500 [&_blockquote]:pl-3",
|
||||
"[&_blockquote]:my-2 [&_blockquote]:text-surface-400 [&_blockquote]:italic",
|
||||
|
||||
// Horizontal rule
|
||||
"[&_hr]:border-surface-700 [&_hr]:my-3",
|
||||
|
||||
// Tables (GFM)
|
||||
"[&_table]:w-full [&_table]:text-xs [&_table]:border-collapse [&_table]:my-2",
|
||||
"[&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:border [&_th]:border-surface-700 [&_th]:bg-surface-800 [&_th]:font-semibold",
|
||||
"[&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-surface-700",
|
||||
"[&_tr:nth-child(even)_td]:bg-surface-900/40",
|
||||
|
||||
// Links
|
||||
"[&_a]:text-brand-400 [&_a]:no-underline [&_a:hover]:underline",
|
||||
|
||||
// Strong / em
|
||||
"[&_strong]:font-bold [&_strong]:text-surface-50",
|
||||
"[&_em]:italic",
|
||||
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className: cls, children: codeChildren, ...rest }) {
|
||||
const isBlock = /language-/.test(cls ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={cn("block text-surface-200", cls)} {...rest}>
|
||||
{codeChildren}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return <InlineCode {...rest}>{codeChildren}</InlineCode>;
|
||||
},
|
||||
pre: ({ children: preChildren }) => <Pre>{preChildren}</Pre>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Table component (matches MarkdownTable.tsx surface) ─────────────────────
|
||||
|
||||
export interface MarkdownTableProps {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownTable({ headers, rows, className }: MarkdownTableProps) {
|
||||
return (
|
||||
<div className={cn("overflow-x-auto my-2", className)}>
|
||||
<table className="w-full text-xs border-collapse font-mono">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="px-2 py-1 text-left border border-surface-700 bg-surface-800 font-semibold text-surface-200"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
className={cn(
|
||||
"px-2 py-1 border border-surface-700 text-surface-300",
|
||||
ri % 2 === 1 && "bg-surface-900/40"
|
||||
)}
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
web/components/adapted/Spinner.tsx
Normal file
151
web/components/adapted/Spinner.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Web-adapted Spinner.
|
||||
*
|
||||
* The terminal Spinner (src/components/Spinner.tsx) drives animation via
|
||||
* useAnimationFrame and renders Unicode braille/block characters with ANSI
|
||||
* colour via Ink's <Text>. In the browser we replace that with a pure-CSS
|
||||
* spinning ring, preserving the same optional `tip` text and `mode` prop
|
||||
* surface so callers can swap in this component without changing props.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Mirrors the SpinnerMode type from src/components/Spinner/index.ts */
|
||||
export type SpinnerMode =
|
||||
| "queued"
|
||||
| "loading"
|
||||
| "thinking"
|
||||
| "auto"
|
||||
| "disabled";
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** Visual mode — controls colour/appearance. */
|
||||
mode?: SpinnerMode;
|
||||
/** Optional tip text shown next to the spinner. */
|
||||
spinnerTip?: string;
|
||||
/** Override message replaces the default verb. */
|
||||
overrideMessage?: string | null;
|
||||
/** Additional suffix appended after the main label. */
|
||||
spinnerSuffix?: string | null;
|
||||
/** When true the spinner renders inline instead of as a block row. */
|
||||
inline?: boolean;
|
||||
/** Extra class names for the wrapper element. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Colour map ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MODE_RING_CLASS: Record<SpinnerMode, string> = {
|
||||
queued: "border-surface-500",
|
||||
loading: "border-brand-400",
|
||||
thinking: "border-brand-500",
|
||||
auto: "border-brand-400",
|
||||
disabled: "border-surface-600",
|
||||
};
|
||||
|
||||
const MODE_TEXT_CLASS: Record<SpinnerMode, string> = {
|
||||
queued: "text-surface-400",
|
||||
loading: "text-brand-300",
|
||||
thinking: "text-brand-300",
|
||||
auto: "text-brand-300",
|
||||
disabled: "text-surface-500",
|
||||
};
|
||||
|
||||
const MODE_LABEL: Record<SpinnerMode, string> = {
|
||||
queued: "Queued…",
|
||||
loading: "Loading…",
|
||||
thinking: "Thinking…",
|
||||
auto: "Working…",
|
||||
disabled: "",
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Spinner({
|
||||
mode = "loading",
|
||||
spinnerTip,
|
||||
overrideMessage,
|
||||
spinnerSuffix,
|
||||
inline = false,
|
||||
className,
|
||||
}: SpinnerProps) {
|
||||
if (mode === "disabled") return null;
|
||||
|
||||
const label =
|
||||
overrideMessage ??
|
||||
spinnerTip ??
|
||||
MODE_LABEL[mode];
|
||||
|
||||
const ringClass = MODE_RING_CLASS[mode];
|
||||
const textClass = MODE_TEXT_CLASS[mode];
|
||||
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label || "Loading"}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
inline ? "inline-flex" : "flex",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* CSS spinning ring */}
|
||||
<span
|
||||
className={cn(
|
||||
"block w-3.5 h-3.5 rounded-full border-2 border-transparent animate-spin flex-shrink-0",
|
||||
ringClass,
|
||||
// Top border only — creates the "gap" in the ring for the spinning effect
|
||||
"[border-top-color:currentColor]"
|
||||
)}
|
||||
style={{ borderTopColor: undefined }}
|
||||
aria-hidden
|
||||
>
|
||||
{/* Inner ring for the visible arc — achieved via box-shadow trick */}
|
||||
</span>
|
||||
|
||||
{(label || spinnerSuffix) && (
|
||||
<span className={cn("text-sm font-mono", textClass)}>
|
||||
{label}
|
||||
{spinnerSuffix && (
|
||||
<span className="text-surface-500 ml-1">{spinnerSuffix}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shimmer / glimmer variant ────────────────────────────────────────────────
|
||||
|
||||
/** Pulsing shimmer bar — web replacement for GlimmerMessage / ShimmerChar. */
|
||||
export function ShimmerBar({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 rounded-full bg-gradient-to-r from-surface-700 via-surface-500 to-surface-700",
|
||||
"bg-[length:200%_100%] animate-shimmer",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline flashing cursor dot — web replacement for FlashingChar. */
|
||||
export function FlashingCursor({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-1.5 h-4 bg-current align-text-bottom ml-0.5",
|
||||
"animate-pulse-soft",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
180
web/components/chat/ChatInput.tsx
Normal file
180
web/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Send, Square, Paperclip } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { streamChat } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAX_MESSAGE_LENGTH } from "@/lib/constants";
|
||||
|
||||
interface ChatInputProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ChatInput({ conversationId }: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const { conversations, settings, addMessage, updateMessage } = useChatStore();
|
||||
const conversation = conversations.find((c) => c.id === conversationId);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
|
||||
// Add user message
|
||||
addMessage(conversationId, {
|
||||
role: "user",
|
||||
content: text,
|
||||
status: "complete",
|
||||
});
|
||||
|
||||
// Add placeholder assistant message
|
||||
const assistantId = addMessage(conversationId, {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const messages = [
|
||||
...(conversation?.messages ?? []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
{ role: "user" as const, content: text },
|
||||
];
|
||||
|
||||
let fullText = "";
|
||||
|
||||
try {
|
||||
for await (const chunk of streamChat(messages, settings.model, controller.signal)) {
|
||||
if (chunk.type === "text" && chunk.content) {
|
||||
fullText += chunk.content;
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: fullText,
|
||||
status: "streaming",
|
||||
});
|
||||
} else if (chunk.type === "done") {
|
||||
break;
|
||||
} else if (chunk.type === "error") {
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: chunk.error ?? "An error occurred",
|
||||
status: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateMessage(conversationId, assistantId, { status: "complete" });
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: "Request failed. Please try again.",
|
||||
status: "error",
|
||||
});
|
||||
} else {
|
||||
updateMessage(conversationId, assistantId, { status: "complete" });
|
||||
}
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [input, isStreaming, conversationId, conversation, settings.model, addMessage, updateMessage]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const adjustHeight = () => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-surface-800 bg-surface-900/50 backdrop-blur-sm px-4 py-3">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-xl border bg-surface-800 px-3 py-2",
|
||||
"border-surface-700 focus-within:border-brand-500 transition-colors"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="p-1 text-surface-500 hover:text-surface-300 transition-colors flex-shrink-0 mb-0.5"
|
||||
aria-label="Attach file"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<label htmlFor="chat-input" className="sr-only">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="chat-input"
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value.slice(0, MAX_MESSAGE_LENGTH));
|
||||
adjustHeight();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message Claude Code..."
|
||||
rows={1}
|
||||
aria-label="Message"
|
||||
className={cn(
|
||||
"flex-1 resize-none bg-transparent text-sm text-surface-100",
|
||||
"placeholder:text-surface-500 focus:outline-none",
|
||||
"min-h-[24px] max-h-[200px] py-0.5"
|
||||
)}
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
aria-label="Stop generation"
|
||||
className="p-1.5 rounded-lg bg-surface-700 text-surface-300 hover:bg-surface-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
<Square className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
aria-label="Send message"
|
||||
aria-disabled={!input.trim()}
|
||||
className={cn(
|
||||
"p-1.5 rounded-lg transition-colors flex-shrink-0",
|
||||
input.trim()
|
||||
? "bg-brand-600 text-white hover:bg-brand-700"
|
||||
: "bg-surface-700 text-surface-500 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Send className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-surface-600 text-center mt-2">
|
||||
Claude can make mistakes. Verify important information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
web/components/chat/ChatLayout.tsx
Normal file
48
web/components/chat/ChatLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { ChatWindow } from "./ChatWindow";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { SkipToContent } from "@/components/a11y/SkipToContent";
|
||||
import { AnnouncerProvider } from "@/components/a11y/Announcer";
|
||||
|
||||
export function ChatLayout() {
|
||||
const { conversations, createConversation, activeConversationId } = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (conversations.length === 0) {
|
||||
createConversation();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnnouncerProvider>
|
||||
<SkipToContent />
|
||||
<div className="flex h-screen bg-surface-950 text-surface-100">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Header />
|
||||
<main
|
||||
id="main-content"
|
||||
aria-label="Chat"
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
{activeConversationId ? (
|
||||
<>
|
||||
<ChatWindow conversationId={activeConversationId} />
|
||||
<ChatInput conversationId={activeConversationId} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-surface-500">
|
||||
Select or create a conversation
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AnnouncerProvider>
|
||||
);
|
||||
}
|
||||
94
web/components/chat/ChatWindow.tsx
Normal file
94
web/components/chat/ChatWindow.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
interface ChatWindowProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ChatWindow({ conversationId }: ChatWindowProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { conversations } = useChatStore();
|
||||
const conversation = conversations.find((c) => c.id === conversationId);
|
||||
const messages = conversation?.messages ?? [];
|
||||
|
||||
const isStreaming = messages.some((m) => m.status === "streaming");
|
||||
|
||||
// Announce the last completed assistant message to screen readers
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const prevLengthRef = useRef(messages.length);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (
|
||||
messages.length > prevLengthRef.current &&
|
||||
lastMsg?.role === "assistant" &&
|
||||
lastMsg.status === "complete"
|
||||
) {
|
||||
// Announce a short preview so screen reader users know a reply arrived
|
||||
const preview = lastMsg.content.slice(0, 100);
|
||||
setAnnouncement("");
|
||||
setTimeout(() => setAnnouncement(`Claude replied: ${preview}`), 50);
|
||||
}
|
||||
prevLengthRef.current = messages.length;
|
||||
}, [messages.length, messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-6">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Bot className="w-6 h-6 text-brand-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-surface-100">How can I help?</h2>
|
||||
<p className="text-sm text-surface-400 mt-1">
|
||||
Start a conversation with Claude Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
aria-busy={isStreaming}
|
||||
aria-label="Conversation"
|
||||
>
|
||||
{/* Polite live region — announces when Claude finishes a reply */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0,0,0,0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
}}
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto py-6 px-4 space-y-6">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
<div ref={bottomRef} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
web/components/chat/MarkdownContent.tsx
Normal file
32
web/components/chat/MarkdownContent.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm prose-invert max-w-none",
|
||||
"prose-p:leading-relaxed prose-p:my-1",
|
||||
"prose-pre:bg-surface-900 prose-pre:border prose-pre:border-surface-700",
|
||||
"prose-code:text-brand-300 prose-code:bg-surface-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs",
|
||||
"prose-pre:code:bg-transparent prose-pre:code:text-surface-100 prose-pre:code:p-0",
|
||||
"prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5",
|
||||
"prose-headings:text-surface-100 prose-headings:font-semibold",
|
||||
"prose-a:text-brand-400 prose-a:no-underline hover:prose-a:underline",
|
||||
"prose-blockquote:border-brand-500 prose-blockquote:text-surface-300",
|
||||
"prose-hr:border-surface-700",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
web/components/chat/MessageBubble.tsx
Normal file
78
web/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { User, Bot, AlertCircle } from "lucide-react";
|
||||
import { cn, extractTextContent } from "@/lib/utils";
|
||||
import type { Message } from "@/lib/types";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.status === "error";
|
||||
const text = extractTextContent(message.content);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"flex gap-3 animate-fade-in",
|
||||
isUser && "flex-row-reverse"
|
||||
)}
|
||||
aria-label={isUser ? "You" : isError ? "Error from Claude" : "Claude"}
|
||||
>
|
||||
{/* Avatar — purely decorative, role conveyed by article label */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
|
||||
isUser
|
||||
? "bg-brand-600 text-white"
|
||||
: isError
|
||||
? "bg-red-900 text-red-300"
|
||||
: "bg-surface-700 text-surface-300"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="w-4 h-4" aria-hidden="true" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Bot className="w-4 h-4" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 max-w-2xl",
|
||||
isUser && "flex justify-end"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl px-4 py-3 text-sm",
|
||||
isUser
|
||||
? "bg-brand-600 text-white rounded-tr-sm"
|
||||
: isError
|
||||
? "bg-red-950 border border-red-800 text-red-200 rounded-tl-sm"
|
||||
: "bg-surface-800 text-surface-100 rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words">{text}</p>
|
||||
) : (
|
||||
<MarkdownContent content={text} />
|
||||
)}
|
||||
{message.status === "streaming" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
114
web/components/chat/VirtualMessageList.tsx
Normal file
114
web/components/chat/VirtualMessageList.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { Message } from "@/lib/types";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
|
||||
/**
|
||||
* Estimated heights used for initial layout. The virtualizer measures actual
|
||||
* heights after render and updates scroll positions accordingly.
|
||||
*/
|
||||
const ESTIMATED_HEIGHT = {
|
||||
short: 80, // typical user message
|
||||
medium: 160, // short assistant reply
|
||||
tall: 320, // code blocks / long replies
|
||||
};
|
||||
|
||||
function estimateMessageHeight(message: Message): number {
|
||||
const text =
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("");
|
||||
|
||||
if (text.length < 100) return ESTIMATED_HEIGHT.short;
|
||||
if (text.length < 500 || text.includes("```")) return ESTIMATED_HEIGHT.medium;
|
||||
return ESTIMATED_HEIGHT.tall;
|
||||
}
|
||||
|
||||
interface VirtualMessageListProps {
|
||||
messages: Message[];
|
||||
/** Whether streaming is in progress — suppresses smooth-scroll so the
|
||||
* autoscroll keeps up with incoming tokens. */
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function VirtualMessageList({ messages, isStreaming }: VirtualMessageListProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: messages.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index) => estimateMessageHeight(messages[index]),
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// Track whether the user has scrolled away from the bottom
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
isAtBottomRef.current = distanceFromBottom < 80;
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive (if already at bottom)
|
||||
useEffect(() => {
|
||||
if (!isAtBottomRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (isStreaming) {
|
||||
// Instant scroll during streaming to keep up with tokens
|
||||
el.scrollTop = el.scrollHeight;
|
||||
} else {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [messages.length, isStreaming]);
|
||||
|
||||
// Also scroll when the last streaming message content changes
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !isAtBottomRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{/* Spacer that gives the virtualizer its total height */}
|
||||
<div
|
||||
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
|
||||
className="max-w-3xl mx-auto px-4 py-6"
|
||||
>
|
||||
{items.map((virtualItem) => {
|
||||
const message = messages[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
className="pb-6"
|
||||
>
|
||||
<MessageBubble message={message} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
web/components/collaboration/AnnotationBadge.tsx
Normal file
50
web/components/collaboration/AnnotationBadge.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnnotationThread } from "./AnnotationThread";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
|
||||
interface AnnotationBadgeProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export function AnnotationBadge({ messageId }: AnnotationBadgeProps) {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
const annotations = ctx.annotations[messageId] ?? [];
|
||||
const unresolved = annotations.filter((a) => !a.resolved);
|
||||
if (annotations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium",
|
||||
"transition-colors border",
|
||||
unresolved.length > 0
|
||||
? "bg-amber-900/30 border-amber-700/50 text-amber-300 hover:bg-amber-900/50"
|
||||
: "bg-surface-800 border-surface-700 text-surface-400 hover:bg-surface-700"
|
||||
)}
|
||||
title={`${annotations.length} comment${annotations.length !== 1 ? "s" : ""}`}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{unresolved.length > 0 ? unresolved.length : annotations.length}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-40 w-80"
|
||||
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
|
||||
>
|
||||
<AnnotationThread messageId={messageId} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
web/components/collaboration/CollaborationProvider.tsx
Normal file
139
web/components/collaboration/CollaborationProvider.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useRef, useMemo } from "react";
|
||||
import { useCollaboration } from "@/hooks/useCollaboration";
|
||||
import { usePresence } from "@/hooks/usePresence";
|
||||
import { CollabSocket } from "@/lib/collaboration/socket";
|
||||
import type { CollabUser, CollabRole } from "@/lib/collaboration/socket";
|
||||
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
|
||||
import type { PresenceState } from "@/lib/collaboration/presence";
|
||||
import type { LinkExpiry, ShareLink } from "@/lib/collaboration/types";
|
||||
import { createShareLink } from "@/lib/collaboration/permissions";
|
||||
|
||||
// ─── Context Shape ────────────────────────────────────────────────────────────
|
||||
|
||||
interface CollaborationContextValue {
|
||||
// Connection
|
||||
isConnected: boolean;
|
||||
sessionId: string;
|
||||
currentUser: CollabUser;
|
||||
|
||||
// Roles & policy
|
||||
myRole: CollabRole | null;
|
||||
toolApprovalPolicy: "owner-only" | "any-collaborator";
|
||||
|
||||
// Presence
|
||||
presence: PresenceState;
|
||||
otherUsers: CollabUser[];
|
||||
typingUsers: CollabUser[];
|
||||
|
||||
// Tool approvals
|
||||
pendingToolUses: PendingToolUse[];
|
||||
approveTool: (toolUseId: string) => void;
|
||||
denyTool: (toolUseId: string) => void;
|
||||
|
||||
// Annotations
|
||||
annotations: Record<string, CollabAnnotation[]>;
|
||||
addAnnotation: (messageId: string, text: string) => void;
|
||||
resolveAnnotation: (annotationId: string, resolved: boolean) => void;
|
||||
replyAnnotation: (annotationId: string, text: string) => void;
|
||||
|
||||
// Presence actions
|
||||
sendCursorUpdate: (pos: number, start?: number, end?: number) => void;
|
||||
notifyTyping: () => void;
|
||||
stopTyping: () => void;
|
||||
|
||||
// Session management
|
||||
generateShareLink: (role: CollabRole, expiry: LinkExpiry) => ShareLink;
|
||||
revokeAccess: (userId: string) => void;
|
||||
changeRole: (userId: string, role: CollabRole) => void;
|
||||
transferOwnership: (userId: string) => void;
|
||||
}
|
||||
|
||||
const CollaborationContext = createContext<CollaborationContextValue | null>(null);
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CollaborationProviderProps {
|
||||
sessionId: string;
|
||||
currentUser: CollabUser;
|
||||
wsUrl?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CollaborationProvider({
|
||||
sessionId,
|
||||
currentUser,
|
||||
wsUrl,
|
||||
children,
|
||||
}: CollaborationProviderProps) {
|
||||
const socketRef = useRef<CollabSocket | null>(null);
|
||||
|
||||
const collab = useCollaboration({ sessionId, currentUser, wsUrl });
|
||||
|
||||
// Access the socket from the ref (set by the hook internally)
|
||||
// Since useCollaboration creates the socket internally, we expose a proxy
|
||||
// via the presence hook's socket param by reaching into the hook return
|
||||
const presence = usePresence({
|
||||
socket: socketRef.current,
|
||||
sessionId,
|
||||
currentUser,
|
||||
});
|
||||
|
||||
const generateShareLink = useMemo(
|
||||
() => (role: CollabRole, expiry: LinkExpiry) =>
|
||||
createShareLink(sessionId, role, expiry, currentUser.id),
|
||||
[sessionId, currentUser.id]
|
||||
);
|
||||
|
||||
const value: CollaborationContextValue = {
|
||||
isConnected: collab.isConnected,
|
||||
sessionId,
|
||||
currentUser,
|
||||
myRole: collab.myRole,
|
||||
toolApprovalPolicy: collab.toolApprovalPolicy,
|
||||
presence: presence.presence,
|
||||
otherUsers: presence.otherUsers,
|
||||
typingUsers: presence.typingUsers,
|
||||
pendingToolUses: collab.pendingToolUses,
|
||||
approveTool: collab.approveTool,
|
||||
denyTool: collab.denyTool,
|
||||
annotations: collab.annotations,
|
||||
addAnnotation: collab.addAnnotation,
|
||||
resolveAnnotation: collab.resolveAnnotation,
|
||||
replyAnnotation: collab.replyAnnotation,
|
||||
sendCursorUpdate: presence.sendCursorUpdate,
|
||||
notifyTyping: presence.notifyTyping,
|
||||
stopTyping: presence.stopTyping,
|
||||
generateShareLink,
|
||||
revokeAccess: collab.revokeAccess,
|
||||
changeRole: collab.changeRole,
|
||||
transferOwnership: collab.transferOwnership,
|
||||
};
|
||||
|
||||
return (
|
||||
<CollaborationContext.Provider value={value}>
|
||||
{children}
|
||||
</CollaborationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Consumer Hook ────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCollaborationContext(): CollaborationContextValue {
|
||||
const ctx = useContext(CollaborationContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useCollaborationContext must be used inside <CollaborationProvider>"
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when there is no active collaboration session.
|
||||
* Use this in components that render outside a CollaborationProvider.
|
||||
*/
|
||||
export function useCollaborationContextOptional(): CollaborationContextValue | null {
|
||||
return useContext(CollaborationContext);
|
||||
}
|
||||
122
web/components/collaboration/CursorGhost.tsx
Normal file
122
web/components/collaboration/CursorGhost.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
import type { CursorState } from "@/lib/collaboration/presence";
|
||||
import type { CollabUser } from "@/lib/collaboration/socket";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CursorGhostProps {
|
||||
/** The textarea ref to measure cursor positions against */
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
interface RenderedCursor {
|
||||
user: CollabUser;
|
||||
cursor: CursorState;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Approximates pixel position of a text offset inside a textarea.
|
||||
* Uses a hidden mirror div that matches the textarea's styling.
|
||||
*/
|
||||
function measureCursorPosition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
offset: number
|
||||
): { top: number; left: number } {
|
||||
const mirror = document.createElement("div");
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
|
||||
mirror.style.position = "absolute";
|
||||
mirror.style.visibility = "hidden";
|
||||
mirror.style.whiteSpace = "pre-wrap";
|
||||
mirror.style.wordWrap = "break-word";
|
||||
mirror.style.width = computed.width;
|
||||
mirror.style.font = computed.font;
|
||||
mirror.style.lineHeight = computed.lineHeight;
|
||||
mirror.style.padding = computed.padding;
|
||||
mirror.style.border = computed.border;
|
||||
mirror.style.boxSizing = computed.boxSizing;
|
||||
|
||||
const text = textarea.value.slice(0, offset);
|
||||
mirror.textContent = text;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "\u200b"; // zero-width space
|
||||
mirror.appendChild(span);
|
||||
|
||||
document.body.appendChild(mirror);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
document.body.removeChild(mirror);
|
||||
|
||||
return {
|
||||
top: spanRect.top - rect.top + textarea.scrollTop,
|
||||
left: spanRect.left - rect.left,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── CursorGhost ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function CursorGhost({ textareaRef }: CursorGhostProps) {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
const [rendered, setRendered] = useState<RenderedCursor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx || !textareaRef.current) return;
|
||||
const textarea = textareaRef.current;
|
||||
const { presence, otherUsers } = ctx;
|
||||
|
||||
const next: RenderedCursor[] = [];
|
||||
for (const user of otherUsers) {
|
||||
const cursor = presence.cursors.get(user.id);
|
||||
if (!cursor) continue;
|
||||
try {
|
||||
const pos = measureCursorPosition(textarea, cursor.position);
|
||||
next.push({ user, cursor, ...pos });
|
||||
} catch {
|
||||
// ignore measurement errors
|
||||
}
|
||||
}
|
||||
setRendered(next);
|
||||
});
|
||||
|
||||
if (!ctx || rendered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<AnimatePresence>
|
||||
{rendered.map(({ user, top, left }) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute flex flex-col items-start"
|
||||
style={{ top, left }}
|
||||
>
|
||||
{/* Cursor caret */}
|
||||
<div
|
||||
className="w-0.5 h-4"
|
||||
style={{ backgroundColor: user.color }}
|
||||
/>
|
||||
{/* Name tag */}
|
||||
<div
|
||||
className="px-1 py-0.5 rounded text-[9px] font-semibold text-white whitespace-nowrap"
|
||||
style={{ backgroundColor: user.color }}
|
||||
>
|
||||
{user.name}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
web/components/collaboration/PresenceAvatars.tsx
Normal file
136
web/components/collaboration/PresenceAvatars.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { getInitials } from "@/lib/collaboration/presence";
|
||||
import { labelForRole } from "@/lib/collaboration/permissions";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Single Avatar ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AvatarProps {
|
||||
name: string;
|
||||
color: string;
|
||||
avatar?: string;
|
||||
role: import("@/lib/collaboration/socket").CollabRole;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
function UserAvatar({ name, color, avatar, role, isActive = true }: AvatarProps) {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className="relative w-7 h-7 rounded-full flex-shrink-0 cursor-default select-none"
|
||||
style={{ boxShadow: `0 0 0 2px ${color}` }}
|
||||
>
|
||||
{avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full rounded-full flex items-center justify-center text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Online indicator dot */}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 right-0 w-2 h-2 rounded-full bg-green-400 border border-surface-900" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"z-50 rounded-md px-2.5 py-1.5 text-xs shadow-md",
|
||||
"bg-surface-800 border border-surface-700 text-surface-100"
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-surface-400">{labelForRole(role)}</p>
|
||||
<Tooltip.Arrow className="fill-surface-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PresenceAvatars ──────────────────────────────────────────────────────────
|
||||
|
||||
export function PresenceAvatars() {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
if (!ctx) return null;
|
||||
|
||||
const { isConnected, otherUsers, currentUser } = ctx;
|
||||
// Show at most 4 avatars + overflow badge
|
||||
const MAX_VISIBLE = 4;
|
||||
const allUsers = [currentUser, ...otherUsers];
|
||||
const visible = allUsers.slice(0, MAX_VISIBLE);
|
||||
const overflow = allUsers.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Connection indicator */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isConnected ? (
|
||||
<Wifi className="w-3.5 h-3.5 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5 text-surface-500 animate-pulse" />
|
||||
)}
|
||||
<span className="text-xs text-surface-500 hidden sm:inline">
|
||||
{isConnected
|
||||
? `${allUsers.length} online`
|
||||
: "Reconnecting…"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stacked avatars */}
|
||||
<div className="flex items-center">
|
||||
<AnimatePresence>
|
||||
{visible.map((user, i) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, scale: 0.5, x: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ duration: 0.2, delay: i * 0.04 }}
|
||||
style={{ zIndex: visible.length - i, marginLeft: i === 0 ? 0 : -8 }}
|
||||
>
|
||||
<UserAvatar
|
||||
name={user.id === currentUser.id ? `${user.name} (you)` : user.name}
|
||||
color={user.color}
|
||||
avatar={user.avatar}
|
||||
role={user.role}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{overflow > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full -ml-2 z-0 flex items-center justify-center",
|
||||
"bg-surface-700 border-2 border-surface-900 text-[10px] font-medium text-surface-300"
|
||||
)}
|
||||
>
|
||||
+{overflow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
web/components/collaboration/TypingIndicator.tsx
Normal file
73
web/components/collaboration/TypingIndicator.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
|
||||
// ─── Animated dots ────────────────────────────────────────────────────────────
|
||||
|
||||
function Dots() {
|
||||
return (
|
||||
<span className="inline-flex items-end gap-0.5 h-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="w-1 h-1 rounded-full bg-surface-400 inline-block"
|
||||
animate={{ y: [0, -3, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TypingIndicator ──────────────────────────────────────────────────────────
|
||||
|
||||
export function TypingIndicator() {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
if (!ctx) return null;
|
||||
|
||||
const { typingUsers } = ctx;
|
||||
if (typingUsers.length === 0) return null;
|
||||
|
||||
let label: string;
|
||||
if (typingUsers.length === 1) {
|
||||
label = `${typingUsers[0].name} is typing`;
|
||||
} else if (typingUsers.length === 2) {
|
||||
label = `${typingUsers[0].name} and ${typingUsers[1].name} are typing`;
|
||||
} else {
|
||||
label = `${typingUsers.length} people are typing`;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="typing-indicator"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex items-center gap-1.5 px-4 pb-1 text-xs text-surface-400"
|
||||
>
|
||||
{/* Colored dots for each typing user */}
|
||||
<span className="flex -space-x-1">
|
||||
{typingUsers.slice(0, 3).map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className="w-4 h-4 rounded-full border border-surface-900 flex items-center justify-center text-[8px] font-bold text-white"
|
||||
style={{ backgroundColor: u.color }}
|
||||
>
|
||||
{u.name[0].toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
<Dots />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
249
web/components/command-palette/CommandPalette.tsx
Normal file
249
web/components/command-palette/CommandPalette.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { Search, Clock } from "lucide-react";
|
||||
import { useCommandRegistry } from "@/hooks/useCommandRegistry";
|
||||
import { CommandPaletteItem } from "./CommandPaletteItem";
|
||||
import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts";
|
||||
import type { Command, ShortcutCategory } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Fuzzy match: every character of query must appear in order in target */
|
||||
function fuzzyMatch(target: string, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const t = target.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
let qi = 0;
|
||||
for (let i = 0; i < t.length && qi < q.length; i++) {
|
||||
if (t[i] === q[qi]) qi++;
|
||||
}
|
||||
return qi === q.length;
|
||||
}
|
||||
|
||||
/** Score a command against a search query (higher = better) */
|
||||
function score(cmd: Command, query: string): number {
|
||||
const q = query.toLowerCase();
|
||||
const label = cmd.label.toLowerCase();
|
||||
if (label === q) return 100;
|
||||
if (label.startsWith(q)) return 80;
|
||||
if (label.includes(q)) return 60;
|
||||
if (cmd.description.toLowerCase().includes(q)) return 40;
|
||||
if (fuzzyMatch(label, q)) return 20;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface GroupedResults {
|
||||
label: string;
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const {
|
||||
paletteOpen,
|
||||
closePalette,
|
||||
commands,
|
||||
runCommand,
|
||||
recentCommandIds,
|
||||
openHelp,
|
||||
} = useCommandRegistry();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset state when palette opens
|
||||
useEffect(() => {
|
||||
if (paletteOpen) {
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
// Small delay to let the dialog animate in before focusing
|
||||
setTimeout(() => inputRef.current?.focus(), 10);
|
||||
}
|
||||
}, [paletteOpen]);
|
||||
|
||||
const filteredGroups = useMemo<GroupedResults[]>(() => {
|
||||
if (!query.trim()) {
|
||||
// Show recents first, then all categories
|
||||
const recentCmds = recentCommandIds
|
||||
.map((id) => commands.find((c) => c.id === id))
|
||||
.filter((c): c is Command => !!c);
|
||||
|
||||
const groups: GroupedResults[] = [];
|
||||
if (recentCmds.length > 0) {
|
||||
groups.push({ label: "Recent", commands: recentCmds });
|
||||
}
|
||||
for (const cat of SHORTCUT_CATEGORIES) {
|
||||
const catCmds = commands.filter((c) => c.category === cat);
|
||||
if (catCmds.length > 0) {
|
||||
groups.push({ label: cat, commands: catCmds });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Search mode: flat scored list, re-grouped by category
|
||||
const scored = commands
|
||||
.map((cmd) => ({ cmd, s: score(cmd, query) }))
|
||||
.filter(({ s }) => s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map(({ cmd }) => cmd);
|
||||
|
||||
if (scored.length === 0) return [];
|
||||
|
||||
const byCategory: Partial<Record<ShortcutCategory, Command[]>> = {};
|
||||
for (const cmd of scored) {
|
||||
if (!byCategory[cmd.category]) byCategory[cmd.category] = [];
|
||||
byCategory[cmd.category]!.push(cmd);
|
||||
}
|
||||
|
||||
return SHORTCUT_CATEGORIES.filter((c) => byCategory[c]?.length).map(
|
||||
(c) => ({ label: c, commands: byCategory[c]! })
|
||||
);
|
||||
}, [query, commands, recentCommandIds]);
|
||||
|
||||
const flatResults = useMemo(
|
||||
() => filteredGroups.flatMap((g) => g.commands),
|
||||
[filteredGroups]
|
||||
);
|
||||
|
||||
// Clamp activeIndex when results change
|
||||
useEffect(() => {
|
||||
setActiveIndex((i) => Math.min(i, Math.max(flatResults.length - 1, 0)));
|
||||
}, [flatResults.length]);
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, flatResults.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const cmd = flatResults[activeIndex];
|
||||
if (cmd) {
|
||||
closePalette();
|
||||
runCommand(cmd.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (cmd: Command) => {
|
||||
closePalette();
|
||||
runCommand(cmd.id);
|
||||
};
|
||||
|
||||
let flatIdx = 0;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={paletteOpen} onOpenChange={(open) => !open && closePalette()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<Dialog.Content
|
||||
className={cn(
|
||||
"fixed left-1/2 top-[20%] -translate-x-1/2 z-50",
|
||||
"w-full max-w-xl",
|
||||
"bg-surface-900 border border-surface-700 rounded-xl shadow-2xl",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2",
|
||||
"data-[state=closed]:slide-out-to-top-[18%] data-[state=open]:slide-in-from-top-[18%]"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Command palette"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 px-3 py-3 border-b border-surface-800">
|
||||
<Search className="w-4 h-4 text-surface-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
placeholder="Search commands..."
|
||||
className={cn(
|
||||
"flex-1 bg-transparent text-sm text-surface-100",
|
||||
"placeholder:text-surface-500 focus:outline-none"
|
||||
)}
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center h-5 px-1.5 rounded text-[10px] font-mono bg-surface-800 border border-surface-700 text-surface-500">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="overflow-y-auto max-h-[360px] py-1"
|
||||
>
|
||||
{filteredGroups.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-surface-500">
|
||||
No commands found
|
||||
</div>
|
||||
) : (
|
||||
filteredGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
{group.label === "Recent" && (
|
||||
<Clock className="w-3 h-3 text-surface-600" />
|
||||
)}
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-surface-600">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.commands.map((cmd) => {
|
||||
const idx = flatIdx++;
|
||||
return (
|
||||
<div key={cmd.id} data-index={idx}>
|
||||
<CommandPaletteItem
|
||||
command={cmd}
|
||||
isActive={idx === activeIndex}
|
||||
onSelect={() => handleSelect(cmd)}
|
||||
onHighlight={() => setActiveIndex(idx)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-4 px-3 py-2 border-t border-surface-800 text-[10px] text-surface-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">↑↓</kbd>
|
||||
navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">↵</kbd>
|
||||
select
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">Esc</kbd>
|
||||
close
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { closePalette(); openHelp(); }}
|
||||
className="ml-auto hover:text-surface-300 transition-colors"
|
||||
>
|
||||
? View all shortcuts
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
89
web/components/command-palette/CommandPaletteItem.tsx
Normal file
89
web/components/command-palette/CommandPaletteItem.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MessageSquarePlus,
|
||||
Trash2,
|
||||
Settings,
|
||||
Sun,
|
||||
Search,
|
||||
HelpCircle,
|
||||
PanelLeftClose,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShortcutBadge } from "@/components/shortcuts/ShortcutBadge";
|
||||
import type { Command } from "@/lib/shortcuts";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
MessageSquarePlus,
|
||||
Trash2,
|
||||
Settings,
|
||||
Sun,
|
||||
Search,
|
||||
HelpCircle,
|
||||
PanelLeftClose,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
};
|
||||
|
||||
interface CommandPaletteItemProps {
|
||||
command: Command;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onHighlight: () => void;
|
||||
}
|
||||
|
||||
export function CommandPaletteItem({
|
||||
command,
|
||||
isActive,
|
||||
onSelect,
|
||||
onHighlight,
|
||||
}: CommandPaletteItemProps) {
|
||||
const Icon = command.icon ? (ICON_MAP[command.icon] ?? ChevronRight) : ChevronRight;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHighlight}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none",
|
||||
"transition-colors",
|
||||
isActive ? "bg-brand-600/20 text-surface-100" : "text-surface-300 hover:bg-surface-800"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center",
|
||||
isActive ? "bg-brand-600/30 text-brand-400" : "bg-surface-800 text-surface-500"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{command.label}</p>
|
||||
{command.description && (
|
||||
<p className="text-xs text-surface-500 truncate">{command.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded border font-medium",
|
||||
isActive
|
||||
? "border-brand-600/40 text-brand-400 bg-brand-600/10"
|
||||
: "border-surface-700 text-surface-600 bg-surface-800"
|
||||
)}
|
||||
>
|
||||
{command.category}
|
||||
</span>
|
||||
{command.keys.length > 0 && <ShortcutBadge keys={command.keys} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
web/components/export/ExportOptions.tsx
Normal file
83
web/components/export/ExportOptions.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import type { ExportOptions, ExportFormat } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OptionRowProps {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function OptionRow({ id, label, description, checked, onCheckedChange, disabled }: OptionRowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between gap-4 py-2", disabled && "opacity-40")}>
|
||||
<label htmlFor={id} className={cn("flex flex-col gap-0.5", !disabled && "cursor-pointer")}>
|
||||
<span className="text-sm text-surface-200">{label}</span>
|
||||
{description && (
|
||||
<span className="text-xs text-surface-500">{description}</span>
|
||||
)}
|
||||
</label>
|
||||
<Switch.Root
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors outline-none cursor-pointer",
|
||||
"data-[state=checked]:bg-brand-600 data-[state=unchecked]:bg-surface-700",
|
||||
"disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Switch.Thumb className="block w-4 h-4 bg-white rounded-full shadow transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportOptionsProps {
|
||||
options: ExportOptions;
|
||||
onChange: (opts: Partial<ExportOptions>) => void;
|
||||
}
|
||||
|
||||
export function ExportOptionsPanel({ options, onChange }: ExportOptionsProps) {
|
||||
const isJson = options.format === "json";
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-surface-800">
|
||||
<OptionRow
|
||||
id="opt-tool-use"
|
||||
label="Include tool use"
|
||||
description="Show tool calls and results in the export"
|
||||
checked={options.includeToolUse}
|
||||
onCheckedChange={(v) => onChange({ includeToolUse: v })}
|
||||
/>
|
||||
<OptionRow
|
||||
id="opt-thinking"
|
||||
label="Include thinking blocks"
|
||||
description="Show extended thinking when present"
|
||||
checked={options.includeThinking}
|
||||
onCheckedChange={(v) => onChange({ includeThinking: v })}
|
||||
disabled={isJson}
|
||||
/>
|
||||
<OptionRow
|
||||
id="opt-timestamps"
|
||||
label="Include timestamps"
|
||||
description="Add date/time to messages and metadata"
|
||||
checked={options.includeTimestamps}
|
||||
onCheckedChange={(v) => onChange({ includeTimestamps: v })}
|
||||
/>
|
||||
<OptionRow
|
||||
id="opt-file-contents"
|
||||
label="Include full file contents"
|
||||
description="Show complete tool result output (may be large)"
|
||||
checked={options.includeFileContents}
|
||||
onCheckedChange={(v) => onChange({ includeFileContents: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
web/components/export/FormatSelector.tsx
Normal file
73
web/components/export/FormatSelector.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Braces, Globe, FileDown, AlignLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ExportFormat } from "@/lib/types";
|
||||
|
||||
interface FormatOption {
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const FORMATS: FormatOption[] = [
|
||||
{
|
||||
value: "markdown",
|
||||
label: "Markdown",
|
||||
description: "Clean .md with code blocks and metadata",
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
value: "json",
|
||||
label: "JSON",
|
||||
description: "Full conversation data with tool use",
|
||||
icon: <Braces className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
value: "html",
|
||||
label: "HTML",
|
||||
description: "Self-contained file with embedded styles",
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: "PDF",
|
||||
description: "Print-to-PDF via browser dialog",
|
||||
icon: <FileDown className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
value: "plaintext",
|
||||
label: "Plain Text",
|
||||
description: "Stripped of all formatting",
|
||||
icon: <AlignLeft className="w-4 h-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
interface FormatSelectorProps {
|
||||
value: ExportFormat;
|
||||
onChange: (format: ExportFormat) => void;
|
||||
}
|
||||
|
||||
export function FormatSelector({ value, onChange }: FormatSelectorProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.value}
|
||||
onClick={() => onChange(fmt.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 px-2 py-3 rounded-lg border text-center transition-colors",
|
||||
value === fmt.value
|
||||
? "border-brand-500 bg-brand-500/10 text-brand-300"
|
||||
: "border-surface-700 bg-surface-800 text-surface-400 hover:border-surface-600 hover:text-surface-200"
|
||||
)}
|
||||
title={fmt.description}
|
||||
>
|
||||
{fmt.icon}
|
||||
<span className="text-xs font-medium leading-none">{fmt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
web/components/file-viewer/FileBreadcrumb.tsx
Normal file
82
web/components/file-viewer/FileBreadcrumb.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileBreadcrumbProps {
|
||||
path: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileBreadcrumb({ path, className }: FileBreadcrumbProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const isAbsolute = path.startsWith("/");
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(path);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const openInVSCode = () => {
|
||||
window.open(`vscode://file${isAbsolute ? path : `/${path}`}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1.5 border-b border-surface-800 bg-surface-900/80",
|
||||
"text-xs text-surface-400 min-w-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Segments */}
|
||||
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
|
||||
{isAbsolute && (
|
||||
<span className="text-surface-600 flex-shrink-0">/</span>
|
||||
)}
|
||||
{segments.map((seg, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{i > 0 && <span className="text-surface-700 mx-0.5">/</span>}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate max-w-[120px]",
|
||||
i === segments.length - 1
|
||||
? "text-surface-200 font-medium"
|
||||
: "text-surface-500 hover:text-surface-300 cursor-pointer transition-colors"
|
||||
)}
|
||||
title={seg}
|
||||
>
|
||||
{seg}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-surface-800 transition-colors"
|
||||
title="Copy path"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={openInVSCode}
|
||||
className="p-1 rounded hover:bg-surface-800 transition-colors"
|
||||
title="Open in VS Code"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
web/components/file-viewer/FileInfoBar.tsx
Normal file
98
web/components/file-viewer/FileInfoBar.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Edit3, GitCompare } from "lucide-react";
|
||||
import { useFileViewerStore, type FileTab, type FileViewMode } from "@/lib/fileViewerStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileInfoBarProps {
|
||||
tab: FileTab;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
typescript: "TypeScript",
|
||||
tsx: "TSX",
|
||||
javascript: "JavaScript",
|
||||
jsx: "JSX",
|
||||
python: "Python",
|
||||
rust: "Rust",
|
||||
go: "Go",
|
||||
css: "CSS",
|
||||
scss: "SCSS",
|
||||
html: "HTML",
|
||||
json: "JSON",
|
||||
markdown: "Markdown",
|
||||
bash: "Bash",
|
||||
yaml: "YAML",
|
||||
toml: "TOML",
|
||||
sql: "SQL",
|
||||
graphql: "GraphQL",
|
||||
ruby: "Ruby",
|
||||
java: "Java",
|
||||
c: "C",
|
||||
cpp: "C++",
|
||||
csharp: "C#",
|
||||
php: "PHP",
|
||||
swift: "Swift",
|
||||
kotlin: "Kotlin",
|
||||
dockerfile: "Dockerfile",
|
||||
makefile: "Makefile",
|
||||
text: "Plain Text",
|
||||
};
|
||||
|
||||
const VIEW_MODES: { mode: FileViewMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ mode: "view", label: "View", icon: Eye },
|
||||
{ mode: "edit", label: "Edit", icon: Edit3 },
|
||||
{ mode: "diff", label: "Diff", icon: GitCompare },
|
||||
];
|
||||
|
||||
export function FileInfoBar({ tab }: FileInfoBarProps) {
|
||||
const { setMode } = useFileViewerStore();
|
||||
|
||||
const lineCount = tab.content.split("\n").length;
|
||||
const byteSize = new TextEncoder().encode(tab.content).length;
|
||||
const langLabel = LANGUAGE_LABELS[tab.language] ?? tab.language;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1 border-t border-surface-800 bg-surface-950 text-xs text-surface-500">
|
||||
{/* Left: file stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-surface-400">{langLabel}</span>
|
||||
<span>UTF-8</span>
|
||||
<span>{lineCount.toLocaleString()} lines</span>
|
||||
<span>{formatBytes(byteSize)}</span>
|
||||
{tab.isDirty && (
|
||||
<span className="text-yellow-500">● Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: mode switcher */}
|
||||
{!tab.isImage && (
|
||||
<div className="flex items-center gap-0.5 bg-surface-900 rounded px-1 py-0.5">
|
||||
{VIEW_MODES.map(({ mode, label, icon: Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setMode(tab.id, mode)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors",
|
||||
tab.mode === mode
|
||||
? "bg-surface-700 text-surface-100"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
disabled={mode === "diff" && !tab.diff}
|
||||
title={mode === "diff" && !tab.diff ? "No diff available" : label}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
web/components/file-viewer/ImageViewer.tsx
Normal file
107
web/components/file-viewer/ImageViewer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { ZoomIn, ZoomOut, Maximize2, Image as ImageIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageViewerProps {
|
||||
src: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function ImageViewer({ src, path }: ImageViewerProps) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
|
||||
const [error, setError] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleZoomIn = () => setZoom((z) => Math.min(z * 1.25, 8));
|
||||
const handleZoomOut = () => setZoom((z) => Math.max(z / 1.25, 0.1));
|
||||
const handleFitToggle = () => {
|
||||
setFitMode((m) => (m === "fit" ? "actual" : "fit"));
|
||||
setZoom(1);
|
||||
};
|
||||
|
||||
const isSvg = path.endsWith(".svg");
|
||||
const hasTransparency = path.endsWith(".png") || path.endsWith(".gif") ||
|
||||
path.endsWith(".webp") || path.endsWith(".svg");
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-surface-500">
|
||||
<ImageIcon className="w-10 h-10" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
<p className="text-xs text-surface-600">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-surface-800 bg-surface-900/50">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-surface-400 w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-surface-800 mx-1" />
|
||||
<button
|
||||
onClick={handleFitToggle}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors",
|
||||
fitMode === "fit"
|
||||
? "text-brand-400 bg-brand-900/30"
|
||||
: "text-surface-500 hover:text-surface-200 hover:bg-surface-800"
|
||||
)}
|
||||
title={fitMode === "fit" ? "Switch to actual size" : "Switch to fit width"}
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
{fitMode === "fit" ? "Fit" : "Actual"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Image container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto flex items-center justify-center p-4"
|
||||
style={{
|
||||
backgroundImage: hasTransparency
|
||||
? "linear-gradient(45deg, #3f3f46 25%, transparent 25%), linear-gradient(-45deg, #3f3f46 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #3f3f46 75%), linear-gradient(-45deg, transparent 75%, #3f3f46 75%)"
|
||||
: undefined,
|
||||
backgroundSize: hasTransparency ? "16px 16px" : undefined,
|
||||
backgroundPosition: hasTransparency ? "0 0, 0 8px, 8px -8px, -8px 0px" : undefined,
|
||||
backgroundColor: hasTransparency ? "#27272a" : "#1a1a1e",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={path.split("/").pop()}
|
||||
onError={() => setError(true)}
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
maxWidth: fitMode === "fit" ? "100%" : "none",
|
||||
maxHeight: fitMode === "fit" ? "100%" : "none",
|
||||
imageRendering: zoom > 2 ? "pixelated" : "auto",
|
||||
}}
|
||||
className="transition-transform duration-150 shadow-lg"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
web/components/file-viewer/SearchBar.tsx
Normal file
250
web/components/file-viewer/SearchBar.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, ChevronUp, ChevronDown, Regex, CaseSensitive } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SearchBarProps {
|
||||
content: string;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isRegex, setIsRegex] = useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [currentMatch, setCurrentMatch] = useState(0);
|
||||
const [totalMatches, setTotalMatches] = useState(0);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Compute matches
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setTotalMatches(0);
|
||||
setCurrentMatch(0);
|
||||
clearHighlights();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const flags = caseSensitive ? "g" : "gi";
|
||||
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
|
||||
const matches = Array.from(content.matchAll(pattern));
|
||||
setTotalMatches(matches.length);
|
||||
setCurrentMatch(matches.length > 0 ? 1 : 0);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
setTotalMatches(0);
|
||||
setCurrentMatch(0);
|
||||
}
|
||||
}, [query, isRegex, caseSensitive, content]);
|
||||
|
||||
// Apply DOM highlights
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
clearHighlights();
|
||||
|
||||
if (!query || hasError || totalMatches === 0) return;
|
||||
|
||||
try {
|
||||
const flags = caseSensitive ? "g" : "gi";
|
||||
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
containerRef.current,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
// Skip nodes inside already-marked elements
|
||||
if ((node.parentElement as HTMLElement)?.tagName === "MARK") {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
let matchIdx = 0;
|
||||
// Process in reverse order to avoid position shifting
|
||||
const replacements: { node: Text; ranges: { start: number; end: number; idx: number }[] }[] = [];
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
const text = textNode.textContent ?? "";
|
||||
pattern.lastIndex = 0;
|
||||
const nodeRanges: { start: number; end: number; idx: number }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(text)) !== null) {
|
||||
nodeRanges.push({ start: m.index, end: m.index + m[0].length, idx: matchIdx++ });
|
||||
if (m[0].length === 0) break; // prevent infinite loop on zero-width matches
|
||||
}
|
||||
if (nodeRanges.length > 0) {
|
||||
replacements.push({ node: textNode, ranges: nodeRanges });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply replacements in document order but process ranges in reverse
|
||||
for (const { node: textNode, ranges } of replacements) {
|
||||
const text = textNode.textContent ?? "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastEnd = 0;
|
||||
|
||||
for (const { start, end, idx } of ranges) {
|
||||
if (start > lastEnd) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastEnd, start)));
|
||||
}
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = cn(
|
||||
"search-highlight",
|
||||
idx === currentMatch - 1 ? "search-highlight-current" : ""
|
||||
);
|
||||
mark.textContent = text.slice(start, end);
|
||||
fragment.appendChild(mark);
|
||||
lastEnd = end;
|
||||
}
|
||||
if (lastEnd < text.length) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastEnd)));
|
||||
}
|
||||
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||
}
|
||||
|
||||
// Scroll current match into view
|
||||
const currentEl = containerRef.current?.querySelector(".search-highlight-current");
|
||||
currentEl?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
} catch {
|
||||
// Ignore DOM errors
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (containerRef.current) clearHighlights();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, isRegex, caseSensitive, currentMatch, totalMatches]);
|
||||
|
||||
function clearHighlights() {
|
||||
if (!containerRef.current) return;
|
||||
const marks = containerRef.current.querySelectorAll("mark.search-highlight");
|
||||
marks.forEach((mark) => {
|
||||
mark.replaceWith(mark.textContent ?? "");
|
||||
});
|
||||
// Normalize text nodes
|
||||
containerRef.current.normalize();
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
setCurrentMatch((c) => (c >= totalMatches ? 1 : c + 1));
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
setCurrentMatch((c) => (c <= 1 ? totalMatches : c - 1));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.shiftKey ? goPrev() : goNext();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
clearHighlights();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-surface-800 bg-surface-900/90 backdrop-blur-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 flex-1 bg-surface-800 rounded px-2 py-1",
|
||||
hasError && "ring-1 ring-red-500/50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-xs text-surface-100 placeholder-surface-500 outline-none min-w-0"
|
||||
/>
|
||||
|
||||
{/* Match count */}
|
||||
{query && (
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0",
|
||||
hasError ? "text-red-400" : totalMatches === 0 ? "text-red-400" : "text-surface-400"
|
||||
)}>
|
||||
{hasError ? "Invalid regex" : totalMatches === 0 ? "No results" : `${currentMatch}/${totalMatches}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Toggles */}
|
||||
<button
|
||||
onClick={() => setCaseSensitive((v) => !v)}
|
||||
className={cn(
|
||||
"p-0.5 rounded transition-colors flex-shrink-0",
|
||||
caseSensitive
|
||||
? "text-brand-400 bg-brand-900/40"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
title="Case sensitive"
|
||||
>
|
||||
<CaseSensitive className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsRegex((v) => !v)}
|
||||
className={cn(
|
||||
"p-0.5 rounded transition-colors flex-shrink-0",
|
||||
isRegex
|
||||
? "text-brand-400 bg-brand-900/40"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
title="Use regular expression"
|
||||
>
|
||||
<Regex className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={totalMatches === 0}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
|
||||
title="Previous match (Shift+Enter)"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={totalMatches === 0}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
|
||||
title="Next match (Enter)"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { clearHighlights(); onClose(); }}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Close (Escape)"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
64
web/components/layout/Header.tsx
Normal file
64
web/components/layout/Header.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { MODELS } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
|
||||
|
||||
export function Header() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { settings, updateSettings } = useChatStore();
|
||||
|
||||
const themeIcons = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
} as const;
|
||||
|
||||
const ThemeIcon = themeIcons[theme];
|
||||
const nextTheme = theme === "dark" ? "light" : theme === "light" ? "system" : "dark";
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-2.5 border-b border-surface-800 bg-surface-900/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-sm font-medium text-surface-100">Chat</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model selector */}
|
||||
<label htmlFor="model-select" className="sr-only">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
id="model-select"
|
||||
value={settings.model}
|
||||
onChange={(e) => updateSettings({ model: e.target.value })}
|
||||
className={cn(
|
||||
"text-xs bg-surface-800 border border-surface-700 rounded-md px-2 py-1",
|
||||
"text-surface-300 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
)}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Notification center */}
|
||||
<NotificationCenter />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(nextTheme)}
|
||||
aria-label={`Switch to ${nextTheme} theme`}
|
||||
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
<ThemeIcon className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user