Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ FROM node:22-alpine

RUN deluser --remove-home node 2>/dev/null; \
adduser -D -u 1000 claude \
&& mkdir -p /home/claude/.claude \
&& mkdir -p /home/claude/.claude /home/claude/.config/meridian \
&& chown -R claude:claude /home/claude

RUN npm install -g @anthropic-ai/claude-code \
Expand Down
101 changes: 101 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,71 @@ See [`adapters/opencode.ts`](src/proxy/adapters/opencode.ts) for reference.

## Configuration

Meridian can load a JSON config file from either:

- `~/.config/meridian/config.json`
- `./meridian.config.json`

Set `CLAUDE_PROXY_CONFIG=/path/to/config.json` to use an explicit file.

Example:

```json
{
"port": 4567,
"defaultProfile": "personal",
"protectAdminRoutes": true,
"requiredApiKeys": ["env:MERIDIAN_LAPTOP_KEY", "env:MERIDIAN_DESKTOP_KEY"],
"profiles": [
{ "id": "personal", "claudeConfigDir": "~/.claude" },
{ "id": "company", "claudeConfigDir": "~/.claude-company" }
]
}
```

Plaintext keys also work if you want a fully self-contained local config:

```json
{
"requiredApiKeys": ["laptop-secret", "desktop-secret"]
}
```

That is supported, but safer practice is to keep secret values in env vars and reference them from JSON.

To lock down telemetry and health endpoints separately from message traffic, you can turn on admin-route protection and optionally use a different key set:

```json
{
"protectAdminRoutes": true,
"requiredApiKeys": ["env:MERIDIAN_CLIENT_KEY"],
"adminApiKeys": ["env:MERIDIAN_ADMIN_KEY"]
}
```

For browser-friendly access, you can also enable Basic Auth on protected admin routes:

```json
{
"protectAdminRoutes": true,
"adminUsername": "admin",
"adminPassword": "env:MERIDIAN_ADMIN_PASSWORD"
}
```

When `protectAdminRoutes` is enabled:

- `/health` requires an admin key
- `/telemetry` and `/telemetry/*` require an admin key
- Basic Auth also works when `adminUsername` and `adminPassword` are configured
- `/` remains public
- if `adminApiKeys` is omitted, Meridian falls back to `requiredApiKeys`

String values support:

- `~/...` home expansion
- `env:NAME` or `$env:NAME` environment variable expansion

| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Port to listen on |
Expand All @@ -177,6 +242,12 @@ See [`adapters/opencode.ts`](src/proxy/adapters/opencode.ts) for reference.
| `CLAUDE_PROXY_WORKDIR` | `cwd()` | Default working directory for SDK |
| `CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS` | `120` | HTTP keep-alive timeout |
| `CLAUDE_PROXY_TELEMETRY_SIZE` | `1000` | Telemetry ring buffer size |
| `CLAUDE_PROXY_CONFIG` | unset | Explicit path to a JSON config file |
| `CLAUDE_PROXY_API_KEYS` | unset | Comma-separated allowed inbound API keys |
| `CLAUDE_PROXY_ADMIN_API_KEYS` | unset | Comma-separated admin keys for `/health` and `/telemetry/*` |
| `CLAUDE_PROXY_PROTECT_ADMIN_ROUTES` | `0` | Require API keys on `/health` and `/telemetry/*` |
| `CLAUDE_PROXY_ADMIN_USERNAME` | unset | Optional Basic Auth username for protected admin routes |
| `CLAUDE_PROXY_ADMIN_PASSWORD` | unset | Optional Basic Auth password for protected admin routes |

## Programmatic API

