diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..b1b897773 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend dependencies + run: cd web && npm ci + + - name: Build frontend + run: cd web && npm run build + + - name: Copy frontend assets to proxy embed directory + run: | + rm -rf proxy/frontend/dist + cp -r web/build/client proxy/frontend/dist + + - name: Run GoReleaser with goreleaser-cross + run: | + docker run \ + --rm \ + -e CGO_ENABLED=1 \ + -e GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v ${{ github.workspace }}:/go/src/github.com/${{ github.repository }} \ + -w /go/src/github.com/${{ github.repository }} \ + ghcr.io/goreleaser/goreleaser-cross:v1.23.6 \ + release --clean diff --git a/.gitignore b/.gitignore index 759a7302c..378e3557e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ bin/ dist/ *.exe +# Embedded frontend assets (populated by build) +proxy/frontend/dist/* +!proxy/frontend/dist/.gitkeep + # IDE and system files .DS_Store .vscode/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 000000000..a37439e2c --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,73 @@ +version: 2 + +project_name: claude-code-proxy + +builds: + - id: linux-amd64 + dir: proxy + main: ./cmd/proxy + binary: claude-code-proxy + goos: + - linux + goarch: + - amd64 + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + + - id: linux-arm64 + dir: proxy + main: ./cmd/proxy + binary: claude-code-proxy + goos: + - linux + goarch: + - arm64 + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + + - id: darwin-amd64 + dir: proxy + main: ./cmd/proxy + binary: claude-code-proxy + goos: + - darwin + goarch: + - amd64 + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + + - id: darwin-arm64 + dir: proxy + main: ./cmd/proxy + binary: claude-code-proxy + goos: + - darwin + goarch: + - arm64 + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + +archives: + - formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" diff --git a/Dockerfile b/Dockerfile index 6891deda1..bff936644 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,18 @@ # Multi-stage Dockerfile for Claude Code Proxy -# Builds both Go proxy server and Remix frontend in a single container +# Builds a single Go binary with embedded frontend assets -# Stage 1: Build Go Backend +# Stage 1: Build Node.js Frontend +FROM node:20-alpine AS node-builder + +WORKDIR /app/web + +COPY web/package*.json ./ +RUN npm ci + +COPY web/ ./ +RUN npm run build + +# Stage 2: Build Go Binary with embedded frontend FROM golang:1.21-alpine AS go-builder WORKDIR /app @@ -16,28 +27,15 @@ RUN go mod download # Copy Go source code COPY proxy/ ./ -# Build with CGO enabled for SQLite support -RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o /app/bin/proxy cmd/proxy/main.go - -# Stage 2: Build Node.js Frontend -FROM node:20-alpine AS node-builder - -WORKDIR /app - -# Copy package files -COPY web/package*.json ./web/ -WORKDIR /app/web -RUN npm ci -# Copy web source code and build -COPY web/ ./ -RUN npm run build +# Copy built frontend assets into embed directory +COPY --from=node-builder /app/web/build/client ./frontend/dist -# Clean up dev dependencies after build -RUN npm ci --only=production && npm cache clean --force +# Build with CGO enabled for SQLite support +RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o /app/bin/proxy cmd/proxy/main.go # Stage 3: Production Runtime -FROM node:20-alpine +FROM alpine:3.19 WORKDIR /app @@ -48,25 +46,15 @@ RUN apk add --no-cache sqlite wget RUN addgroup -g 1001 -S appgroup && \ adduser -S appuser -u 1001 -G appgroup -# Copy built Go binary +# Copy built Go binary (includes embedded frontend) COPY --from=go-builder /app/bin/proxy ./bin/proxy RUN chmod +x ./bin/proxy -# Copy built Remix application -COPY --from=node-builder /app/web/build ./web/build -COPY --from=node-builder /app/web/package*.json ./web/ -COPY --from=node-builder /app/web/node_modules ./web/node_modules - # Create data directory for SQLite database RUN mkdir -p /app/data && chown -R appuser:appgroup /app -# Copy startup script -COPY docker-entrypoint.sh ./ -RUN chmod +x docker-entrypoint.sh - # Environment variables with defaults ENV PORT=3001 -ENV WEB_PORT=5173 ENV READ_TIMEOUT=600 ENV WRITE_TIMEOUT=600 ENV IDLE_TIMEOUT=600 @@ -75,8 +63,8 @@ ENV ANTHROPIC_VERSION=2023-06-01 ENV ANTHROPIC_MAX_RETRIES=3 ENV DB_PATH=/app/data/requests.db -# Expose ports -EXPOSE 3001 5173 +# Expose single port +EXPOSE 3001 # Switch to app user USER appuser @@ -85,5 +73,5 @@ USER appuser HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget -qO- http://localhost:3001/health > /dev/null || exit 1 -# Start both services -CMD ["./docker-entrypoint.sh"] \ No newline at end of file +# Start single binary +CMD ["./bin/proxy"] diff --git a/Makefile b/Makefile index e4d75c50f..186d4db4b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build run clean install dev +.PHONY: all build run clean install dev # Default target all: install build @@ -10,17 +10,22 @@ install: @echo "📦 Installing Node dependencies..." cd web && npm install -# Build both services -build: build-proxy build-web +# Build single binary (frontend → copy → Go) +build: build-web copy-frontend build-proxy build-proxy: @echo "🔨 Building proxy server..." - cd proxy && go build -o ../bin/proxy cmd/proxy/main.go + cd proxy && CGO_ENABLED=1 go build -o ../bin/proxy cmd/proxy/main.go build-web: @echo "🔨 Building web interface..." cd web && npm run build +copy-frontend: + @echo "📋 Copying frontend assets into Go embed directory..." + rm -rf proxy/frontend/dist + cp -r web/build/client proxy/frontend/dist + # Run in development mode dev: @echo "🚀 Starting development servers..." @@ -40,6 +45,9 @@ clean: rm -rf bin/ rm -rf web/build/ rm -rf web/.cache/ + rm -rf proxy/frontend/dist + mkdir -p proxy/frontend/dist + touch proxy/frontend/dist/.gitkeep rm -f requests.db rm -rf requests/ @@ -53,7 +61,7 @@ db-reset: help: @echo "Claude Code Monitor - Available targets:" @echo " make install - Install all dependencies" - @echo " make build - Build both services" + @echo " make build - Build single binary (frontend + Go)" @echo " make dev - Run in development mode" @echo " make run-proxy - Run proxy server only" @echo " make run-web - Run web interface only" diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index 8623203b6..4eb2bddd8 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -2,16 +2,19 @@ package main import ( "context" + "io/fs" "log" "net/http" "os" "os/signal" + "strings" "syscall" "time" "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/seifghazi/claude-code-monitor/frontend" "github.com/seifghazi/claude-code-monitor/internal/config" "github.com/seifghazi/claude-code-monitor/internal/handler" "github.com/seifghazi/claude-code-monitor/internal/middleware" @@ -62,15 +65,45 @@ func main() { r.HandleFunc("/v1/models", h.Models).Methods("GET") r.HandleFunc("/health", h.Health).Methods("GET") - r.HandleFunc("/", h.UI).Methods("GET") - r.HandleFunc("/ui", h.UI).Methods("GET") r.HandleFunc("/api/requests", h.GetRequests).Methods("GET") r.HandleFunc("/api/requests", h.DeleteRequests).Methods("DELETE") r.HandleFunc("/api/conversations", h.GetConversations).Methods("GET") r.HandleFunc("/api/conversations/{id}", h.GetConversationByID).Methods("GET") r.HandleFunc("/api/conversations/project", h.GetConversationsByProject).Methods("GET") - r.NotFoundHandler = http.HandlerFunc(h.NotFound) + // Serve embedded frontend assets + distFS, err := fs.Sub(frontend.Assets, "dist") + if err != nil { + logger.Fatalf("❌ Failed to create sub filesystem: %v", err) + } + fileServer := http.FileServer(http.FS(distFS)) + + // Serve static assets (JS, CSS, fonts, etc.) + r.PathPrefix("/assets/").Handler(fileServer) + + // SPA fallback: serve index.html for all non-API, non-asset paths + r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Let API routes return 404 normally + if strings.HasPrefix(req.URL.Path, "/api/") || strings.HasPrefix(req.URL.Path, "/v1/") { + h.NotFound(w, req) + return + } + // Try to serve the file directly first (e.g. favicon.ico) + f, err := distFS.Open(strings.TrimPrefix(req.URL.Path, "/")) + if err == nil { + f.Close() + fileServer.ServeHTTP(w, req) + return + } + // Fall back to index.html for SPA routing + indexHTML, err := fs.ReadFile(distFS, "index.html") + if err != nil { + h.NotFound(w, req) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(indexHTML) + }) srv := &http.Server{ Addr: ":" + cfg.Server.Port, @@ -80,6 +113,26 @@ func main() { IdleTimeout: cfg.Server.IdleTimeout, } + // Detect HTTP/HTTPS proxy from environment + proxyEnvVars := []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"} + var detectedProxy bool + for _, env := range proxyEnvVars { + if val := os.Getenv(env); val != "" { + if !detectedProxy { + logger.Println("⚠️ HTTP/HTTPS proxy detected! All outbound API requests will be routed through:") + detectedProxy = true + } + logger.Printf(" %s=%s", env, val) + } + } + if !detectedProxy { + logger.Println("⚠️ No HTTP/HTTPS proxy detected. If you are behind a firewall, API calls may fail!") + logger.Println(" Set proxy environment variables before starting:") + logger.Printf(" export HTTP_PROXY=http://your-proxy:port") + logger.Printf(" export HTTPS_PROXY=http://your-proxy:port") + logger.Printf(" export NO_PROXY=localhost,127.0.0.1") + } + go func() { logger.Printf("🚀 Claude Code Monitor Server running on http://localhost:%s", cfg.Server.Port) logger.Printf("📡 API endpoints available at:") @@ -89,6 +142,11 @@ func main() { logger.Printf("🎨 Web UI available at:") logger.Printf(" - GET http://localhost:%s/ (Request Visualizer)", cfg.Server.Port) logger.Printf(" - GET http://localhost:%s/api/requests (Request API)", cfg.Server.Port) + logger.Println("") + logger.Println("👉 To use with Claude Code, run:") + logger.Printf(" export ANTHROPIC_BASE_URL=http://localhost:%s", cfg.Server.Port) + logger.Println(" claude") + logger.Println("") if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Fatalf("❌ Server failed to start: %v", err) diff --git a/proxy/frontend/embed.go b/proxy/frontend/embed.go new file mode 100644 index 000000000..fccd9d5de --- /dev/null +++ b/proxy/frontend/embed.go @@ -0,0 +1,6 @@ +package frontend + +import "embed" + +//go:embed all:dist +var Assets embed.FS diff --git a/proxy/internal/handler/handlers.go b/proxy/internal/handler/handlers.go index 05d1360b7..a9bc9bc78 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -10,7 +10,6 @@ import ( "io" "log" "net/http" - "os" "sort" "strconv" "strings" @@ -60,6 +59,7 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) { var req model.AnthropicRequest if err := json.Unmarshal(bodyBytes, &req); err != nil { log.Printf("❌ Error parsing JSON: %v", err) + log.Printf("📋 Raw request body: %s", string(bodyBytes)) writeErrorResponse(w, "Invalid JSON", http.StatusBadRequest) return } @@ -150,18 +150,6 @@ func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { writeJSONResponse(w, response) } -func (h *Handler) UI(w http.ResponseWriter, r *http.Request) { - htmlContent, err := os.ReadFile("index.html") - if err != nil { - // Error reading index.html - http.Error(w, "UI not available", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "text/html") - w.Write(htmlContent) -} - func (h *Handler) GetRequests(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { diff --git a/proxy/internal/model/models.go b/proxy/internal/model/models.go index a5d03c058..4a86aec37 100644 --- a/proxy/internal/model/models.go +++ b/proxy/internal/model/models.go @@ -147,6 +147,41 @@ type AnthropicRequest struct { ToolChoice interface{} `json:"tool_choice,omitempty"` } +func (r *AnthropicRequest) UnmarshalJSON(data []byte) error { + // Use an alias to avoid infinite recursion + type Alias AnthropicRequest + aux := &struct { + System json.RawMessage `json:"system,omitempty"` + *Alias + }{ + Alias: (*Alias)(r), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + if len(aux.System) == 0 { + return nil + } + + // Try as array first + var systemMessages []AnthropicSystemMessage + if err := json.Unmarshal(aux.System, &systemMessages); err == nil { + r.System = systemMessages + return nil + } + + // Try as string + var systemStr string + if err := json.Unmarshal(aux.System, &systemStr); err == nil { + r.System = []AnthropicSystemMessage{{Type: "text", Text: systemStr}} + return nil + } + + return json.Unmarshal(aux.System, &r.System) +} + type ModelsResponse struct { Object string `json:"object"` Data []ModelInfo `json:"data"` diff --git a/web/app/entry.server.tsx b/web/app/entry.server.tsx deleted file mode 100644 index 45db3229c..000000000 --- a/web/app/entry.server.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/** - * By default, Remix will handle generating the HTTP Response for you. - * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ - * For more information, see https://remix.run/file-conventions/entry.server - */ - -import { PassThrough } from "node:stream"; - -import type { AppLoadContext, EntryContext } from "@remix-run/node"; -import { createReadableStreamFromReadable } from "@remix-run/node"; -import { RemixServer } from "@remix-run/react"; -import { isbot } from "isbot"; -import { renderToPipeableStream } from "react-dom/server"; - -const ABORT_DELAY = 5_000; - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - // This is ignored so we can keep it in the template for visibility. Feel - // free to delete this parameter in your app if you're not using it! - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadContext: AppLoadContext -) { - return isbot(request.headers.get("user-agent") || "") - ? handleBotRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ) - : handleBrowserRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ); -} - -function handleBotRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onAllReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - } - ); - - setTimeout(abort, ABORT_DELAY); - }); -} - -function handleBrowserRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onShellReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - } - ); - - setTimeout(abort, ABORT_DELAY); - }); -} diff --git a/web/app/routes/api.conversations.tsx b/web/app/routes/api.conversations.tsx deleted file mode 100644 index 988a84cbc..000000000 --- a/web/app/routes/api.conversations.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { LoaderFunction } from "@remix-run/node"; -import { json } from "@remix-run/node"; - -export const loader: LoaderFunction = async ({ request }) => { - try { - const url = new URL(request.url); - const modelFilter = url.searchParams.get("model"); - - const backendUrl = new URL('http://localhost:3001/api/conversations'); - if (modelFilter) { - backendUrl.searchParams.append('model', modelFilter); - } - - const response = await fetch(backendUrl.toString()); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return json(data); - } catch (error) { - console.error('Failed to fetch conversations:', error); - return json({ conversations: [] }); - } -}; \ No newline at end of file diff --git a/web/app/routes/api.grade-prompt.tsx b/web/app/routes/api.grade-prompt.tsx deleted file mode 100644 index 4a84253d5..000000000 --- a/web/app/routes/api.grade-prompt.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { ActionFunction } from "@remix-run/node"; -import { json } from "@remix-run/node"; - -export const action: ActionFunction = async ({ request }) => { - if (request.method !== "POST") { - return json({ error: 'Method not allowed' }, { status: 405 }); - } - - try { - const body = await request.json(); - - // Forward the request to the Go backend - const response = await fetch('http://localhost:3001/api/grade-prompt', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return json(data); - } catch (error) { - console.error('Failed to grade prompt:', error); - return json({ - error: 'Failed to grade prompt. Please ensure the backend is running and has a valid Anthropic API key.' - }, { status: 500 }); - } -}; \ No newline at end of file diff --git a/web/app/routes/api.requests.tsx b/web/app/routes/api.requests.tsx deleted file mode 100644 index 7f2b9cf52..000000000 --- a/web/app/routes/api.requests.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { ActionFunction, LoaderFunction } from "@remix-run/node"; -import { json } from "@remix-run/node"; - -export const loader: LoaderFunction = async ({ request }) => { - try { - const url = new URL(request.url); - const modelFilter = url.searchParams.get("model"); - const page = url.searchParams.get("page"); - const limit = url.searchParams.get("limit"); - - // Forward the request to the Go backend - const backendUrl = new URL('http://localhost:3001/api/requests'); - if (modelFilter) { - backendUrl.searchParams.append('model', modelFilter); - } - if (page) { - backendUrl.searchParams.append('page', page); - } - if (limit) { - backendUrl.searchParams.append('limit', limit); - } - - const response = await fetch(backendUrl.toString()); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return json(data); - } catch (error) { - console.error('Failed to fetch requests:', error); - - // Return empty array if backend is not available - return json({ requests: [] }); - } -}; - -export const action: ActionFunction = async ({ request }) => { - const method = request.method; - - if (method === "DELETE") { - try { - // Forward the DELETE request to the Go backend - const response = await fetch('http://localhost:3001/api/requests', { - method: 'DELETE' - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return json({ success: true }); - } catch (error) { - console.error('Failed to clear requests:', error); - return json({ success: false, error: 'Failed to clear requests' }, { status: 500 }); - } - } - - return json({ error: 'Method not allowed' }, { status: 405 }); -}; \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index ef6d1387a..1d0c9370c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,15 +2,10 @@ import { vitePlugin as remix } from "@remix-run/dev"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; -declare module "@remix-run/node" { - interface Future { - v3_singleFetch: true; - } -} - export default defineConfig({ plugins: [ remix({ + ssr: false, future: { v3_fetcherPersist: true, v3_relativeSplatPath: true,