From 809a9ab792d32897387bf1b274c1f096cf290d59 Mon Sep 17 00:00:00 2001 From: fajarhide Date: Wed, 1 Apr 2026 00:01:33 +0700 Subject: [PATCH 1/2] refactor: simplify project structure and add automated setup logic --- .gitignore | 4 +- README.md | 781 ++++------------------------------- cmd/server/main.go | 76 ++++ go.mod | 6 +- go.sum | 4 + internal/setup/setup.go | 438 ++++++++++++++++++++ internal/setup/setup_test.go | 220 ++++++++++ scripts/install.sh | 118 +----- 8 files changed, 832 insertions(+), 815 deletions(-) create mode 100644 go.sum create mode 100644 internal/setup/setup.go create mode 100644 internal/setup/setup_test.go diff --git a/.gitignore b/.gitignore index 89763af..866edb5 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ logs/ # Temporary files tmp/ -temp/ \ No newline at end of file +temp/ +server +*.rdb \ No newline at end of file diff --git a/README.md b/README.md index 1a50806..623028f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ - - -# Heimsense +# Heimsense πŸ”±

Stars - Last Update + Last Update Go Version - Supported Providers + Supported Providers Container Ready CI Release Version @@ -18,781 +16,164 @@ Last Commit

-> *Claude Code is the supercar. Heimsense gives any LLM the keys.* πŸ”± - -A lightweight, production-ready API adapter that gives **any LLM provider** the power of **Claude Code CLI** β€” by translating Anthropic's protocol to OpenAI's, and back. Zero dependencies. Single binary. - -``` - Heimsense - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - Claude Code CLI ────►│ translates │────► Any LLM Provider - (Anthropic format) β”‚ both ways β”‚ (OpenAI format) - ◄────│ :8080 │◄──── - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β–Ό β–Ό β–Ό - OpenAI DeepSeek Ollama - Groq Mistral LM Studio - xAI Together vLLM ... -``` - ---- - -## Why Heimsense? - -### The Mythology - -In Norse mythology, **Heimdall** is the guardian of BifrΓΆst β€” the rainbow bridge connecting the nine realms. He possesses extraordinary senses: he can see and hear everything happening across all worlds, day and night, without sleeping. His keen perception makes him the perfect sentinel, watching over the cosmos. - -### The Philosophy - -**Claude Code is a supercar.** It's one of the most powerful agentic coding tools ever built β€” autonomous file editing, multi-step reasoning, tool orchestration, and deep codebase understanding. But out of the box, only one engine fits: Claude. - -**Any LLM is a capable driver.** GPT, DeepSeek, Gemini, Llama, Qwen β€” they're all skilled, but they can't get behind the wheel of that supercar. The interface doesn't fit. The protocol doesn't match. - -**Heimsense gives any LLM the keys.** It translates the language barrier between Anthropic's protocol and OpenAI's protocol, so any model can drive the most powerful coding CLI available β€” unlocking capabilities they could never access alone. - -Just as Heimdall stands at the gateway of BifrΓΆst, deciding who may cross between realms, **Heimsense** stands at the gateway between Claude Code and the vast landscape of LLM providers β€” letting any worthy model cross the bridge: - -| Heimdall | Heimsense | -|----------|-----------| -| Guards BifrΓΆst (the bridge) | Guards your API gateway | -| Lets worthy beings cross realms | Lets any LLM drive Claude Code | -| Sees across all nine realms | Connects to 20+ LLM providers | -| Never sleeps | Always-on, production-ready | -| Heightened senses | Intelligent request translation | -| Warns of threats | Handles errors gracefully with retries | - -### The Name - -**Heim** (from Heimdall) + **Sense** (perception/awareness) = **Heimsense** - -The ability to sense, route, and adapt API requests across the LLM multiverse. - ---- - -## What Problems Does It Solve? - -| Problem | Solution | -|---------|----------| -| **Vendor Lock-in** | Switch providers without code changes | -| **High API Costs** | Use cheaper alternatives to Anthropic | -| **Model Limitations** | Access GPT, Gemini, DeepSeek, Llama, etc. via Claude Code | -| **API Downtime** | Automatic retry with exponential backoff | -| **Format Incompatibility** | Seamless Anthropic ↔ OpenAI translation | -| **Complex Setup** | Single binary, zero config complexity | - ---- - -## Benefits - -### Cost Savings -- Use budget-friendly providers like DeepSeek, Groq, or Together AI -- Pay fraction of Anthropic's pricing for similar capabilities - -### Flexibility -- Switch between providers by changing a single environment variable -- Test and compare different models with the same interface +> *Claude Code is the supercar. Heimsense unlocks it for any LLM.* -### Reliability -- Automatic retry on transient failures (5xx errors) -- Exponential backoff prevents overwhelming upstream -- Graceful shutdown preserves in-flight requests +A lightweight, production-ready API adapter that unlocks **Claude Code CLI** for **any LLM provider** (OpenAI, DeepSeek, Groq, local models) by translating Anthropic's protocol to OpenAI's, and back. **Zero Python/Node dependencies. Single binary.** -### Simplicity -- Single binary deployment -- Environment-based configuration -- Works with existing Claude Code setup (one command) - -### Transparency -- Structured JSON logging for observability -- Health check endpoint for monitoring -- Request/response metrics in logs - ---- - -## How Heimsense Works - -### Architecture - -```mermaid -graph LR - subgraph Client - CC["Claude Code
Anthropic format"] - end - - subgraph Heimsense - direction TB - CFG["Config
env / .env loader"] - H["Handler
parse & validate"] - A["Adapter
transform"] - CL["Client
HTTP + retry"] - CFG -.-> H - CFG -.-> CL - H --> A --> CL - end - - subgraph Upstream - LP["LLM Provider
OpenAI format"] - end - - CC -- "POST /v1/messages
Anthropic request" --> H - CL -- "/v1/chat/completions
OpenAI request" --> LP - LP -. "OpenAI response" .-> CL - CL -. "Anthropic response" .-> CC -``` - -### Request Flow - -```mermaid -sequenceDiagram - participant CC as Claude Code CLI - participant H as Handler - participant A as Adapter - participant CL as Client - participant UP as LLM Provider - - CC->>H: POST /v1/messages (Anthropic) - H->>H: Validate (method, JSON, messages, max_tokens) - H->>A: ToOpenAIRequest(req, defaultModel, forceModel) - A-->>H: OpenAIRequest - - alt Non-Streaming - H->>CL: ChatCompletion() - CL->>UP: POST /chat/completions (stream=false) - UP-->>CL: OpenAIResponse (JSON) - CL-->>H: OpenAIResponse - H->>A: ToAnthropicResponse() - A-->>H: AnthropicResponse - H-->>CC: JSON response - else Streaming (SSE) - H->>CL: ChatCompletionStream() - CL->>UP: POST /chat/completions (stream=true) - UP-->>CL: SSE stream (data: chunks) - loop Each SSE chunk - CL-->>H: OpenAI chunk - H->>H: Translate to Anthropic SSE events - H-->>CC: event: content_block_delta - end - H-->>CC: event: message_stop - end +```text + Claude Code CLI ────► [ Heimsense ] ────► Any LLM Provider + (Anthropic format) [ :8080 ] (OpenAI format) ``` -### Model Resolution - -```mermaid -flowchart TD - A["Incoming request"] --> B{ForceModel set?} - B -- Yes --> C["Use ForceModel
always override"] - B -- No --> D{Request has model?} - D -- Yes --> E["Use request model
from client"] - D -- No --> F{DefaultModel set?} - F -- Yes --> G["Use DefaultModel
fallback"] - F -- No --> H["Empty
upstream decides"] -``` - -### Steps - -1. **Receive** β€” Claude Code sends Anthropic-format request to `/v1/messages` -2. **Validate** β€” Handler checks method, JSON body, required fields -3. **Transform** β€” Adapter converts Anthropic schema β†’ OpenAI schema (with model resolution) -4. **Forward** β€” Client sends to upstream with retry logic (exponential backoff on 5xx) -5. **Adapt** β€” Response transformed back to Anthropic format -6. **Return** β€” Claude Code receives expected Anthropic response +## ✨ Features & Benefits -### Translation Layer - -| Anthropic | Direction | OpenAI | -|-----------|-----------|--------| -| `/v1/messages` | β†’ | `/v1/chat/completions` | -| `content[]` array | β†’ | `message.content` string | -| `system` field | β†’ | `messages[0]` with role:system | -| `max_tokens` | ↔ | `max_tokens` | -| `tools` | ↔ | `tools` / `functions` | -| `input_tokens` | ← | `prompt_tokens` | -| `output_tokens` | ← | `completion_tokens` | - ---- - -## Features - -- Full `/v1/messages` β†’ `/v1/chat/completions` translation -- Streaming (SSE) with Anthropic event protocol -- String and array content formats -- System prompt handling -- Function calling (tools) support -- Authorization header passthrough -- Retry with exponential backoff (5xx) -- Configurable timeout -- Structured JSON logging (`slog`) -- Graceful shutdown (SIGINT/SIGTERM) -- Health check endpoint (`/health`) -- Auto-setup script for Claude Code -- Container support (Podman/Docker) - ---- - -## Requirements - -- Go 1.22+ (for building from source) -- Podman or Docker (for containerized deployment) -- API key from any OpenAI-compatible provider +* **Use Any Model:** DeepSeek for cheap coding, ChatGPT, Groq for speed, or local Ollama. +* **Cost Savings:** Pay a fraction of Anthropic's pricing. +* **Zero Dependencies:** Single Go binary. No Python, no Node.js. +* **Production Ready:** Auto-retries on 5xx errors, graceful shutdown, health checks. +* **Auto Setup:** Interactive CLI wizard configures Claude Code for you automatically. --- -## Quick Start - -### Option 1: One-Line Install (Recommended) +## πŸš€ Quick Start +### 1. Install Heimsense +Use the one-line installer (auto-detects OS & installs to `~/.local/bin/`): ```bash curl -fsSL https://raw.githubusercontent.com/fajarhide/heimsense/main/scripts/install.sh | bash ``` +*(Alternatively, download the binary from [Releases](https://github.com/fajarhide/heimsense/releases) or build it yourself using `make build`)* -This will: -1. Auto-detect your OS & architecture -2. Download the latest binary from GitHub Releases -3. Install to `~/.local/bin/` -4. Configure Claude Code settings (prompts for API key) - -Then start Heimsense and run Claude: - +### 2. Configure & Run +Run the interactive setup. It will ask for your API key and automatically configure Claude Code: ```bash -heimsense - -# In another terminal: -claude -# Inside Claude β†’ /model β†’ select Heimsense Custom Model +heimsense setup ``` -Config is saved to `~/.heimsense/.env` β€” edit it anytime to change provider, key, or model. - -### Option 2: Native Go - +Then, start the Heimsense server: ```bash -# 1. Setup environment -cp env.example .env -# Edit .env β†’ set your ANTHROPIC_API_KEY - -# 2. Start Heimsense -make run - -# 3. Configure Claude Code -make setup - -# 4. Run Claude Code -claude - -# 5. Select Model (inside Claude) -/model -# Select your custom model (e.g., Heimsense Model) +heimsense run ``` -### Option 2: Podman - +### 3. Use in Claude Code +In a new terminal, open Claude Code: ```bash -# 1. Setup environment -cp env.example .env -# Edit .env β†’ set your ANTHROPIC_API_KEY - -# 2. Build and run -make podman-build -make podman-run - -# 3. Configure Claude Code -make setup - -# 4. Run Claude Code -claude - -# 5. Select Model (inside Claude) -/model -# Select your custom model (e.g., Heimsense Model) -``` - -### Option 3: Docker - -```bash -# 1. Setup environment -cp env.example .env - -# 2. Build and run -make docker-build -make docker-run - -# 3. Configure Claude Code -make setup - -# 4. Run Claude Code claude - -# 5. Select Model (inside Claude) -/model -# Select your custom model (e.g., Heimsense Model) +# Once inside, type /model and select "Heimsense Custom Model" ``` --- -## Configuration +## βš™οΈ Configuration -All configuration via environment variables (or `.env` file): +Heimsense uses `~/.heimsense/.env` for configuration. You can edit this file to change your provider or model at any time. -| Variable | Default | Description | +| Variable | Example | Description | |----------|---------|-------------| -| `ANTHROPIC_BASE_URL` | `https://api.openai.com/v1` | Upstream OpenAI-compatible API | -| `ANTHROPIC_API_KEY` | β€” | Fallback API key for upstream | -| `ANTHROPIC_CUSTOM_MODEL_OPTION` | β€” | Default model if request doesn't specify one | -| `ANTHROPIC_CUSTOM_MODEL_OPTION_NAME` | β€” | Display name in Claude Code `/model` menu | -| `ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION` | β€” | Description shown in Claude Code `/model` menu | -| `ANTHROPIC_CUSTOM_FORCE_MODEL` | β€” | Force all requests to use this model (overrides client) | -| `LISTEN_ADDR` | `:8080` | Server listen address | -| `REQUEST_TIMEOUT_MS` | `120000` | Upstream timeout (ms) | -| `MAX_RETRIES` | `3` | Retry attempts on 5xx errors | - -### Example `.env` +| `ANTHROPIC_BASE_URL` | `https://api.openai.com/v1` | Upstream API URL | +| `ANTHROPIC_API_KEY` | `sk-...` | Your API key | +| `ANTHROPIC_CUSTOM_MODEL_OPTION` | `gpt-4o` | Model used if none is specified | +| `LISTEN_ADDR` | `:8080` | Local server port | -```bash -ANTHROPIC_BASE_URL=https://api.openai.com/v1 -ANTHROPIC_API_KEY=sk-your-api-key-here -ANTHROPIC_CUSTOM_MODEL_OPTION=gpt-5.1 -ANTHROPIC_CUSTOM_FORCE_MODEL= -LISTEN_ADDR=:8080 -REQUEST_TIMEOUT_MS=120000 -MAX_RETRIES=3 -``` - ---- - -## Supported Providers - -Heimsense works with any OpenAI-compatible API: - -### General LLM Providers - -| Provider | Base URL | Notes | -|----------|----------|-------| -| OpenAI | `https://api.openai.com/v1` | Official GPT models | -| [DeepSeek](https://deepseek.com) | `https://api.deepseek.com/v1` | Excellent for coding, competitive pricing | -| [GLM (Zhipu AI)](https://open.bigmodel.cn) | `https://open.bigmodel.cn/api/paas/v4` | Chinese LLM, GLM-4 series | -| [MiniMax](https://minimax.chat) | `https://api.minimax.chat/v1` | Chinese LLM provider | -| [Groq](https://groq.com) | `https://api.groq.com/openai/v1` | Ultra-fast inference (LPU) | -| [Together AI](https://together.ai) | `https://api.together.xyz/v1` | Open-source models | -| [OpenRouter](https://openrouter.ai) | `https://openrouter.ai/api/v1` | Multi-provider gateway | -| [Fireworks AI](https://fireworks.ai) | `https://api.fireworks.ai/inference/v1` | Fast serverless inference | -| [Replicate](https://replicate.com) | `https://api.replicate.com/v1` | Model hosting platform | -| [Perplexity](https://perplexity.ai) | `https://api.perplexity.ai` | Search-augmented LLM | -| [Mistral](https://mistral.ai) | `https://api.mistral.ai/v1` | European LLM provider | -| [Cohere](https://cohere.com) | `https://api.cohere.ai/v1` | Enterprise LLM | -| [xAI (Grok)](https://x.ai) | `https://api.x.ai/v1` | Elon Musk's AI company | - -### Coding-Focused LLMs - -| Provider | Models | Best For | -|----------|--------|----------| -| [DeepSeek](https://deepseek.com) | `deepseek-coder` | Code generation, debugging | -| [Cursor](https://cursor.sh) | Various | AI-powered IDE | -| [Codeium](https://codeium.com) | `codeium` | Free code completion | -| [Tabnine](https://tabnine.com) | Various | Enterprise code assistant | -| [Amazon CodeWhisperer](https://aws.amazon.com/codewhisperer) | Various | AWS-integrated coding | -| [Sourcegraph Cody](https://sourcegraph.com/cody) | Various | Code understanding | -| [Replit AI](https://replit.com) | Various | Browser-based coding | -| [CodeLlama](https://huggingface.co/codellama) | `codellama-*` | Meta's code model (via Ollama/Together) | -| [StarCoder](https://huggingface.co/bigcode) | `starcoder*` | BigCode's models (via Ollama/Together) | - -### Local / Self-Hosted - -| Provider | Base URL | Notes | -|----------|----------|-------| -| [Ollama](https://ollama.ai) | `http://localhost:11434/v1` | Run models locally | -| [LM Studio](https://lmstudio.ai) | `http://localhost:1234/v1` | GUI for local models | -| [vLLM](https://github.com/vllm-project/vllm) | `http://localhost:8000/v1` | High-performance serving | -| [LocalAI](https://localai.io) | `http://localhost:8080/v1` | Drop-in OpenAI replacement | -| [Text Generation WebUI](https://github.com/oobabooga/text-generation-webui) | Varies | Flexible local inference | - -### Popular Models by Use Case - -| Use Case | Recommended Models | -|----------|-------------------| -| **General Chat** | `gpt-4o`, `claude-3-opus`, `glm-4`, `deepseek-chat` | -| **Coding** | `deepseek-coder`, `gpt-4o`, `claude-3.5-sonnet`, `codellama-70b` | -| **Fast/Cheap** | `gpt-4o-mini`, `deepseek-chat`, `glm-4-flash`, `groq-llama3` | -| **Large Context** | `claude-3-opus` (200K), `glm-4` (128K), `deepseek` (64K) | -| **Local** | `llama3`, `codellama`, `mistral`, `qwen2.5-coder` | +*Tip: If you manually change the port in `.env`, run `heimsense sync` to update Claude Code's settings.* --- -## Using with Claude Code - -### Auto Setup (Recommended) - -```bash -make setup -``` - -This updates `~/.claude/settings.json`: - -```diff - { - "env": { -- "ANTHROPIC_BASE_URL": "https://api.anthropic.com", -+ "ANTHROPIC_BASE_URL": "http://localhost:8080", - "ANTHROPIC_AUTH_TOKEN": "sk-xxx", - } - } -``` +## 🐳 Docker / Podman -**To revert:** +If you prefer containers, Heimsense is available as a lightweight ~15MB image. ```bash -make revert -``` +# 1. Prepare configuration +cp env.example .env +nano .env # Add your API key and Base URL -### Manual Setup +# 2. Run with Docker +docker run -d \ + --name heimsense \ + -p 8080:8080 \ + -v $(pwd)/.env:/.env \ + ghcr.io/fajarhide/heimsense:latest -```bash -export ANTHROPIC_BASE_URL=http://localhost:8080 -export ANTHROPIC_API_KEY=your-api-key -claude +# 3. Setup Claude Code locally +heimsense setup ``` +*(For Podman, just replace `docker` with `podman`)* --- -## Container Deployment +## 🧩 Supported Providers -### Podman +Heimsense works flawlessly with **any** OpenAI-compatible API: -```bash -# Build -podman build -t heimsense:latest . +* **Cloud Providers:** OpenAI, DeepSeek, Groq, Together AI, Mistral, xAI (Grok), OpenRouter, Fireworks AI. +* **Local / Self-Hosted:** Ollama, LM Studio, vLLM, LocalAI. -# Run -podman run -d \ - --name heimsense \ - -p 8080:8080 \ - --env-file .env \ - heimsense:latest +--- -# Or with compose -podman-compose up -d -``` +## 🧠 How It Works -### Docker +Heimsense acts as a transparent reverse proxy between Claude Code and your LLM of choice. -```bash -# Build -docker build -t heimsense:latest . +1. Claude Code sends a request in **Anthropic format** (`/v1/messages`). +2. Heimsense translates the payload to **OpenAI format** (`/v1/chat/completions`). +3. Your chosen LLM provider responds. +4. Heimsense translates the response (including SSE stream and tool/function calls) back to the Anthropic format expected by Claude Code. -# Run -docker run -d \ - --name heimsense \ - -p 8080:8080 \ - --env-file .env \ - heimsense:latest +--- -# Or with compose -docker compose up -d -``` +## πŸ†š Why Heimsense? (vs Alternatives) -### Container Features +While there are Python and Node.js proxies available, Heimsense is built in Go for maximum simplicity: -- Multi-stage build (~15MB image) -- Non-root user for security -- Read-only filesystem with tmpfs -- Health check support -- Graceful shutdown +* **No `pip install`, no `npm install`.** Just a single compiled binary. +* **Extremely lightweight.** Uses <20MB of RAM. +* **Built specifically for Claude Code.** Includes CLI commands (`setup`, `sync`, `run`) to automate configuration directly. +* **Production-ready defaults.** Built-in retry with exponential backoff, graceful shutdown, structured logging, and health checks. --- -## Make Targets +## πŸ› οΈ Development & API Reference + +If you want to contribute, build from source, or use the API manually: ```bash -# Development make run # Build + start server -make dev # Run via `go run` -make build # Compile to ./bin/ make test # Run tests -make fmt # Format code -make lint # Run go vet -make clean # Remove build artifacts - -# Claude Code Setup -make setup # Configure Claude Code -make revert # Revert settings - -# Docker -make docker-build # Build image -make docker-run # Run with compose -make docker-stop # Stop compose -make docker-logs # View logs - -# Podman -make podman-build # Build image -make podman-run # Run with compose -make podman-stop # Stop compose -make podman-logs # View logs - -# Help -make help +make build # Compile to ./bin/ +make lint # Code formatting and linting ``` ---- - -## API Endpoints +
+Click to view API details -### `POST /v1/messages` +### `POST /v1/messages` -**Non-streaming:** - -```bash -curl -X POST http://localhost:8080/v1/messages \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer your-key" \ - -d '{ - "model": "gpt-5.1", - "max_tokens": 1024, - "messages": [{"role": "user", "content": "Hello!"}] - }' -``` +You can interact with Heimsense just like the official Anthropic API: **Streaming:** - ```bash curl -X POST http://localhost:8080/v1/messages \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your-key" \ -d '{ - "model": "gpt-5.1", + "model": "gpt-4o", "max_tokens": 1024, "stream": true, - "messages": [{"role": "user", "content": "Tell me a story"}] + "messages": [{"role": "user", "content": "Tell me a story about Heimdall."}] }' ``` -**With tools:** - -```bash -curl -X POST http://localhost:8080/v1/messages \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer your-key" \ - -d '{ - "model": "gpt-5.1", - "max_tokens": 1024, - "tools": [{ - "name": "get_weather", - "description": "Get weather", - "input_schema": { - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"] - } - }], - "messages": [{"role": "user", "content": "Weather in Tokyo?"}] - }' -``` +*(Tool calling is fully supported)* ### `GET /health` - ```bash curl http://localhost:8080/health # β†’ {"status":"ok"} ``` ---- - -## API Translation Reference - -### Request: Anthropic β†’ OpenAI - -| Anthropic | OpenAI | Notes | -|-----------|--------|-------| -| `model` | `model` | Pass-through with fallback | -| `messages` | `messages` | Arrays flattened | -| `system` | `messages[0]` | Prepended as system | -| `max_tokens` | `max_tokens` | Direct | -| `temperature` | `temperature` | Direct | -| `top_p` | `top_p` | Direct | -| `stream` | `stream` | Enables SSE | -| `stop_sequences` | `stop` | Direct | -| `tools` | `tools` | Function calling | - -### Response: OpenAI β†’ Anthropic - -| OpenAI | Anthropic | -|--------|-----------| -| `choices[0].message.content` | `content[0].text` | -| `choices[0].message.tool_calls` | `content[].tool_use` | -| `usage.prompt_tokens` | `usage.input_tokens` | -| `usage.completion_tokens` | `usage.output_tokens` | -| `finish_reason: "stop"` | `stop_reason: "end_turn"` | -| `finish_reason: "length"` | `stop_reason: "max_tokens"` | -| `finish_reason: "tool_calls"` | `stop_reason: "tool_use"` | - -### Streaming Events - -``` -message_start β†’ content_block_start - β†’ content_block_delta (repeated) - β†’ content_block_stop β†’ message_delta β†’ message_stop -``` - ---- - -## Project Structure - -``` -heimsense/ -β”œβ”€β”€ .github/workflows/ # CI, Release, Docker -β”œβ”€β”€ cmd/server/main.go # Entry point + logging middleware -β”œβ”€β”€ internal/ -β”‚ β”œβ”€β”€ adapter/ -β”‚ β”‚ β”œβ”€β”€ transform.go # Anthropic ↔ OpenAI transformation -β”‚ β”‚ └── transform_test.go # Adapter tests -β”‚ β”œβ”€β”€ client/ -β”‚ β”‚ β”œβ”€β”€ openai.go # HTTP client + retry -β”‚ β”‚ └── openai_test.go # Client tests -β”‚ β”œβ”€β”€ config/ -β”‚ β”‚ β”œβ”€β”€ config.go # Config loader -β”‚ β”‚ β”œβ”€β”€ config_test.go # Config tests -β”‚ β”‚ └── dotenv.go # .env file parser -β”‚ └── handler/ -β”‚ β”œβ”€β”€ messages.go # Request handler + SSE streaming -β”‚ └── messages_test.go # Handler tests -β”œβ”€β”€ scripts/ -β”‚ β”œβ”€β”€ install.sh # One-line installer -β”‚ └── setup-claude.sh # Claude Code setup -β”œβ”€β”€ Containerfile # Container build -β”œβ”€β”€ docker-compose.yaml # Compose config -β”œβ”€β”€ Makefile # Build targets -β”œβ”€β”€ env.example # Config template -β”œβ”€β”€ LICENSE # MIT License -└── go.mod # Module definition -``` +
--- - -## Development - -### Run Tests - -```bash -make test -``` - -### Build - -```bash -make build # ./bin/heimsense -``` - -### Code Quality - -```bash -make fmt -make lint -``` - ---- - -## Troubleshooting - -### Port in use - -```bash -lsof -i :8080 -LISTEN_ADDR=:8081 make run -``` - -### View logs - -```bash -make podman-logs -# or -make docker-logs -``` - -### Auth errors - -- Check `ANTHROPIC_API_KEY` in `.env` -- Verify `ANTHROPIC_BASE_URL` is correct -- Ensure key format matches provider requirements - ---- - -## Comparison with Alternatives - -Several open-source projects solve the same problem β€” bridging Claude Code CLI to non-Anthropic providers. Here's how Heimsense compares: - -### Similar Projects - -| Project | Language | Approach | Dependencies | -|---------|----------|----------|-------------| -| [claude-code-proxy](https://github.com/fuergaosi233/claude-code-proxy) (fuergaosi233) | Python | Full-featured proxy with model mapping | Python runtime, pip packages | -| [claude-code-proxy](https://github.com/1rgs/claude-code-proxy) (1rgs) | Python | LiteLLM-powered, supports 100+ providers | Python, LiteLLM, many transitive deps | -| [anthropic-proxy-rs](https://github.com/m0n0x41d/anthropic-proxy-rs) | Rust | High-performance binary | Rust toolchain to build | -| [claude-adapter](https://github.com/shantoislamdev/claude-adapter) | Node.js | Interactive CLI wizard for setup | Node.js runtime, npm packages | -| **Heimsense** | **Go** | **Single binary, zero runtime dependencies** | **None (pure Go stdlib)** | - -### Feature Comparison - -| Feature | Heimsense | Python proxies | Rust proxy | Node.js adapter | -|---------|-----------|---------------|------------|-----------------| -| Single binary deployment | βœ… | ❌ (needs runtime) | βœ… | ❌ (needs runtime) | -| Zero dependencies | βœ… | ❌ | βœ… | ❌ | -| Streaming (SSE) | βœ… | βœ… | βœ… | βœ… | -| Tool calling / function calling | βœ… | βœ… | βœ… | βœ… | -| Retry with backoff | βœ… | Varies | ❌ | ❌ | -| Container image size | ~15 MB | 100+ MB | ~10 MB | 150+ MB | -| One-line install script | βœ… | ❌ | ❌ | ❌ | -| Auto Claude Code setup | βœ… | Varies | ❌ | βœ… | -| Structured JSON logging | βœ… | Varies | βœ… | ❌ | -| Health check endpoint | βœ… | ❌ | ❌ | ❌ | -| Model force override | βœ… | ❌ | ❌ | ❌ | -| Auth header passthrough | βœ… | βœ… | βœ… | βœ… | -| Codebase size | ~700 lines | 1000+ lines | 500+ lines | 800+ lines | -| Easy to read & contribute | βœ… (Go) | βœ… (Python) | ⚠️ (Rust) | βœ… (JS) | - -### When to Use Heimsense - -Heimsense is the best choice when you want: - -- **Minimal footprint** β€” A single binary with no runtime dependencies (no Python, Node, or Rust toolchain needed) -- **Fast deployment** β€” Download, configure, run. One-line install or copy the binary -- **Tiny containers** β€” ~15 MB container image, ideal for resource-constrained environments -- **Readable codebase** β€” ~700 lines of straightforward Go, easy to audit, fork, and extend -- **Production-ready defaults** β€” Built-in retry, graceful shutdown, structured logging, and health checks out of the box - -### When to Use Alternatives - -- **100+ provider support** β€” If you need routing to obscure providers, LiteLLM-based proxies have wider coverage -- **Python ecosystem** β€” If your team is Python-first and prefers `pip install` workflows -- **Maximum performance** β€” The Rust proxy (`anthropic-proxy-rs`) may have slightly lower latency for extremely high-throughput use cases -- **Interactive setup wizard** β€” `claude-adapter` provides a guided CLI experience for first-time configuration - -### Design Philosophy - -Heimsense follows the Unix philosophy: **do one thing well**. - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Design Principles β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ β€’ Zero external dependencies (pure Go standard library) β”‚ -β”‚ β€’ Single responsibility (Anthropic ↔ OpenAI, nothing β”‚ -β”‚ else) β”‚ -β”‚ β€’ Convention over configuration (sensible defaults) β”‚ -β”‚ β€’ Transparency (structured logs for every request) β”‚ -β”‚ β€’ Resilience (retry, backoff, graceful shutdown) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -The entire adapter is ~700 lines of Go. There is no framework, no ORM, no dependency injection, no magic. Every line serves the core mission: **translate Anthropic requests to OpenAI format and back, reliably.** - ---- - -## License - -MIT - ---- - -> *"Heimdall guards the BifrΓΆst, so that any worthy being may cross between realms."* β€” Heimsense guards your API, so that any LLM may drive Claude Code. πŸ”± +*Heimsense: Inspired by Heimdall, the guardian of the BifrΓΆst bridge. Unlocking cross-realm AI capabilities.* +**License:** [MIT](./LICENSE) diff --git a/cmd/server/main.go b/cmd/server/main.go index df8dc34..aa6294d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "log/slog" "net/http" "os" @@ -12,9 +13,62 @@ import ( "github.com/fajarhide/heimsense/internal/client" "github.com/fajarhide/heimsense/internal/config" "github.com/fajarhide/heimsense/internal/handler" + "github.com/fajarhide/heimsense/internal/setup" ) +// Version is the current version of the Heimsense binary. +// Can be overridden via -ldflags="-X main.Version=v0.1.x" +var Version = "v0.1.1" + func main() { + if len(os.Args) < 2 { + printHelp() + os.Exit(0) + } + + command := os.Args[1] + + // Handle subcommands. + switch command { + case "setup": + if err := setup.RunWizard(); err != nil { + fmt.Fprintf(os.Stderr, " βœ— Setup failed: %v\n", err) + os.Exit(1) + } + // Set command to run so it continues to start server + command = "run" + case "run": + // Check first-run config + if setup.NeedsSetup() { + fmt.Println(" β„Ή First run detected. Let's configure your setup.") + if err := setup.RunWizard(); err != nil { + fmt.Fprintf(os.Stderr, " βœ— Setup failed: %v\n", err) + os.Exit(1) + } + } + // Continues outside the switch to start the server. + case "sync": + if err := setup.SyncToClaude(); err != nil { + fmt.Fprintf(os.Stderr, " βœ— Sync failed: %v\n", err) + os.Exit(1) + } + os.Exit(0) + case "version", "-v", "--version": + fmt.Printf("heimsense version %s\n", Version) + os.Exit(0) + case "help", "-h", "--help": + printHelp() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) + printHelp() + os.Exit(1) + } + + if command != "run" { + return // Should not be accessible due to os.Exit in other cases, but just to be safe. + } + // Structured logger. logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, @@ -115,3 +169,25 @@ func (sw *statusWriter) Flush() { f.Flush() } } + +func printHelp() { + fmt.Println() + fmt.Printf(" %sHEIMΒ·SENSE%s\n", "\033[1;36m", "\033[0m") + fmt.Printf(" %sUnlock Your Claude Code for Any LLM%s\n", "\033[3;36m", "\033[0m") + fmt.Println() + fmt.Println(" Usage:") + fmt.Println(" heimsense [command]") + fmt.Println() + fmt.Println(" Commands:") + fmt.Println(" setup Launch the interactive setup wizard to configure provider, model, and port.") + fmt.Println(" run Start the Heimsense server (also runs setup if config is missing).") + fmt.Println(" sync Read ~/.heimsense/.env and sync its values to ~/.claude/settings.json.") + fmt.Println(" version Show current version.") + fmt.Println(" help Show this help message.") + fmt.Println() + fmt.Println(" Examples:") + fmt.Println(" heimsense setup (Run interactive configuration)") + fmt.Println(" heimsense run (Start background daemon/server)") + fmt.Println(" heimsense sync (Sync manual .env changes to Claude Code)") + fmt.Println() +} diff --git a/go.mod b/go.mod index 1db494b..4f9d692 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module github.com/fajarhide/heimsense -go 1.22.0 +go 1.25.0 + +require golang.org/x/term v0.41.0 + +require golang.org/x/sys v0.42.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..91a25f5 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..f5c7b49 --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,438 @@ +package setup + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "golang.org/x/term" +) + +// ANSI color helpers +const ( + cyan = "\033[0;36m" + green = "\033[0;32m" + bold = "\033[1m" + dim = "\033[2m" + nc = "\033[0m" +) + +// Provider represents a supported LLM provider. +type Provider struct { + Name string + BaseURL string +} + +// providers is the list of known providers. +var providers = []Provider{ + {Name: "OpenAI", BaseURL: "https://api.openai.com/v1"}, + {Name: "DeepSeek", BaseURL: "https://api.deepseek.com/v1"}, + {Name: "Groq", BaseURL: "https://api.groq.com/openai/v1"}, + {Name: "OpenRouter", BaseURL: "https://openrouter.ai/api/v1"}, + {Name: "Ollama (local)", BaseURL: "http://localhost:11434/v1"}, +} + +// SetupConfig holds user-provided setup values. +type SetupConfig struct { + BaseURL string + APIKey string + Model string + ModelName string + ModelDesc string + ListenAddr string +} + +// ConfigDir returns the path to ~/.heimsense. +func ConfigDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".heimsense") +} + +// ConfigPath returns the path to ~/.heimsense/.env. +func ConfigPath() string { + return filepath.Join(ConfigDir(), ".env") +} + +// NeedsSetup returns true if ~/.heimsense/.env does not exist. +func NeedsSetup() bool { + _, err := os.Stat(ConfigPath()) + return os.IsNotExist(err) +} + +// RunWizard runs the interactive first-run setup wizard. +// It prompts the user for provider, API key, model, then writes +// config files and configures Claude Code. +func RunWizard() error { + reader := bufio.NewReader(os.Stdin) + + printHeader() + + // 1. Provider selection + baseURL, err := promptProvider(reader) + if err != nil { + return fmt.Errorf("provider selection: %w", err) + } + + // 2. API Key (masked input) + apiKey, err := promptAPIKey() + if err != nil { + return fmt.Errorf("api key input: %w", err) + } + + // 3. Model name + model, err := promptModel(reader) + if err != nil { + return fmt.Errorf("model input: %w", err) + } + + // 4. Listen port + listenAddr, err := promptPort(reader) + if err != nil { + return fmt.Errorf("port input: %w", err) + } + + cfg := SetupConfig{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + ModelName: "Heimsense Custom Model", + ModelDesc: "Custom model via Heimsense adapter", + ListenAddr: listenAddr, + } + + // Show summary + fmt.Println() + fmt.Printf(" %sβ”Œβ”€ Summary ─────────────────────────────────┐%s\n", dim, nc) + fmt.Printf(" %sβ”‚%s Provider %s%s%s\n", dim, nc, cyan, cfg.BaseURL, nc) + fmt.Printf(" %sβ”‚%s API Key %s%s%s\n", dim, nc, dim, maskKey(cfg.APIKey), nc) + fmt.Printf(" %sβ”‚%s Model %s%s%s\n", dim, nc, cyan, cfg.Model, nc) + fmt.Printf(" %sβ”‚%s Listen %s%s%s\n", dim, nc, cyan, cfg.ListenAddr, nc) + fmt.Printf(" %sβ””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜%s\n", dim, nc) + fmt.Println() + + // 5. Write config + if err := WriteConfig(cfg); err != nil { + return fmt.Errorf("writing config: %w", err) + } + fmt.Printf(" %sβœ“%s Config saved %s~/.heimsense/.env%s\n", green, nc, dim, nc) + + // 6. Configure Claude Code + if err := ConfigureClaudeCode(cfg); err != nil { + fmt.Printf(" %s!%s Claude Code %sskipped (%v)%s\n", "\033[1;33m", nc, dim, err, nc) + } else { + fmt.Printf(" %sβœ“%s Claude Code %s~/.claude/settings.json%s\n", green, nc, dim, nc) + } + + fmt.Println() + fmt.Printf(" %s%sSetup complete!%s\n", bold, green, nc) + fmt.Println() + fmt.Printf(" %s1.%s Server will start on %s%s%s\n", bold, nc, cyan, cfg.ListenAddr, nc) + fmt.Printf(" %s2.%s Open another terminal and run %sclaude%s\n", bold, nc, cyan, nc) + fmt.Printf(" %s3.%s Inside Claude, run %s/model%s and select the custom model\n", bold, nc, cyan, nc) + fmt.Println() + fmt.Printf(" %sEdit config anytime: %s~/.heimsense/.env%s\n", dim, cyan, nc) + fmt.Printf(" %sRe-run setup: %sheimsense setup%s\n", dim, cyan, nc) + fmt.Println() + + return nil +} + +func printHeader() { + fmt.Println() + fmt.Printf(" %s%sHEIMΒ·SENSE%s %ssetup%s\n", bold, cyan, nc, dim, nc) + fmt.Printf(" %sUnlock Your Claude Code for Any LLM%s\n", "\033[3;36m", nc) + fmt.Println() + fmt.Printf(" %sLet's configure your LLM provider.%s\n", dim, nc) + fmt.Println() +} + +func promptProvider(reader *bufio.Reader) (string, error) { + fmt.Printf(" %sSelect your provider:%s\n\n", bold, nc) + for i, p := range providers { + fmt.Printf(" %s%d%s %s %s(%s)%s\n", bold, i+1, nc, p.Name, dim, p.BaseURL, nc) + } + fmt.Printf(" %s%d%s Custom URL\n", bold, len(providers)+1, nc) + fmt.Println() + + for { + fmt.Printf(" %sChoice [1]: %s", bold, nc) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + input = strings.TrimSpace(input) + + if input == "" { + input = "1" + } + + choice, err := strconv.Atoi(input) + if err != nil || choice < 1 || choice > len(providers)+1 { + fmt.Printf(" %sPlease enter a number 1-%d%s\n", "\033[0;31m", len(providers)+1, nc) + continue + } + + if choice <= len(providers) { + return providers[choice-1].BaseURL, nil + } + + // Custom URL + fmt.Printf(" %sBase URL: %s", bold, nc) + customURL, err := reader.ReadString('\n') + if err != nil { + return "", err + } + customURL = strings.TrimSpace(customURL) + if customURL == "" { + fmt.Printf(" %sURL cannot be empty%s\n", "\033[0;31m", nc) + continue + } + return customURL, nil + } +} + +func promptAPIKey() (string, error) { + fmt.Printf(" %sAPI Key: %s", bold, nc) + + // Read password without echo + fd := int(syscall.Stdin) + if term.IsTerminal(fd) { + keyBytes, err := term.ReadPassword(fd) + fmt.Println() // newline after hidden input + if err != nil { + return "", err + } + key := strings.TrimSpace(string(keyBytes)) + if key == "" { + return "", fmt.Errorf("API key cannot be empty") + } + return key, nil + } + + // Fallback for non-terminal (e.g. pipe) + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + key := strings.TrimSpace(input) + if key == "" { + return "", fmt.Errorf("API key cannot be empty") + } + return key, nil +} + +func promptModel(reader *bufio.Reader) (string, error) { + fmt.Printf(" %sModel [gpt-4o-mini]: %s", bold, nc) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + input = strings.TrimSpace(input) + if input == "" { + return "gpt-4o-mini", nil + } + return input, nil +} + +func promptPort(reader *bufio.Reader) (string, error) { + fmt.Printf(" %sPort [8080]: %s", bold, nc) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + input = strings.TrimSpace(input) + if input == "" { + return ":8080", nil + } + // Validate it's a number + port, err := strconv.Atoi(input) + if err != nil || port < 1 || port > 65535 { + return "", fmt.Errorf("invalid port: %s (must be 1-65535)", input) + } + return fmt.Sprintf(":%d", port), nil +} + +// maskKey returns a masked representation of an API key. +func maskKey(key string) string { + if len(key) <= 8 { + return strings.Repeat("β€’", len(key)) + } + return key[:4] + strings.Repeat("β€’", len(key)-8) + key[len(key)-4:] +} + +// WriteConfig writes the setup config to ~/.heimsense/.env. +func WriteConfig(cfg SetupConfig) error { + dir := ConfigDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating config dir: %w", err) + } + + content := fmt.Sprintf(`# Heimsense config β€” generated by setup wizard +# Edit this file to change settings, then restart heimsense. + +ANTHROPIC_BASE_URL=%s +ANTHROPIC_API_KEY=%s +ANTHROPIC_CUSTOM_MODEL_OPTION=%s +ANTHROPIC_CUSTOM_MODEL_OPTION_NAME=%s +ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION=%s + +LISTEN_ADDR=%s +REQUEST_TIMEOUT_MS=120000 +MAX_RETRIES=3 +`, cfg.BaseURL, cfg.APIKey, cfg.Model, cfg.ModelName, cfg.ModelDesc, cfg.ListenAddr) + + return os.WriteFile(ConfigPath(), []byte(content), 0o600) +} + +// ConfigureClaudeCode writes or updates ~/.claude/settings.json +// to point at the local Heimsense adapter. +func ConfigureClaudeCode(cfg SetupConfig) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + claudeDir := filepath.Join(home, ".claude") + settingsPath := filepath.Join(claudeDir, "settings.json") + + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + return err + } + + // Load existing or create new + var data map[string]interface{} + if raw, err := os.ReadFile(settingsPath); err == nil { + if err := json.Unmarshal(raw, &data); err != nil { + data = make(map[string]interface{}) + } + } else { + data = map[string]interface{}{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + } + } + + // Backup existing + if _, err := os.Stat(settingsPath); err == nil { + backupPath := settingsPath + ".bak" + if raw, err := os.ReadFile(settingsPath); err == nil { + os.WriteFile(backupPath, raw, 0o644) + } + } + + // Merge env settings + env, ok := data["env"].(map[string]interface{}) + if !ok { + env = make(map[string]interface{}) + } + env["ANTHROPIC_BASE_URL"] = "http://localhost" + cfg.ListenAddr + env["ANTHROPIC_CUSTOM_MODEL_OPTION"] = cfg.Model + env["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"] = cfg.ModelName + env["ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION"] = cfg.ModelDesc + env["ANTHROPIC_AUTH_TOKEN"] = cfg.APIKey + env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" + data["env"] = env + + out, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + + if err := os.WriteFile(settingsPath, append(out, '\n'), 0o644); err != nil { + return err + } + + // Bypass onboarding in ~/.claude.json + claudeJSON := filepath.Join(home, ".claude.json") + if raw, err := os.ReadFile(claudeJSON); err == nil { + var cj map[string]interface{} + if json.Unmarshal(raw, &cj) == nil { + cj["hasCompletedOnboarding"] = true + if out, err := json.MarshalIndent(cj, "", " "); err == nil { + os.WriteFile(claudeJSON, append(out, '\n'), 0o644) + } + } + } + + return nil +} + +// SyncToClaude reads ~/.heimsense/.env, extracts settings, and updates +// ~/.claude/settings.json to match. This allows users to edit the .env +// file manually and sync changes without re-running the wizard. +func SyncToClaude() error { + envPath := ConfigPath() + if _, err := os.Stat(envPath); os.IsNotExist(err) { + return fmt.Errorf("config not found at %s β€” run 'heimsense setup' first", envPath) + } + + env, err := parseEnvFile(envPath) + if err != nil { + return fmt.Errorf("reading config: %w", err) + } + + listenAddr := env["LISTEN_ADDR"] + if listenAddr == "" { + listenAddr = ":8080" + } + + cfg := SetupConfig{ + BaseURL: env["ANTHROPIC_BASE_URL"], + APIKey: env["ANTHROPIC_API_KEY"], + Model: env["ANTHROPIC_CUSTOM_MODEL_OPTION"], + ModelName: env["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"], + ModelDesc: env["ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION"], + ListenAddr: listenAddr, + } + + if cfg.ModelName == "" { + cfg.ModelName = "Heimsense Custom Model" + } + if cfg.ModelDesc == "" { + cfg.ModelDesc = "Custom model via Heimsense adapter" + } + + if err := ConfigureClaudeCode(cfg); err != nil { + return err + } + + fmt.Printf("\n %s%sHEIMΒ·SENSE%s %ssync%s\n\n", bold, cyan, nc, dim, nc) + fmt.Printf(" %sβœ“%s Synced to %s~/.claude/settings.json%s\n\n", green, nc, dim, nc) + fmt.Printf(" %sβ”Œβ”€ Synced values ────────────────────────────┐%s\n", dim, nc) + fmt.Printf(" %sβ”‚%s Provider %s%s%s\n", dim, nc, cyan, cfg.BaseURL, nc) + fmt.Printf(" %sβ”‚%s API Key %s%s%s\n", dim, nc, dim, maskKey(cfg.APIKey), nc) + fmt.Printf(" %sβ”‚%s Model %s%s%s\n", dim, nc, cyan, cfg.Model, nc) + fmt.Printf(" %sβ”‚%s Listen %s%s%s β†’ %sANTHROPIC_BASE_URL=http://localhost%s%s\n", dim, nc, cyan, cfg.ListenAddr, nc, dim, cfg.ListenAddr, nc) + fmt.Printf(" %sβ””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜%s\n\n", dim, nc) + + return nil +} + +// parseEnvFile reads a .env file and returns a map of key-value pairs. +func parseEnvFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + result := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + result[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return result, scanner.Err() +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..83f3b33 --- /dev/null +++ b/internal/setup/setup_test.go @@ -0,0 +1,220 @@ +package setup + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestNeedsSetup(t *testing.T) { + // Point HOME to a temp dir without .heimsense + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + if !NeedsSetup() { + t.Error("NeedsSetup() = false, want true when no config exists") + } + + // Create the config file + cfgDir := filepath.Join(tmpDir, ".heimsense") + os.MkdirAll(cfgDir, 0o755) + os.WriteFile(filepath.Join(cfgDir, ".env"), []byte("KEY=value\n"), 0o644) + + if NeedsSetup() { + t.Error("NeedsSetup() = true, want false when config exists") + } +} + +func TestWriteConfig(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + cfg := SetupConfig{ + BaseURL: "https://api.test.com/v1", + APIKey: "sk-test-key-123", + Model: "gpt-test", + ModelName: "Test Model", + ModelDesc: "A test model", + ListenAddr: ":9090", + } + + if err := WriteConfig(cfg); err != nil { + t.Fatalf("WriteConfig() error: %v", err) + } + + envPath := filepath.Join(tmpDir, ".heimsense", ".env") + content, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + + s := string(content) + checks := map[string]string{ + "ANTHROPIC_BASE_URL": "https://api.test.com/v1", + "ANTHROPIC_API_KEY": "sk-test-key-123", + "ANTHROPIC_CUSTOM_MODEL_OPTION=": "gpt-test", + "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME=": "Test Model", + "LISTEN_ADDR": ":9090", + } + for key, want := range checks { + if !contains(s, key) || !contains(s, want) { + t.Errorf("config missing %s=%s", key, want) + } + } + + // Verify file permissions (0600 β€” owner only) + info, _ := os.Stat(envPath) + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("config file perm = %o, want 0600", perm) + } +} + +func TestConfigureClaudeCode_NewFile(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + cfg := SetupConfig{ + BaseURL: "https://api.openai.com/v1", + APIKey: "sk-abc123", + Model: "gpt-5", + ModelName: "My Model", + ModelDesc: "My description", + ListenAddr: ":8080", + } + + if err := ConfigureClaudeCode(cfg); err != nil { + t.Fatalf("ConfigureClaudeCode() error: %v", err) + } + + settingsPath := filepath.Join(tmpDir, ".claude", "settings.json") + raw, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("reading settings.json: %v", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(raw, &data); err != nil { + t.Fatalf("parsing settings.json: %v", err) + } + + env, ok := data["env"].(map[string]interface{}) + if !ok { + t.Fatal("settings.json missing 'env' key") + } + + if env["ANTHROPIC_BASE_URL"] != "http://localhost:8080" { + t.Errorf("ANTHROPIC_BASE_URL = %q, want http://localhost:8080", env["ANTHROPIC_BASE_URL"]) + } + if env["ANTHROPIC_CUSTOM_MODEL_OPTION"] != "gpt-5" { + t.Errorf("ANTHROPIC_CUSTOM_MODEL_OPTION = %q, want gpt-5", env["ANTHROPIC_CUSTOM_MODEL_OPTION"]) + } + if env["ANTHROPIC_AUTH_TOKEN"] != "sk-abc123" { + t.Errorf("ANTHROPIC_AUTH_TOKEN = %q, want sk-abc123", env["ANTHROPIC_AUTH_TOKEN"]) + } +} + +func TestConfigureClaudeCode_MergeExisting(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + // Create existing settings with custom keys + claudeDir := filepath.Join(tmpDir, ".claude") + os.MkdirAll(claudeDir, 0o755) + existing := map[string]interface{}{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "customField": "should-persist", + "env": map[string]interface{}{ + "EXISTING_VAR": "keep-this", + }, + } + raw, _ := json.MarshalIndent(existing, "", " ") + os.WriteFile(filepath.Join(claudeDir, "settings.json"), raw, 0o644) + + cfg := SetupConfig{ + BaseURL: "https://api.deepseek.com/v1", + APIKey: "sk-deep", + Model: "deepseek-v3", + ModelName: "DeepSeek", + ModelDesc: "DeepSeek model", + ListenAddr: ":3000", + } + + if err := ConfigureClaudeCode(cfg); err != nil { + t.Fatalf("ConfigureClaudeCode() error: %v", err) + } + + updated, _ := os.ReadFile(filepath.Join(claudeDir, "settings.json")) + var data map[string]interface{} + json.Unmarshal(updated, &data) + + // Custom field should persist + if data["customField"] != "should-persist" { + t.Error("existing customField was lost during merge") + } + + env := data["env"].(map[string]interface{}) + + // Existing env var should persist + if env["EXISTING_VAR"] != "keep-this" { + t.Error("existing EXISTING_VAR was lost during merge") + } + + // New values should be set + if env["ANTHROPIC_CUSTOM_MODEL_OPTION"] != "deepseek-v3" { + t.Errorf("model = %q, want deepseek-v3", env["ANTHROPIC_CUSTOM_MODEL_OPTION"]) + } + + // Custom port should flow to ANTHROPIC_BASE_URL + if env["ANTHROPIC_BASE_URL"] != "http://localhost:3000" { + t.Errorf("ANTHROPIC_BASE_URL = %q, want http://localhost:3000", env["ANTHROPIC_BASE_URL"]) + } + + // Backup should exist + if _, err := os.Stat(filepath.Join(claudeDir, "settings.json.bak")); os.IsNotExist(err) { + t.Error("backup file was not created") + } +} + +func TestMaskKey(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"sk-abc123def456", "sk-aβ€’β€’β€’β€’β€’β€’β€’f456"}, // 15 chars: 4 + 7 dots + 4 + {"short", "β€’β€’β€’β€’β€’"}, // 5 chars: all dots + {"12345678", "β€’β€’β€’β€’β€’β€’β€’β€’"}, // 8 chars: all dots (<=8) + {"abcdefghij", "abcdβ€’β€’ghij"}, // 10 chars: 4 + 2 dots + 4 + } + for _, tt := range tests { + got := maskKey(tt.input) + if got != tt.want { + t.Errorf("maskKey(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && containsStr(s, substr) +} + +func containsStr(s, substr string) bool { + return len(s) >= len(substr) && searchStr(s, substr) +} + +func searchStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/scripts/install.sh b/scripts/install.sh index cb4014e..fe44488 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -11,6 +11,7 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' +DIM='\033[2m' NC='\033[0m' info() { printf "${CYAN}β„Ή${NC} %s\n" "$1"; } @@ -89,107 +90,9 @@ ensure_path() { export PATH="$PATH:${INSTALL_DIR}" } -# --- Configure Claude Code --- -configure_claude() { - # Prompt for API key - printf "\n${BOLD}${CYAN}Claude Code Setup${NC}\n" - printf "%s" "Enter your LLM provider API key: " - read -r API_KEY - [ -z "$API_KEY" ] && { warn "No API key provided, skipping Claude Code setup"; return 1; } - - printf "%s" "Enter provider base URL [https://api.openai.com/v1]: " - read -r BASE_URL - BASE_URL="${BASE_URL:-https://api.openai.com/v1}" - - printf "%s" "Enter model name [gpt-5.1]: " - read -r MODEL - MODEL="${MODEL:-gpt-5.1}" - - # Write ~/.heimsense/.env so heimsense can start without exports - local config_dir="$HOME/.heimsense" - mkdir -p "$config_dir" - cat > "${config_dir}/.env" < "$settings" < Date: Wed, 1 Apr 2026 00:37:01 +0700 Subject: [PATCH 2/2] chore: upgrade Go version to 1.25, update gitignore rules, and add dummy test for coverage reporting --- .github/workflows/ci.yml | 2 +- .gitignore | 5 ++--- cmd/server/main_test.go | 8 ++++++++ 3 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 cmd/server/main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d195499..8a3cd28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.25" - name: Verify dependencies run: go mod verify diff --git a/.gitignore b/.gitignore index 866edb5..d9a7dc5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ bin/ # Output of the go coverage tool *.out +*.rdb coverage.txt coverage.html @@ -43,6 +44,4 @@ logs/ # Temporary files tmp/ -temp/ -server -*.rdb \ No newline at end of file +temp/ \ No newline at end of file diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..9397cb1 --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,8 @@ +package main + +import "testing" + +func TestDummy(t *testing.T) { + // Dummy test to ensure the package is included in coverage reports + // without failing 'go test' when -coverprofile is used. +}