Expand Down Expand Up @@ -255,12 +326,42 @@ See [`examples/opencode-plugin/`](examples/opencode-plugin/) for a reference imp
docker run -v ~/.claude:/home/claude/.claude -p 3456:3456 meridian
```

To use config-file-driven profiles and API keys in Docker, mount your config to the default path inside the container:

```bash
docker run \
-v ~/.claude:/home/claude/.claude \
-v ~/.config/meridian/config.json:/home/claude/.config/meridian/config.json:ro \
-e MERIDIAN_LAPTOP_KEY="$MERIDIAN_LAPTOP_KEY" \
-e MERIDIAN_DESKTOP_KEY="$MERIDIAN_DESKTOP_KEY" \
-p 3456:3456 \
meridian
```

If you prefer plaintext keys in the mounted JSON, you can omit the extra env vars and keep the file fully self-contained.

Or with docker-compose:

```bash
docker compose up -d
```

Example `docker-compose.yml` service override:

```yaml
services:
proxy:
environment:
CLAUDE_PROXY_CONFIG: /home/claude/.config/meridian/config.json
MERIDIAN_LAPTOP_KEY: ${MERIDIAN_LAPTOP_KEY}
MERIDIAN_DESKTOP_KEY: ${MERIDIAN_DESKTOP_KEY}
volumes:
- claude-auth:/home/claude/.claude
- ./meridian.config.json:/home/claude/.config/meridian/config.json:ro
```

The container now creates `/home/claude/.config/meridian` automatically, so the default config-file path works without extra setup.

## Testing

```bash
Expand Down
39 changes: 34 additions & 5 deletions bin/cli.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/usr/bin/env node

import { startProxyServer } from "../src/proxy/server"
import { loadProxyConfigFile } from "../src/proxy/configLoader"
import { exec as execCallback } from "child_process"
import { promisify } from "util"
import type { ProxyConfig } from "../src/proxy/types"

const exec = promisify(execCallback)

Expand All @@ -14,9 +16,29 @@ process.on("unhandledRejection", (reason) => {
console.error(`[PROXY] Unhandled rejection (recovered): ${reason instanceof Error ? reason.message : reason}`)
})

const port = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10)
const host = process.env.CLAUDE_PROXY_HOST || "127.0.0.1"
const idleTimeoutSeconds = parseInt(process.env.CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS || "120", 10)
function getEnvConfigOverrides(env: NodeJS.ProcessEnv = process.env): Partial<ProxyConfig> {
const overrides: Partial<ProxyConfig> = {}

if (env.CLAUDE_PROXY_PORT) overrides.port = parseInt(env.CLAUDE_PROXY_PORT, 10)
if (env.CLAUDE_PROXY_HOST) overrides.host = env.CLAUDE_PROXY_HOST
if (env.CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS) {
overrides.idleTimeoutSeconds = parseInt(env.CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS, 10)
}
if (env.CLAUDE_PROXY_DEBUG) overrides.debug = env.CLAUDE_PROXY_DEBUG === "1"
if (env.CLAUDE_PROXY_API_KEYS) {
overrides.requiredApiKeys = env.CLAUDE_PROXY_API_KEYS.split(",").map((key) => key.trim()).filter(Boolean)
}
if (env.CLAUDE_PROXY_ADMIN_API_KEYS) {
overrides.adminApiKeys = env.CLAUDE_PROXY_ADMIN_API_KEYS.split(",").map((key) => key.trim()).filter(Boolean)
}
if (env.CLAUDE_PROXY_PROTECT_ADMIN_ROUTES) {
overrides.protectAdminRoutes = env.CLAUDE_PROXY_PROTECT_ADMIN_ROUTES === "1"
}
if (env.CLAUDE_PROXY_ADMIN_USERNAME) overrides.adminUsername = env.CLAUDE_PROXY_ADMIN_USERNAME
if (env.CLAUDE_PROXY_ADMIN_PASSWORD) overrides.adminPassword = env.CLAUDE_PROXY_ADMIN_PASSWORD

return overrides
}

