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 89763af..d9a7dc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ bin/
# Output of the go coverage tool
*.out
+*.rdb
coverage.txt
coverage.html
diff --git a/README.md b/README.md
index 1a50806..623028f 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,10 @@
-
-
-# Heimsense
+# Heimsense π±
-
+
-
+
@@ -18,781 +16,164 @@
-> *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/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.
+}
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" <