From cea3d6a294c33368e686cbce73cd11e9a30c43c9 Mon Sep 17 00:00:00 2001 From: Chartsmith Date: Tue, 3 Feb 2026 19:59:41 +0000 Subject: [PATCH 1/2] feat: Add Node.js Claude service for official SDK access Phase 1 of issue #202 - Extract LLM calls to Node.js Claude service. This PR adds: ## New: claude-service (Node.js) - Express server wrapping official @anthropic-ai/sdk - Endpoints: - POST /v1/messages (non-streaming) - POST /v1/messages/stream (SSE streaming) - POST /v1/messages/think (extended thinking) - GET /health - Dockerfile for containerized deployment - Full TypeScript with Zod validation ## New: Go Claude client (pkg/llm/claude/) - HTTP client to call the Node service - Supports streaming via SSE parsing - StreamMessage() and StreamMessageWithResponse() methods ## Updated: pkg/llm/expand.go - Now checks CLAUDE_SERVICE_URL env var - If set, routes through Node service - Otherwise falls back to direct Anthropic SDK ## Usage Set CLAUDE_SERVICE_URL=http://localhost:3100 to enable. Next steps (future PRs): - Migrate remaining streaming functions - Add docker-compose integration - Enable prompt caching --- .gitignore | 1 + claude-service/.dockerignore | 5 + claude-service/Dockerfile | 37 + claude-service/README.md | 124 +++ claude-service/package-lock.json | 1771 ++++++++++++++++++++++++++++++ claude-service/package.json | 27 + claude-service/src/index.ts | 213 ++++ claude-service/src/types.ts | 75 ++ claude-service/tsconfig.json | 19 + pkg/llm/claude/client.go | 371 +++++++ pkg/llm/claude/models.go | 7 + pkg/llm/expand.go | 60 +- 12 files changed, 2703 insertions(+), 7 deletions(-) create mode 100644 claude-service/.dockerignore create mode 100644 claude-service/Dockerfile create mode 100644 claude-service/README.md create mode 100644 claude-service/package-lock.json create mode 100644 claude-service/package.json create mode 100644 claude-service/src/index.ts create mode 100644 claude-service/src/types.ts create mode 100644 claude-service/tsconfig.json create mode 100644 pkg/llm/claude/client.go create mode 100644 pkg/llm/claude/models.go diff --git a/.gitignore b/.gitignore index 7b10e193..8c4d0fbe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ test-results/ .specstory/ chart/chartsmith/*.tgz .direnv/ +claude-service/node_modules/ diff --git a/claude-service/.dockerignore b/claude-service/.dockerignore new file mode 100644 index 00000000..2a5015ff --- /dev/null +++ b/claude-service/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log +.env +.env.* diff --git a/claude-service/Dockerfile b/claude-service/Dockerfile new file mode 100644 index 00000000..8d980cc7 --- /dev/null +++ b/claude-service/Dockerfile @@ -0,0 +1,37 @@ +# Build stage +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src + +RUN npm run build + +# Production stage +FROM node:22-alpine + +WORKDIR /app + +# Install production dependencies only +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +# Copy built files +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 -G nodejs + +USER nodejs + +EXPOSE 3100 + +ENV NODE_ENV=production +ENV PORT=3100 + +CMD ["node", "dist/index.js"] diff --git a/claude-service/README.md b/claude-service/README.md new file mode 100644 index 00000000..79a02eca --- /dev/null +++ b/claude-service/README.md @@ -0,0 +1,124 @@ +# Claude Service + +A Node.js service that wraps the official Anthropic Claude SDK, providing HTTP endpoints for the Go worker to consume. + +## Why? + +The official Anthropic SDK is only available for Node.js and Python. This service allows the Go worker to access SDK-exclusive features like: + +- Prompt caching +- Extended thinking (Claude 3.7+) +- Better streaming primitives +- Faster feature parity with API releases + +## Endpoints + +### Health Check +``` +GET /health +``` + +### Messages (Non-streaming) +``` +POST /v1/messages +Content-Type: application/json + +{ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 8192, + "system": "You are a helpful assistant.", + "messages": [ + {"role": "user", "content": "Hello!"} + ] +} +``` + +### Messages (Streaming) +``` +POST /v1/messages/stream +Content-Type: application/json +Accept: text/event-stream + +{ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 8192, + "messages": [ + {"role": "user", "content": "Hello!"} + ] +} +``` + +Returns Server-Sent Events (SSE): +``` +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}} + +event: message_stop +data: {"type":"message_stop","message":{...}} + +event: done +data: [DONE] +``` + +### Extended Thinking (Claude 3.7+) +``` +POST /v1/messages/think +Content-Type: application/json +Accept: text/event-stream + +{ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 16000, + "thinking": { + "type": "enabled", + "budget_tokens": 10000 + }, + "messages": [ + {"role": "user", "content": "Solve this complex problem..."} + ] +} +``` + +## Development + +```bash +# Install dependencies +npm install + +# Run in development mode (with hot reload) +npm run dev + +# Type check +npm run typecheck + +# Build for production +npm run build + +# Run production build +npm start +``` + +## Environment Variables + +- `ANTHROPIC_API_KEY` - Required. Your Anthropic API key. +- `PORT` - Optional. Server port (default: 3100). + +## Docker + +```bash +# Build +docker build -t claude-service . + +# Run +docker run -p 3100:3100 -e ANTHROPIC_API_KEY=your-key claude-service +``` + +## Integration with Go Worker + +Set the `CLAUDE_SERVICE_URL` environment variable in the Go worker to enable routing Claude calls through this service: + +```bash +export CLAUDE_SERVICE_URL=http://localhost:3100 +``` + +When this variable is set, supported LLM functions will use the Node service instead of the Go Anthropic SDK. diff --git a/claude-service/package-lock.json b/claude-service/package-lock.json new file mode 100644 index 00000000..48ec41d1 --- /dev/null +++ b/claude-service/package-lock.json @@ -0,0 +1,1771 @@ +{ + "name": "claude-service", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-service", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "express": "^4.21.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "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" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", + "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/claude-service/package.json b/claude-service/package.json new file mode 100644 index 00000000..aece3e8f --- /dev/null +++ b/claude-service/package.json @@ -0,0 +1,27 @@ +{ + "name": "claude-service", + "version": "0.1.0", + "description": "Node.js service wrapping the official Anthropic Claude SDK", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src/", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "express": "^4.21.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/claude-service/src/index.ts b/claude-service/src/index.ts new file mode 100644 index 00000000..c79b784c --- /dev/null +++ b/claude-service/src/index.ts @@ -0,0 +1,213 @@ +import express, { Request, Response, NextFunction } from 'express'; +import Anthropic from '@anthropic-ai/sdk'; +import { ClaudeRequestSchema, ClaudeRequest, Message } from './types'; +import { ZodError } from 'zod'; + +const app = express(); +app.use(express.json({ limit: '10mb' })); + +// Initialize Anthropic client +const anthropic = new Anthropic(); + +// Convert our Message type to Anthropic's MessageParam +function toAnthropicMessages(messages: Message[]): Anthropic.MessageParam[] { + return messages.map(m => ({ + role: m.role, + content: typeof m.content === 'string' + ? m.content + : m.content.map(block => { + if (typeof block === 'string') { + return { type: 'text' as const, text: block }; + } + return block as Anthropic.ContentBlockParam; + }), + })); +} + +// Health check +app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Non-streaming messages endpoint +app.post('/v1/messages', async (req: Request, res: Response, next: NextFunction) => { + try { + const body = ClaudeRequestSchema.parse(req.body); + + // If stream requested, redirect to streaming endpoint + if (body.stream) { + return handleStreaming(body, res); + } + + const response = await anthropic.messages.create({ + model: body.model, + max_tokens: body.max_tokens, + system: body.system, + messages: toAnthropicMessages(body.messages), + tools: body.tools?.map(t => ({ + name: t.name, + description: t.description || '', + input_schema: t.input_schema as Anthropic.Tool['input_schema'], + })), + tool_choice: body.tool_choice as Anthropic.ToolChoice | undefined, + stop_sequences: body.stop_sequences, + temperature: body.temperature, + top_p: body.top_p, + top_k: body.top_k, + }); + + res.json(response); + } catch (err) { + next(err); + } +}); + +// Streaming messages endpoint +app.post('/v1/messages/stream', async (req: Request, res: Response, next: NextFunction) => { + try { + const body = ClaudeRequestSchema.parse(req.body); + await handleStreaming(body, res); + } catch (err) { + next(err); + } +}); + +async function handleStreaming(body: ClaudeRequest, res: Response): Promise { + // Set up SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering + + try { + const stream = await anthropic.messages.stream({ + model: body.model, + max_tokens: body.max_tokens, + system: body.system, + messages: toAnthropicMessages(body.messages), + tools: body.tools?.map(t => ({ + name: t.name, + description: t.description || '', + input_schema: t.input_schema as Anthropic.Tool['input_schema'], + })), + tool_choice: body.tool_choice as Anthropic.ToolChoice | undefined, + stop_sequences: body.stop_sequences, + temperature: body.temperature, + top_p: body.top_p, + top_k: body.top_k, + }); + + // Forward all events to client + stream.on('text', (text) => { + res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`); + }); + + stream.on('message', (message) => { + res.write(`event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message })}\n\n`); + }); + + stream.on('contentBlock', (block) => { + res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', content_block: block })}\n\n`); + }); + + stream.on('finalMessage', (message) => { + res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop', message })}\n\n`); + }); + + // Wait for stream to complete + await stream.finalMessage(); + + res.write('event: done\ndata: [DONE]\n\n'); + res.end(); + } catch (err) { + // Send error as SSE event + const error = err instanceof Error ? err.message : 'Unknown error'; + res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', error })}\n\n`); + res.end(); + } +} + +// Extended thinking endpoint (Claude 3.7+) +app.post('/v1/messages/think', async (req: Request, res: Response, next: NextFunction) => { + try { + const body = ClaudeRequestSchema.parse(req.body); + + if (!body.thinking) { + res.status(400).json({ error: 'thinking parameter required for this endpoint' }); + return; + } + + // Set up SSE for streaming thinking + response + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + + const stream = await anthropic.messages.stream({ + model: body.model, + max_tokens: body.max_tokens, + thinking: body.thinking, + system: body.system, + messages: toAnthropicMessages(body.messages), + tools: body.tools?.map(t => ({ + name: t.name, + description: t.description || '', + input_schema: t.input_schema as Anthropic.Tool['input_schema'], + })), + temperature: 1, // Required for extended thinking + betas: ['interleaved-thinking-2025-05-14'], + } as Parameters[0]); + + stream.on('text', (text) => { + res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`); + }); + + // Handle thinking blocks + stream.on('contentBlock', (block) => { + res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', content_block: block })}\n\n`); + }); + + stream.on('finalMessage', (message) => { + res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop', message })}\n\n`); + }); + + await stream.finalMessage(); + res.write('event: done\ndata: [DONE]\n\n'); + res.end(); + } catch (err) { + next(err); + } +}); + +// Error handling middleware +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error('Error:', err); + + if (err instanceof ZodError) { + res.status(400).json({ + error: 'Validation error', + details: err.errors, + }); + return; + } + + if (err instanceof Anthropic.APIError) { + res.status(err.status || 500).json({ + error: err.message, + type: err.name, + }); + return; + } + + res.status(500).json({ + error: err.message || 'Internal server error', + }); +}); + +// Start server +const PORT = process.env.PORT || 3100; + +app.listen(PORT, () => { + console.log(`Claude service listening on port ${PORT}`); + console.log(`Health check: http://localhost:${PORT}/health`); +}); diff --git a/claude-service/src/types.ts b/claude-service/src/types.ts new file mode 100644 index 00000000..f88d9e8b --- /dev/null +++ b/claude-service/src/types.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; + +// Content block schemas +export const TextBlockSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}); + +export const ToolUseBlockSchema = z.object({ + type: z.literal('tool_use'), + id: z.string(), + name: z.string(), + input: z.record(z.unknown()), +}); + +export const ToolResultBlockSchema = z.object({ + type: z.literal('tool_result'), + tool_use_id: z.string(), + content: z.string(), + is_error: z.boolean().optional(), +}); + +export const ContentBlockSchema = z.union([ + TextBlockSchema, + ToolUseBlockSchema, + ToolResultBlockSchema, + z.string(), +]); + +// Message schema +export const MessageSchema = z.object({ + role: z.enum(['user', 'assistant']), + content: z.union([z.string(), z.array(ContentBlockSchema)]), +}); + +// Tool schema +export const ToolSchema = z.object({ + name: z.string(), + description: z.string().optional(), + input_schema: z.object({ + type: z.literal('object'), + properties: z.record(z.unknown()).optional(), + required: z.array(z.string()).optional(), + }), +}); + +// Main request schema +export const ClaudeRequestSchema = z.object({ + model: z.string(), + system: z.string().optional(), + messages: z.array(MessageSchema), + max_tokens: z.number(), + tools: z.array(ToolSchema).optional(), + tool_choice: z.union([ + z.object({ type: z.literal('auto') }), + z.object({ type: z.literal('any') }), + z.object({ type: z.literal('tool'), name: z.string() }), + ]).optional(), + stop_sequences: z.array(z.string()).optional(), + temperature: z.number().min(0).max(1).optional(), + top_p: z.number().min(0).max(1).optional(), + top_k: z.number().int().positive().optional(), + stream: z.boolean().optional(), + + // Extended thinking (Claude 3.7) + thinking: z.object({ + type: z.literal('enabled'), + budget_tokens: z.number().int().positive(), + }).optional(), +}); + +export type ClaudeRequest = z.infer; +export type Message = z.infer; +export type Tool = z.infer; +export type ContentBlock = z.infer; diff --git a/claude-service/tsconfig.json b/claude-service/tsconfig.json new file mode 100644 index 00000000..19517784 --- /dev/null +++ b/claude-service/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pkg/llm/claude/client.go b/pkg/llm/claude/client.go new file mode 100644 index 00000000..90b99150 --- /dev/null +++ b/pkg/llm/claude/client.go @@ -0,0 +1,371 @@ +package claude + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// Client is an HTTP client for the Claude Node.js service +type Client struct { + baseURL string + httpClient *http.Client +} + +// NewClient creates a new Claude service client +func NewClient() *Client { + baseURL := os.Getenv("CLAUDE_SERVICE_URL") + if baseURL == "" { + baseURL = "http://localhost:3100" + } + + return &Client{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 5 * time.Minute, // Long timeout for LLM calls + }, + } +} + +// Message represents a conversation message +type Message struct { + Role string `json:"role"` + Content interface{} `json:"content"` // string or []ContentBlock +} + +// ContentBlock represents a content block in a message +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input map[string]interface{} `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content string `json:"content,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +// Tool represents a tool definition +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema InputSchema `json:"input_schema"` +} + +// InputSchema represents a tool's input schema +type InputSchema struct { + Type string `json:"type"` + Properties map[string]interface{} `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` +} + +// ToolChoice represents tool selection configuration +type ToolChoice struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` +} + +// Request represents a request to the Claude service +type Request struct { + Model string `json:"model"` + System string `json:"system,omitempty"` + Messages []Message `json:"messages"` + MaxTokens int `json:"max_tokens"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice *ToolChoice `json:"tool_choice,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// Response represents a response from the Claude service +type Response struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []ContentBlock `json:"content"` + Model string `json:"model"` + StopReason string `json:"stop_reason"` + StopSequence string `json:"stop_sequence,omitempty"` + Usage Usage `json:"usage"` +} + +// Usage represents token usage information +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` +} + +// StreamEvent represents a server-sent event from the streaming endpoint +type StreamEvent struct { + Event string + Data json.RawMessage +} + +// TextDelta represents a text delta in a streaming response +type TextDelta struct { + Type string `json:"type"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` +} + +// MessageStop represents a message stop event +type MessageStop struct { + Type string `json:"type"` + Message Response `json:"message"` +} + +// CreateMessage sends a non-streaming message request +func (c *Client) CreateMessage(ctx context.Context, req *Request) (*Response, error) { + req.Stream = false + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var response Response + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &response, nil +} + +// StreamMessage sends a streaming message request and returns a channel of text deltas +func (c *Client) StreamMessage(ctx context.Context, req *Request) (<-chan string, <-chan error, error) { + req.Stream = true + + body, err := json.Marshal(req) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages/stream", bytes.NewReader(body)) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, nil, fmt.Errorf("failed to send request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + textCh := make(chan string, 100) + errCh := make(chan error, 1) + + go func() { + defer close(textCh) + defer close(errCh) + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + var currentEvent string + + for scanner.Scan() { + line := scanner.Text() + + if strings.HasPrefix(line, "event: ") { + currentEvent = strings.TrimPrefix(line, "event: ") + continue + } + + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + + if data == "[DONE]" { + return + } + + switch currentEvent { + case "content_block_delta": + var delta TextDelta + if err := json.Unmarshal([]byte(data), &delta); err == nil { + if delta.Delta.Text != "" { + select { + case textCh <- delta.Delta.Text: + case <-ctx.Done(): + errCh <- ctx.Err() + return + } + } + } + case "error": + var errData struct { + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(data), &errData); err == nil { + errCh <- fmt.Errorf("stream error: %s", errData.Error) + return + } + } + } + } + + if err := scanner.Err(); err != nil { + errCh <- fmt.Errorf("scanner error: %w", err) + } + }() + + return textCh, errCh, nil +} + +// StreamMessageWithResponse sends a streaming request and also returns the final response +func (c *Client) StreamMessageWithResponse(ctx context.Context, req *Request) (<-chan string, <-chan *Response, <-chan error, error) { + req.Stream = true + + body, err := json.Marshal(req) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages/stream", bytes.NewReader(body)) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to send request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, nil, nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + textCh := make(chan string, 100) + responseCh := make(chan *Response, 1) + errCh := make(chan error, 1) + + go func() { + defer close(textCh) + defer close(responseCh) + defer close(errCh) + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + // Increase buffer size for large responses + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + var currentEvent string + + for scanner.Scan() { + line := scanner.Text() + + if strings.HasPrefix(line, "event: ") { + currentEvent = strings.TrimPrefix(line, "event: ") + continue + } + + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + + if data == "[DONE]" { + return + } + + switch currentEvent { + case "content_block_delta": + var delta TextDelta + if err := json.Unmarshal([]byte(data), &delta); err == nil { + if delta.Delta.Text != "" { + select { + case textCh <- delta.Delta.Text: + case <-ctx.Done(): + errCh <- ctx.Err() + return + } + } + } + case "message_stop": + var msgStop MessageStop + if err := json.Unmarshal([]byte(data), &msgStop); err == nil { + select { + case responseCh <- &msgStop.Message: + case <-ctx.Done(): + errCh <- ctx.Err() + return + } + } + case "error": + var errData struct { + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(data), &errData); err == nil { + errCh <- fmt.Errorf("stream error: %s", errData.Error) + return + } + } + } + } + + if err := scanner.Err(); err != nil { + errCh <- fmt.Errorf("scanner error: %w", err) + } + }() + + return textCh, responseCh, errCh, nil +} + +// Health checks the service health +func (c *Client) Health(ctx context.Context) error { + httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health check failed with status %d", resp.StatusCode) + } + + return nil +} diff --git a/pkg/llm/claude/models.go b/pkg/llm/claude/models.go new file mode 100644 index 00000000..84f26a9d --- /dev/null +++ b/pkg/llm/claude/models.go @@ -0,0 +1,7 @@ +package claude + +// Model constants matching Anthropic's model names +const ( + ModelClaude37Sonnet = "claude-3-7-sonnet-20250219" + ModelClaude35Sonnet = "claude-3-5-sonnet-20241022" +) diff --git a/pkg/llm/expand.go b/pkg/llm/expand.go index 230b4a57..1e63bac0 100644 --- a/pkg/llm/expand.go +++ b/pkg/llm/expand.go @@ -3,16 +3,13 @@ package llm import ( "context" "fmt" + "os" anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/replicatedhq/chartsmith/pkg/llm/claude" ) func ExpandPrompt(ctx context.Context, prompt string) (string, error) { - client, err := newAnthropicClient(ctx) - if err != nil { - return "", fmt.Errorf("failed to create anthropic client: %w", err) - } - userMessage := fmt.Sprintf(`The following question is about developing a Helm chart. There is an existing chart that we will be editing. Look at the question, and help decide how to determine the existing files that are relevant to the question. @@ -29,6 +26,57 @@ Here is the prompt: %s `, prompt) + // Use Claude service if CLAUDE_SERVICE_URL is set + if os.Getenv("CLAUDE_SERVICE_URL") != "" { + return expandPromptViaService(ctx, userMessage) + } + + return expandPromptDirect(ctx, userMessage) +} + +// expandPromptViaService uses the Node.js Claude service +func expandPromptViaService(ctx context.Context, userMessage string) (string, error) { + client := claude.NewClient() + + resp, err := client.CreateMessage(ctx, &claude.Request{ + Model: claude.ModelClaude37Sonnet, + MaxTokens: 8192, + Messages: []claude.Message{ + { + Role: "user", + Content: userMessage, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to call Claude service: %w", err) + } + + if resp == nil { + return "", fmt.Errorf("received nil response from Claude service") + } + + if len(resp.Content) == 0 { + return "", fmt.Errorf("received empty content from Claude service") + } + + // Find the first text block + for _, block := range resp.Content { + if block.Type == "text" && block.Text != "" { + return block.Text, nil + } + } + + return "", fmt.Errorf("no text content in response") +} + +// expandPromptDirect uses the Anthropic Go SDK directly +func expandPromptDirect(ctx context.Context, userMessage string) (string, error) { + client, err := newAnthropicClient(ctx) + if err != nil { + return "", fmt.Errorf("failed to create anthropic client: %w", err) + } + resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{ Model: anthropic.F(anthropic.ModelClaude3_7Sonnet20250219), MaxTokens: anthropic.F(int64(8192)), @@ -38,7 +86,6 @@ Here is the prompt: return "", fmt.Errorf("failed to call Anthropic API: %w", err) } - // Check if response or response.Content is nil or empty if resp == nil { return "", fmt.Errorf("received nil response from Anthropic API") } @@ -49,6 +96,5 @@ Here is the prompt: expandedPrompt := resp.Content[0].Text - // we can inject some keywords into the prompt to help the match in the vector search return expandedPrompt, nil } From 096c15dbf1e6bc67f12383fbb51d2d93fe8efde3 Mon Sep 17 00:00:00 2001 From: Chartsmith Date: Thu, 26 Feb 2026 17:49:52 +0000 Subject: [PATCH 2/2] prototype: Replace LLM orchestration with agent CLI exec Add pkg/agent/ package that replaces the ~4K line custom LLM orchestration pipeline in pkg/llm/ with simple os/exec calls to open coding agent CLIs (claude, opencode, aider). New files: - agent.go: Core exec wrapper with streaming support - tools.go: Tool permission definitions - prompts.go: Helm-specific system prompts - workspace.go: Temp workspace management with diff detection - validate.go: Post-run helm lint/template validation - agent_test.go: Unit tests (no live API needed) - example_test.go: Shows old vs new architecture --- pkg/agent/agent.go | 228 +++++++++++++++++++++++++++++++++++++ pkg/agent/agent_test.go | 231 ++++++++++++++++++++++++++++++++++++++ pkg/agent/example_test.go | 74 ++++++++++++ pkg/agent/prompts.go | 76 +++++++++++++ pkg/agent/tools.go | 91 +++++++++++++++ pkg/agent/validate.go | 112 ++++++++++++++++++ pkg/agent/workspace.go | 177 +++++++++++++++++++++++++++++ 7 files changed, 989 insertions(+) create mode 100644 pkg/agent/agent.go create mode 100644 pkg/agent/agent_test.go create mode 100644 pkg/agent/example_test.go create mode 100644 pkg/agent/prompts.go create mode 100644 pkg/agent/tools.go create mode 100644 pkg/agent/validate.go create mode 100644 pkg/agent/workspace.go diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 00000000..ef34364a --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,228 @@ +// Package agent replaces the custom LLM orchestration in pkg/llm/ with simple +// os/exec calls to an open coding agent CLI (opencode, claude, aider, etc.). +// +// The old pkg/llm/ pipeline (~4K lines) implements: +// - Intent detection (what does the user want?) +// - Plan creation (break it into steps) +// - Action execution (execute each step with tool calls) +// - Streaming (send partial results to the client) +// +// This package replaces ALL of that with a single exec call. The coding agent +// CLI handles intent detection, planning, and execution internally. We just +// need to: shell out → stream output → collect modified files → validate. +package agent + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// Agent wraps an external coding agent CLI tool (opencode, claude, aider). +// Instead of reimplementing LLM orchestration in Go, we delegate to tools +// that already handle tool use, context management, and code editing. +type Agent struct { + // CLI is the agent binary name: "opencode", "claude", "aider" + CLI string + + // Model to use (e.g. "claude-sonnet-4-20250514", "gpt-4o") + Model string + + // SystemPrompt provides Helm-specific context and constraints. + // See prompts.go for the default. + SystemPrompt string + + // WorkDir is the chart workspace directory. + WorkDir string + + // APIKey for the LLM provider (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) + APIKey string + + // Timeout for the entire agent run. Zero means no timeout. + Timeout time.Duration +} + +// Result captures everything from an agent CLI invocation. +type Result struct { + // Output is the full stdout from the agent. + Output string + + // ModifiedFiles lists files that changed during the run. + ModifiedFiles []string + + // TokensUsed is parsed from CLI output if available (best-effort). + TokensUsed *TokenUsage + + // ExitCode of the agent process. + ExitCode int +} + +// TokenUsage tracks LLM token consumption, parsed from agent CLI output. +type TokenUsage struct { + InputTokens int + OutputTokens int +} + +// Run executes the agent synchronously and returns the result. +func (a *Agent) Run(ctx context.Context, message string) (*Result, error) { + streamCh := make(chan string, 256) + + // Drain the stream channel in background + var output strings.Builder + done := make(chan struct{}) + go func() { + defer close(done) + for chunk := range streamCh { + output.WriteString(chunk) + } + }() + + result, err := a.RunStreaming(ctx, message, streamCh) + <-done + + if result != nil && result.Output == "" { + result.Output = output.String() + } + return result, err +} + +// RunStreaming executes the agent and streams stdout chunks to streamCh. +// The channel is closed when the agent finishes. Cancelling ctx kills the process. +func (a *Agent) RunStreaming(ctx context.Context, message string, streamCh chan<- string) (*Result, error) { + defer close(streamCh) + + if a.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, a.Timeout) + defer cancel() + } + + args := a.buildArgs(message) + cmd := exec.CommandContext(ctx, a.CLI, args...) + cmd.Dir = a.WorkDir + cmd.Env = a.buildEnv() + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("creating stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("starting %s: %w", a.CLI, err) + } + + // Stream stdout line by line + var output strings.Builder + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + "\n" + output.WriteString(line) + streamCh <- line + } + + err = cmd.Wait() + + result := &Result{ + Output: output.String(), + ExitCode: cmd.ProcessState.ExitCode(), + } + + // Best-effort token parsing from stderr/stdout + result.TokensUsed = parseTokenUsage(stderr.String() + output.String()) + + if err != nil && ctx.Err() != nil { + return result, fmt.Errorf("agent cancelled or timed out: %w", ctx.Err()) + } + if err != nil { + return result, fmt.Errorf("agent exited with error: %w (stderr: %s)", err, stderr.String()) + } + + return result, nil +} + +// buildArgs constructs CLI arguments based on the agent type. +func (a *Agent) buildArgs(message string) []string { + switch a.CLI { + case "claude": + // Claude Code CLI: claude -p "message" --model model + args := []string{"-p", message, "--output-format", "text"} + if a.Model != "" { + args = append(args, "--model", a.Model) + } + if a.SystemPrompt != "" { + args = append(args, "--system-prompt", a.SystemPrompt) + } + return args + + case "opencode": + // opencode run "message" + args := []string{"run", message} + if a.Model != "" { + args = append(args, "--model", a.Model) + } + return args + + case "aider": + // aider --message "message" --yes-always --no-git + args := []string{"--message", message, "--yes-always", "--no-git"} + if a.Model != "" { + args = append(args, "--model", a.Model) + } + return args + + default: + // Generic: pass message as first arg + return []string{message} + } +} + +// buildEnv constructs the environment for the agent process. +func (a *Agent) buildEnv() []string { + env := []string{ + "HOME=" + a.WorkDir, + "PATH=/usr/local/bin:/usr/bin:/bin", + } + + if a.APIKey != "" { + // Set key for all common providers + env = append(env, + "ANTHROPIC_API_KEY="+a.APIKey, + "OPENAI_API_KEY="+a.APIKey, + ) + } + + return env +} + +// tokenPattern matches common token usage output from agent CLIs. +var tokenPattern = regexp.MustCompile(`(?i)(?:input|prompt)\s*tokens?\s*[:=]\s*(\d[\d,]*)`) +var outputTokenPattern = regexp.MustCompile(`(?i)(?:output|completion)\s*tokens?\s*[:=]\s*(\d[\d,]*)`) + +// parseTokenUsage attempts to extract token counts from agent output. +func parseTokenUsage(output string) *TokenUsage { + inputMatch := tokenPattern.FindStringSubmatch(output) + outputMatch := outputTokenPattern.FindStringSubmatch(output) + + if inputMatch == nil && outputMatch == nil { + return nil + } + + usage := &TokenUsage{} + if inputMatch != nil { + usage.InputTokens, _ = strconv.Atoi(strings.ReplaceAll(inputMatch[1], ",", "")) + } + if outputMatch != nil { + usage.OutputTokens, _ = strconv.Atoi(strings.ReplaceAll(outputMatch[1], ",", "")) + } + return usage +} diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go new file mode 100644 index 00000000..0899cd9e --- /dev/null +++ b/pkg/agent/agent_test.go @@ -0,0 +1,231 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestBuildArgs_Claude(t *testing.T) { + a := &Agent{CLI: "claude", Model: "claude-sonnet-4-20250514", SystemPrompt: "you are helpful"} + args := a.buildArgs("fix the chart") + + if args[0] != "-p" || args[1] != "fix the chart" { + t.Errorf("expected -p flag with message, got %v", args) + } + if !contains(args, "--model") { + t.Error("expected --model flag") + } + if !contains(args, "--system-prompt") { + t.Error("expected --system-prompt flag") + } +} + +func TestBuildArgs_Aider(t *testing.T) { + a := &Agent{CLI: "aider", Model: "gpt-4o"} + args := a.buildArgs("add ingress") + + if args[0] != "--message" || args[1] != "add ingress" { + t.Errorf("expected --message flag, got %v", args) + } + if !contains(args, "--yes-always") { + t.Error("expected --yes-always flag") + } +} + +func TestBuildArgs_Opencode(t *testing.T) { + a := &Agent{CLI: "opencode"} + args := a.buildArgs("hello") + + if args[0] != "run" || args[1] != "hello" { + t.Errorf("expected run subcommand, got %v", args) + } +} + +func TestParseTokenUsage(t *testing.T) { + tests := []struct { + name string + output string + want *TokenUsage + }{ + { + name: "claude style", + output: "Input tokens: 1,234\nOutput tokens: 567", + want: &TokenUsage{InputTokens: 1234, OutputTokens: 567}, + }, + { + name: "no tokens", + output: "just some output", + want: nil, + }, + { + name: "prompt tokens style", + output: "Prompt Tokens: 500\nCompletion Tokens: 200", + want: &TokenUsage{InputTokens: 500, OutputTokens: 200}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTokenUsage(tt.output) + if tt.want == nil { + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + return + } + if got == nil { + t.Fatal("expected non-nil token usage") + } + if got.InputTokens != tt.want.InputTokens { + t.Errorf("input tokens: got %d, want %d", got.InputTokens, tt.want.InputTokens) + } + if got.OutputTokens != tt.want.OutputTokens { + t.Errorf("output tokens: got %d, want %d", got.OutputTokens, tt.want.OutputTokens) + } + }) + } +} + +func TestWorkspace_ModifiedFiles(t *testing.T) { + // Create a temp "chart" directory + srcDir := t.TempDir() + os.WriteFile(filepath.Join(srcDir, "Chart.yaml"), []byte("name: test\n"), 0o644) + os.WriteFile(filepath.Join(srcDir, "values.yaml"), []byte("key: value\n"), 0o644) + + ws, err := NewWorkspace(srcDir) + if err != nil { + t.Fatal(err) + } + defer ws.Cleanup() + + // No changes yet + modified, err := ws.ModifiedFiles() + if err != nil { + t.Fatal(err) + } + if len(modified) != 0 { + t.Errorf("expected no modified files, got %v", modified) + } + + // Modify a file + os.WriteFile(filepath.Join(ws.Dir, "values.yaml"), []byte("key: changed\n"), 0o644) + + // Add a new file + os.WriteFile(filepath.Join(ws.Dir, "new.yaml"), []byte("new: file\n"), 0o644) + + modified, err = ws.ModifiedFiles() + if err != nil { + t.Fatal(err) + } + if len(modified) != 2 { + t.Errorf("expected 2 modified files, got %v", modified) + } +} + +func TestWorkspace_PathTraversal(t *testing.T) { + ws := &Workspace{Dir: "/tmp/test"} + _, err := ws.ReadFile("../../etc/passwd") + if err == nil { + t.Error("expected error for path traversal") + } +} + +func TestRunStreaming_NonexistentCLI(t *testing.T) { + a := &Agent{ + CLI: "nonexistent-agent-cli-12345", + WorkDir: t.TempDir(), + Timeout: 5 * time.Second, + } + + ch := make(chan string, 10) + _, err := a.RunStreaming(context.Background(), "hello", ch) + if err == nil { + t.Error("expected error for nonexistent CLI") + } +} + +func TestRunStreaming_WithEcho(t *testing.T) { + // Use "echo" as a fake agent CLI to test streaming + a := &Agent{ + CLI: "echo", + WorkDir: t.TempDir(), + Timeout: 5 * time.Second, + } + + ch := make(chan string, 10) + result, err := a.RunStreaming(context.Background(), "hello world", ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + + // echo receives "hello world" as its argument + if !strings.Contains(result.Output, "hello world") { + t.Errorf("expected output to contain 'hello world', got %q", result.Output) + } +} + +func TestRun_Timeout(t *testing.T) { + a := &Agent{ + CLI: "sleep", + WorkDir: t.TempDir(), + Timeout: 100 * time.Millisecond, + } + + _, err := a.Run(context.Background(), "60") + if err == nil { + t.Error("expected timeout error") + } +} + +func TestDefaultToolConfig(t *testing.T) { + tc := DefaultToolConfig() + if !tc.AllowFileWrite || !tc.AllowHelmTemplate || !tc.AllowHelmLint || !tc.AllowArtifactHubSearch { + t.Error("default config should allow all tools") + } +} + +func TestToolPermissionsPrompt(t *testing.T) { + tc := DefaultToolConfig() + prompt := ToolPermissionsPrompt(tc) + + if !strings.Contains(prompt, "File Operations") { + t.Error("prompt should mention file operations") + } + if !strings.Contains(prompt, "helm template") { + t.Error("prompt should mention helm template") + } +} + +func TestDefaultSystemPrompt(t *testing.T) { + prompt := DefaultSystemPrompt("my-chart") + if !strings.Contains(prompt, "my-chart") { + t.Error("prompt should include chart name") + } + if !strings.Contains(prompt, "helm lint") { + t.Error("prompt should mention helm lint") + } +} + +func TestBuildSystemPrompt(t *testing.T) { + prompt := BuildSystemPrompt("test", DefaultToolConfig(), "extra info") + if !strings.Contains(prompt, "extra info") { + t.Error("prompt should include extra context") + } +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/pkg/agent/example_test.go b/pkg/agent/example_test.go new file mode 100644 index 00000000..fbb3a097 --- /dev/null +++ b/pkg/agent/example_test.go @@ -0,0 +1,74 @@ +package agent_test + +import ( + "context" + "fmt" + "time" + + "github.com/replicatedhq/chartsmith/pkg/agent" +) + +// Example_oldVsNew demonstrates the architectural shift from the old +// pkg/llm/ pipeline to the new agent exec approach. +// +// OLD FLOW (pkg/llm/ - ~4K lines): +// +// 1. intent.go → Classify user intent (LLM call #1) +// 2. plan.go → Generate execution plan (LLM call #2) +// 3. execute-plan.go → Execute each step (LLM calls #3..N) +// 4. execute-action.go → Run tools for each action +// 5. parser.go → Parse LLM responses for file changes +// 6. artifacts.go → Extract and apply file modifications +// 7. streaming throughout via Centrifugo +// +// NEW FLOW (pkg/agent/ - ~500 lines): +// +// 1. agent.Run(ctx, message) → Single exec call to coding agent +// 2. validate.Validate() → helm lint + helm template +// 3. Done. +// +// The coding agent CLI (claude, opencode, aider) handles intent detection, +// planning, and execution internally. We don't need to reimplement that. +func Example_oldVsNew() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + // Configure the agent + a := &agent.Agent{ + CLI: "claude", + Model: "claude-sonnet-4-20250514", + SystemPrompt: agent.BuildSystemPrompt("my-app", agent.DefaultToolConfig(), ""), + WorkDir: "/tmp/chart-workspace", + Timeout: 5 * time.Minute, + } + + // Stream results to the user (replaces Centrifugo streaming in pkg/llm/) + streamCh := make(chan string, 256) + go func() { + for chunk := range streamCh { + // In production: send via Centrifugo to the frontend + _ = chunk + } + }() + + // Single call replaces the entire intent → plan → execute pipeline + result, err := a.RunStreaming(ctx, "Add an HPA that scales from 2 to 10 replicas based on CPU", streamCh) + if err != nil { + fmt.Printf("Agent error: %v\n", err) + return + } + + // Validate the result (replaces scattered validation in pkg/llm/) + validation, err := agent.Validate(ctx, a.WorkDir) + if err != nil { + fmt.Printf("Validation error: %v\n", err) + return + } + + if !validation.IsValid() { + // Optionally retry with validation errors + validation, _ = agent.ValidateAndRetry(ctx, a, a.WorkDir, 2) + } + + fmt.Printf("Exit code: %d, Valid: %v\n", result.ExitCode, validation.IsValid()) +} diff --git a/pkg/agent/prompts.go b/pkg/agent/prompts.go new file mode 100644 index 00000000..b84876b4 --- /dev/null +++ b/pkg/agent/prompts.go @@ -0,0 +1,76 @@ +package agent + +import "fmt" + +// prompts.go contains Helm-specific system prompts for the agent CLI. +// +// These are derived from the prompts in pkg/llm/system.go but simplified +// for the agent CLI model: instead of orchestrating multi-step tool calls +// ourselves, we give the agent a clear description of the task and let it +// handle planning and execution. + +// DefaultSystemPrompt returns the standard system prompt for Helm chart editing. +func DefaultSystemPrompt(chartName string) string { + return fmt.Sprintf(`You are ChartSmith, an expert Helm chart engineer. + +## Environment +You are working in a Helm chart workspace for %q. +The chart files are in the current directory. + +## Your Task +Edit the Helm chart files to fulfill the user's request. You have full access +to read and modify files in this directory. + +## Quality Standards +- All YAML must be valid (2-space indentation) +- Helm templates must use correct Go template syntax +- Changes must pass 'helm lint' +- Changes must render successfully with 'helm template' +- Preserve existing chart structure — modify, don't rewrite +- Use Helm best practices (parameterize values, use helpers, etc.) + +## Workflow +1. Read the existing chart files to understand the current structure +2. Make the requested changes +3. Run 'helm lint .' to verify quality +4. Run 'helm template .' to verify rendering +5. Fix any issues found + +## Output +After making changes, briefly describe what you modified and why. +`, chartName) +} + +// EndUserSystemPrompt is for sessions where the user interacts with an +// already-installed chart (values.yaml changes only, no template editing). +func EndUserSystemPrompt(chartName string) string { + return fmt.Sprintf(`You are ChartSmith, an expert Helm chart assistant. + +## Environment +You are helping configure the %q Helm chart. +You should ONLY modify values.yaml — do not edit templates or other chart files. + +## Quality Standards +- Valid YAML with 2-space indentation +- Only use values that the chart's templates actually reference +- Changes must pass 'helm lint' and 'helm template' + +## Workflow +1. Read values.yaml and relevant templates to understand available options +2. Modify values.yaml to fulfill the user's request +3. Validate with helm lint and helm template +`, chartName) +} + +// BuildSystemPrompt constructs a complete system prompt including tool +// permissions and optional workspace context. +func BuildSystemPrompt(chartName string, tools ToolConfig, extraContext string) string { + prompt := DefaultSystemPrompt(chartName) + prompt += "\n" + ToolPermissionsPrompt(tools) + + if extraContext != "" { + prompt += "\n## Additional Context\n" + extraContext + "\n" + } + + return prompt +} diff --git a/pkg/agent/tools.go b/pkg/agent/tools.go new file mode 100644 index 00000000..0e186877 --- /dev/null +++ b/pkg/agent/tools.go @@ -0,0 +1,91 @@ +package agent + +// tools.go defines the restricted tool set available to the agent CLI. +// +// When using Claude Code or similar agents, we configure allowed tools via +// the system prompt and CLI flags. This file documents what operations the +// agent is permitted to perform and provides Go helpers for the ones we +// validate server-side. +// +// Available tools for the agent: +// - File read/write (restricted to workspace directory) +// - helm template (validate rendering) +// - helm lint (check chart quality) +// - Artifact Hub search (find existing charts/examples) +// +// These map to existing ChartSmith capabilities: +// - helm-utils package for template/lint +// - recommendations package for Artifact Hub +// - workspace package for file management + +// ToolConfig defines which tools the agent is allowed to use. +type ToolConfig struct { + // AllowFileWrite permits the agent to create/modify files in the workspace. + AllowFileWrite bool + + // AllowHelmTemplate permits running helm template for validation. + AllowHelmTemplate bool + + // AllowHelmLint permits running helm lint for quality checks. + AllowHelmLint bool + + // AllowArtifactHubSearch permits searching Artifact Hub. + AllowArtifactHubSearch bool + + // AllowedPaths restricts file operations to these directories. + // Empty means workspace root only. + AllowedPaths []string +} + +// DefaultToolConfig returns the standard tool configuration for chart editing. +func DefaultToolConfig() ToolConfig { + return ToolConfig{ + AllowFileWrite: true, + AllowHelmTemplate: true, + AllowHelmLint: true, + AllowArtifactHubSearch: true, + } +} + +// ToolPermissionsPrompt returns a prompt fragment describing allowed tools. +// This is injected into the system prompt so the agent CLI knows its boundaries. +func ToolPermissionsPrompt(tc ToolConfig) string { + var parts []string + + parts = append(parts, "## Available Tools and Permissions") + + if tc.AllowFileWrite { + parts = append(parts, `- **File Operations**: You may read and write files within the workspace directory. + Only modify files under the chart root. Do not create files outside the workspace.`) + } else { + parts = append(parts, "- **File Operations**: READ ONLY. Do not modify any files.") + } + + if tc.AllowHelmTemplate { + parts = append(parts, `- **helm template**: You can run 'helm template' to validate that templates render correctly. + Use this to verify your changes produce valid Kubernetes manifests.`) + } + + if tc.AllowHelmLint { + parts = append(parts, `- **helm lint**: You can run 'helm lint' to check chart quality and best practices. + Always lint after making changes.`) + } + + if tc.AllowArtifactHubSearch { + parts = append(parts, `- **Artifact Hub**: You can search Artifact Hub for reference charts and examples. + Use this when you need to see how other charts handle similar patterns.`) + } + + parts = append(parts, ` +## Tool Restrictions +- Do NOT install packages or run arbitrary commands +- Do NOT access the network except via the tools listed above +- Do NOT modify files outside the workspace directory +- Do NOT delete the Chart.yaml or other structural files`) + + result := "" + for _, p := range parts { + result += p + "\n\n" + } + return result +} diff --git a/pkg/agent/validate.go b/pkg/agent/validate.go new file mode 100644 index 00000000..4ed54764 --- /dev/null +++ b/pkg/agent/validate.go @@ -0,0 +1,112 @@ +package agent + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// validate.go runs post-agent validation on the workspace. +// +// After the agent CLI finishes editing chart files, we validate that: +// 1. helm lint passes (chart quality) +// 2. helm template succeeds (templates render correctly) +// +// This catches errors that the agent might have introduced. +// In a full integration, this would call into the existing helm-utils +// package rather than shelling out to helm directly. + +// ValidationResult contains the outcome of post-run validation. +type ValidationResult struct { + // LintPassed is true if helm lint reported no errors. + LintPassed bool + + // TemplatePassed is true if helm template rendered successfully. + TemplatePassed bool + + // LintOutput is the raw output from helm lint. + LintOutput string + + // TemplateOutput is the raw output from helm template. + TemplateOutput string + + // Errors collects all validation error messages. + Errors []string +} + +// IsValid returns true if all validations passed. +func (v *ValidationResult) IsValid() bool { + return v.LintPassed && v.TemplatePassed +} + +// Validate runs helm lint and helm template on the workspace. +func Validate(ctx context.Context, chartDir string) (*ValidationResult, error) { + result := &ValidationResult{} + + // Run helm lint + lintOut, lintErr := runHelm(ctx, chartDir, "lint", ".") + result.LintOutput = lintOut + if lintErr != nil { + result.LintPassed = false + result.Errors = append(result.Errors, fmt.Sprintf("helm lint failed: %s", lintOut)) + } else { + result.LintPassed = true + } + + // Run helm template + tmplOut, tmplErr := runHelm(ctx, chartDir, "template", "test-release", ".") + result.TemplateOutput = tmplOut + if tmplErr != nil { + result.TemplatePassed = false + result.Errors = append(result.Errors, fmt.Sprintf("helm template failed: %s", tmplOut)) + } else { + result.TemplatePassed = true + } + + return result, nil +} + +// ValidateAndRetry runs validation; if it fails, sends errors back to the +// agent for a fix attempt. Returns the final validation result. +func ValidateAndRetry(ctx context.Context, a *Agent, chartDir string, maxRetries int) (*ValidationResult, error) { + for i := 0; i <= maxRetries; i++ { + result, err := Validate(ctx, chartDir) + if err != nil { + return nil, err + } + if result.IsValid() { + return result, nil + } + if i == maxRetries { + return result, nil + } + + // Ask the agent to fix validation errors + fixMsg := fmt.Sprintf( + "The chart has validation errors. Please fix them:\n\n%s", + strings.Join(result.Errors, "\n\n"), + ) + _, err = a.Run(ctx, fixMsg) + if err != nil { + return result, fmt.Errorf("fix attempt %d failed: %w", i+1, err) + } + } + + // unreachable + return nil, nil +} + +// runHelm executes a helm subcommand and returns combined output. +func runHelm(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "helm", args...) + cmd.Dir = dir + + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + err := cmd.Run() + return out.String(), err +} diff --git a/pkg/agent/workspace.go b/pkg/agent/workspace.go new file mode 100644 index 00000000..8e4f886c --- /dev/null +++ b/pkg/agent/workspace.go @@ -0,0 +1,177 @@ +package agent + +import ( + "crypto/sha256" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// workspace.go manages the temporary workspace for agent runs. +// +// Before an agent runs, we snapshot the chart files into a temp directory. +// After the agent finishes, we diff against the snapshot to find what changed. +// This replaces the workspace management scattered across pkg/llm/. + +// Workspace represents a temporary working directory for an agent run. +type Workspace struct { + // Dir is the workspace root directory. + Dir string + + // snapshot stores file hashes taken before the agent runs. + snapshot map[string]string + + // managed is true if we created the directory (and should clean it up). + managed bool +} + +// NewWorkspace creates a workspace from existing chart files. +// srcDir contains the chart files to copy into the workspace. +func NewWorkspace(srcDir string) (*Workspace, error) { + tmpDir, err := os.MkdirTemp("", "chartsmith-agent-*") + if err != nil { + return nil, fmt.Errorf("creating temp dir: %w", err) + } + + // Copy chart files into workspace + if err := copyDir(srcDir, tmpDir); err != nil { + os.RemoveAll(tmpDir) + return nil, fmt.Errorf("copying chart files: %w", err) + } + + ws := &Workspace{ + Dir: tmpDir, + managed: true, + } + + // Take initial snapshot for diff detection + ws.snapshot, err = hashDir(tmpDir) + if err != nil { + os.RemoveAll(tmpDir) + return nil, fmt.Errorf("snapshotting workspace: %w", err) + } + + return ws, nil +} + +// WrapExisting wraps an existing directory as a workspace (no copy). +func WrapExisting(dir string) (*Workspace, error) { + snapshot, err := hashDir(dir) + if err != nil { + return nil, fmt.Errorf("snapshotting workspace: %w", err) + } + + return &Workspace{ + Dir: dir, + snapshot: snapshot, + managed: false, + }, nil +} + +// ModifiedFiles returns files that changed since the workspace was created. +func (w *Workspace) ModifiedFiles() ([]string, error) { + current, err := hashDir(w.Dir) + if err != nil { + return nil, fmt.Errorf("scanning workspace: %w", err) + } + + var modified []string + + // Check for changed or new files + for path, hash := range current { + if oldHash, exists := w.snapshot[path]; !exists || oldHash != hash { + modified = append(modified, path) + } + } + + return modified, nil +} + +// Cleanup removes the workspace if it was created by us. +func (w *Workspace) Cleanup() error { + if w.managed { + return os.RemoveAll(w.Dir) + } + return nil +} + +// ReadFile reads a file from the workspace, returning its contents. +func (w *Workspace) ReadFile(relPath string) (string, error) { + // Prevent path traversal + cleaned := filepath.Clean(relPath) + if strings.HasPrefix(cleaned, "..") { + return "", fmt.Errorf("path traversal not allowed: %s", relPath) + } + + data, err := os.ReadFile(filepath.Join(w.Dir, cleaned)) + if err != nil { + return "", err + } + return string(data), nil +} + +// hashDir creates a map of relative path → SHA256 hash for all files. +func hashDir(dir string) (map[string]string, error) { + hashes := make(map[string]string) + + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + // Skip hidden directories (.git, etc.) + if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { + return filepath.SkipDir + } + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + hash := sha256.Sum256(data) + hashes[relPath] = fmt.Sprintf("%x", hash) + return nil + }) + + return hashes, err +} + +// copyDir recursively copies src to dst. +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip .git + if d.IsDir() && d.Name() == ".git" { + return filepath.SkipDir + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + dstPath := filepath.Join(dst, relPath) + + if d.IsDir() { + return os.MkdirAll(dstPath, 0o755) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dstPath, data, 0o644) + }) +}