export async function runCli(
start = startProxyServer,
Expand All @@ -37,7 +59,9 @@ export async function runCli(
console.error("\x1b[33m⚠ Could not verify Claude auth status. If requests fail, run: claude login\x1b[0m")
}

const proxy = await start({ port, host, idleTimeoutSeconds })
const fileConfig = loadProxyConfigFile()
const envOverrides = getEnvConfigOverrides()
const proxy = await start({ ...fileConfig, ...envOverrides })

// Handle EADDRINUSE — preserve CLI behavior of exiting on port conflict
proxy.server.on("error", (error: NodeJS.ErrnoException) => {
Expand All @@ -48,5 +72,10 @@ export async function runCli(
}

if (import.meta.main) {
await runCli()
try {
await runCli()
} catch (error) {
console.error(`[PROXY] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
}
}
3 changes: 3 additions & 0 deletions bin/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
CLAUDE_DIR="/home/claude/.claude"
CLAUDE_JSON="/home/claude/.claude.json"
CLAUDE_JSON_VOL="$CLAUDE_DIR/.claude.json"
MERIDIAN_CONFIG_DIR="/home/claude/.config/meridian"

mkdir -p "$MERIDIAN_CONFIG_DIR"

# Fix ownership if volume was created as root
if [ -d "$CLAUDE_DIR" ] && [ ! -w "$CLAUDE_DIR" ]; then
Expand Down
19 changes: 19 additions & 0 deletions examples/claude-code-profile.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail

PROFILE="${1:-personal}"
BASE_URL="${ANTHROPIC_BASE_URL:-http://127.0.0.1:4567}"

case "$PROFILE" in
personal|company)
;;
*)
printf 'Usage: %s [personal|company]\n' "$0" >&2
exit 1
;;
esac

ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-x}" \
ANTHROPIC_BASE_URL="$BASE_URL" \
ANTHROPIC_CUSTOM_HEADERS="x-meridian-profile: $PROFILE" \
claude "${@:2}"
19 changes: 19 additions & 0 deletions examples/meridian.config.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"port": 4567,
"host": "127.0.0.1",
"defaultProfile": "personal",
"requiredApiKeys": [
"env:MERIDIAN_LAPTOP_KEY",
"env:MERIDIAN_DESKTOP_KEY"
],
"profiles": [
{
"id": "personal",
"claudeConfigDir": "~/.claude"
},
{
"id": "company",
"claudeConfigDir": "~/.claude-company"
}
]
}
33 changes: 33 additions & 0 deletions examples/profile-routing-local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { startProxyServer } from "../src/proxy/server"

const port = Number.parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10)
const host = process.env.CLAUDE_PROXY_HOST || "127.0.0.1"

const personalDir = process.env.MERIDIAN_PERSONAL_CLAUDE_DIR
const companyDir = process.env.MERIDIAN_COMPANY_CLAUDE_DIR

if (!personalDir || !companyDir) {
throw new Error("Set MERIDIAN_PERSONAL_CLAUDE_DIR and MERIDIAN_COMPANY_CLAUDE_DIR before running this example")
}

const proxy = await startProxyServer({
port,
host,
profiles: [
{ id: "personal", claudeConfigDir: personalDir },
{ id: "company", claudeConfigDir: companyDir },
],
defaultProfile: "personal",
})

const stop = async () => {
await proxy.close()
process.exit(0)
}

process.on("SIGINT", () => { void stop() })
process.on("SIGTERM", () => { void stop() })

console.log(`Profile test proxy running at http://${host}:${port}`)
console.log("Profiles: personal, company")
console.log("Use x-meridian-profile to select a profile per request")
9 changes: 9 additions & 0 deletions src/__tests__/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ describe("openCodeAdapter", () => {
expect(openCodeAdapter.getSessionId(mockContext as any)).toBeUndefined()
})

it("extracts profile ID from x-meridian-profile header", () => {
const mockContext = {
req: {
header: (name: string) => name === "x-meridian-profile" ? "company" : undefined
}
}
expect(openCodeAdapter.getProfileId(mockContext as any)).toBe("company")
})

it("extracts working directory from system prompt env block", () => {
const body = {
system: "<env>\n Working directory: /Users/test/project\n</env>"
Expand Down
Loading