Skip to content
Closed
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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,42 @@ 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",
"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.

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 +213,8 @@ 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 |

## Programmatic API

Expand Down Expand Up @@ -255,12 +293,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
31 changes: 26 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,21 @@ 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)
}

return overrides
}

export async function runCli(
start = startProxyServer,
Expand All @@ -37,7 +51,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 +64,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/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
95 changes: 95 additions & 0 deletions src/__tests__/configLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it } from "bun:test"
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { loadProxyConfigFile } from "../proxy/configLoader"

describe("configLoader", () => {
const envKeys = ["CLAUDE_PROXY_CONFIG", "MERIDIAN_SHARED_KEY", "MERIDIAN_API_KEY"] as const
const originalEnv = new Map<string, string | undefined>(envKeys.map((key) => [key, process.env[key]]))

afterEach(() => {
for (const [key, value] of originalEnv.entries()) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
})

it("loads the default config file from the current working directory", () => {
const cwd = mkdtempSync(join(tmpdir(), "meridian-config-cwd-"))

try {
writeFileSync(join(cwd, "meridian.config.json"), JSON.stringify({
defaultProfile: "company",
requiredApiKeys: ["alpha", "beta"],
}))

const config = loadProxyConfigFile({ cwd, homeDir: cwd })
expect(config.defaultProfile).toBe("company")
expect(config.requiredApiKeys).toEqual(["alpha", "beta"])
} finally {
rmSync(cwd, { recursive: true, force: true })
}
})

it("merges home config first and lets cwd config override it", () => {
const cwd = mkdtempSync(join(tmpdir(), "meridian-config-merge-cwd-"))
const homeDir = mkdtempSync(join(tmpdir(), "meridian-config-merge-home-"))

try {
mkdirSync(join(homeDir, ".config", "meridian"), { recursive: true })
writeFileSync(join(homeDir, ".config", "meridian", "config.json"), JSON.stringify({
defaultProfile: "personal",
requiredApiKeys: ["alpha"],
}))
writeFileSync(join(cwd, "meridian.config.json"), JSON.stringify({
defaultProfile: "company",
}))

const config = loadProxyConfigFile({ cwd, homeDir })
expect(config.defaultProfile).toBe("company")
expect(config.requiredApiKeys).toEqual(["alpha"])
} finally {
rmSync(cwd, { recursive: true, force: true })
rmSync(homeDir, { recursive: true, force: true })
}
})

it("resolves env references and home paths in config values", () => {
const cwd = mkdtempSync(join(tmpdir(), "meridian-config-env-cwd-"))
const homeDir = mkdtempSync(join(tmpdir(), "meridian-config-env-home-"))

try {
process.env.MERIDIAN_SHARED_KEY = "shared-secret"
process.env.MERIDIAN_API_KEY = "profile-secret"

writeFileSync(join(cwd, "meridian.config.json"), JSON.stringify({
requiredApiKeys: ["env:MERIDIAN_SHARED_KEY"],
profiles: [{
id: "company",
type: "api",
apiKey: "$env:MERIDIAN_API_KEY",
claudeConfigDir: "~/.claude-company",
}],
}))

const config = loadProxyConfigFile({ cwd, homeDir })
expect(config.requiredApiKeys).toEqual(["shared-secret"])
expect(config.profiles?.[0]?.apiKey).toBe("profile-secret")
expect(config.profiles?.[0]?.claudeConfigDir).toBe(join(homeDir, ".claude-company"))
} finally {
rmSync(cwd, { recursive: true, force: true })
rmSync(homeDir, { recursive: true, force: true })
}
})

it("throws when an explicit config path is missing", () => {
const cwd = mkdtempSync(join(tmpdir(), "meridian-config-missing-cwd-"))

try {
expect(() => loadProxyConfigFile({ cwd, homeDir: cwd, configPath: "missing.json" })).toThrow("Config file not found")
} finally {
rmSync(cwd, { recursive: true, force: true })
}
})
})
38 changes: 38 additions & 0 deletions src/__tests__/proxy-async-ops.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it } from "bun:test"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"

const { createProxyServer, startProxyServer } = await import("../proxy/server")
const { resetCachedClaudeAuthStatus } = await import("../proxy/models")
Expand Down Expand Up @@ -92,4 +95,39 @@ describe("proxy async ops", () => {
expect(startCalled).toBe(1)
expect(errors.some((line) => line.includes("Could not verify Claude auth status"))).toBe(true)
})

it("loads config from a JSON file before starting the CLI proxy", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "meridian-cli-config-"))
const originalConfigPath = process.env.CLAUDE_PROXY_CONFIG
const configPath = join(tmpDir, "meridian.config.json")
writeFileSync(configPath, JSON.stringify({
port: 8123,
host: "0.0.0.0",
defaultProfile: "company",
requiredApiKeys: ["alpha", "beta"],
}))

let capturedConfig: any
try {
process.env.CLAUDE_PROXY_CONFIG = configPath

await runCli(
async (config) => {
capturedConfig = config
const { EventEmitter } = await import("events")
return { server: new EventEmitter(), config: {}, close: async () => {} } as any
},
((async () => ({ stdout: JSON.stringify({ loggedIn: true, subscriptionType: "max" }) })) as any),
)
} finally {
if (originalConfigPath === undefined) delete process.env.CLAUDE_PROXY_CONFIG
else process.env.CLAUDE_PROXY_CONFIG = originalConfigPath
rmSync(tmpDir, { recursive: true, force: true })
}

expect(capturedConfig.port).toBe(8123)
expect(capturedConfig.host).toBe("0.0.0.0")
expect(capturedConfig.defaultProfile).toBe("company")
expect(capturedConfig.requiredApiKeys).toEqual(["alpha", "beta"])
})
})
Loading