CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI
- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens, management API /api/* (providers CRUD, apply/restore, settings, backups, logs) - Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming state machine keyed by content block index (text, tool_use, parallel calls) - ~/.claude/settings.json guarded read/write: timestamped backups, managed env keys only, one-click restore - Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout, stdin pipe watchdog prevents orphans, kills the child on exit - React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query - Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
1 parent
8ee5e98ea3
commit
f8acf31ebc
71 files changed
+15727
-1
No files matched your search
@@ -0,0 +1,303 @@
|
||||
// Package claudecfg reads and writes Claude Code's settings.json.
|
||||
// CrossRelay only touches the env keys it manages; every other key
|
||||
// (theme, permissions, unrelated env vars) round-trips untouched.
|
||||
package claudecfg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
||||
)
|
||||
|
||||
// ManagedEnvKeys lists the settings.json env keys CrossRelay owns.
|
||||
var ManagedEnvKeys = []string{
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_MODEL_NAME",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||||
}
|
||||
|
||||
// DefaultPath returns the location of Claude Code's settings.json.
|
||||
// CROSSRELAY_CLAUDE_SETTINGS overrides it (used by tests).
|
||||
func DefaultPath() (string, error) {
|
||||
if v := strings.TrimSpace(os.Getenv("CROSSRELAY_CLAUDE_SETTINGS")); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR"))
|
||||
if dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate home directory: %w", err)
|
||||
}
|
||||
dir = filepath.Join(home, ".claude")
|
||||
}
|
||||
return filepath.Join(dir, "settings.json"), nil
|
||||
}
|
||||
|
||||
// Manager performs guarded edits on one settings.json file.
|
||||
type Manager struct {
|
||||
path string
|
||||
backupDir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New builds a manager for settingsPath, storing backups in backupDir.
|
||||
func New(settingsPath, backupDir string) *Manager {
|
||||
return &Manager{path: settingsPath, backupDir: backupDir}
|
||||
}
|
||||
|
||||
// ApplyOptions describes what gets injected into settings.json.
|
||||
type ApplyOptions struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
Models config.Models
|
||||
}
|
||||
|
||||
// Settings is a parsed settings.json. Raw keeps every top-level key.
|
||||
type Settings struct {
|
||||
Raw map[string]json.RawMessage `json:"-"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses settings.json while keeping unknown keys.
|
||||
func (s *Settings) UnmarshalJSON(data []byte) error {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Raw = raw
|
||||
s.Env = nil
|
||||
if envRaw, ok := raw["env"]; ok {
|
||||
_ = json.Unmarshal(envRaw, &s.Env)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes every original key back, env included.
|
||||
func (s Settings) MarshalJSON() ([]byte, error) {
|
||||
out := make(map[string]json.RawMessage, len(s.Raw)+1)
|
||||
for k, v := range s.Raw {
|
||||
out[k] = v
|
||||
}
|
||||
env, err := json.Marshal(s.Env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["env"] = env
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// Apply injects the relay configuration, backing up the current file
|
||||
// first. It returns the resulting settings.
|
||||
func (m *Manager) Apply(opts ApplyOptions) (*Settings, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
settings, err := m.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := m.backupLocked(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if settings.Env == nil {
|
||||
settings.Env = map[string]string{}
|
||||
}
|
||||
settings.Env["ANTHROPIC_BASE_URL"] = opts.BaseURL
|
||||
settings.Env["ANTHROPIC_AUTH_TOKEN"] = opts.Token
|
||||
applyModelEnv(settings.Env, opts.Models)
|
||||
if err := m.saveLocked(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// Restore copies the newest backup over settings.json and returns the
|
||||
// backup file name.
|
||||
func (m *Manager) Restore() (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
backups, err := m.listBackupsLocked()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(backups) == 0 {
|
||||
return "", fmt.Errorf("no backup found in %s", m.backupDir)
|
||||
}
|
||||
newest := backups[0]
|
||||
data, err := os.ReadFile(filepath.Join(m.backupDir, newest.Name))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := m.writeFile(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newest.Name, nil
|
||||
}
|
||||
|
||||
// BackupInfo describes one backup file.
|
||||
type BackupInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
// Backups lists backup files, newest first.
|
||||
func (m *Manager) Backups() ([]BackupInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.listBackupsLocked()
|
||||
}
|
||||
|
||||
// CurrentEnv returns the env block of the live settings.json.
|
||||
func (m *Manager) CurrentEnv() (map[string]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
settings, err := m.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settings.Env, nil
|
||||
}
|
||||
|
||||
func (m *Manager) load() (*Settings, error) {
|
||||
data, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Settings{Raw: map[string]json.RawMessage{}, Env: map[string]string{}}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return &Settings{Raw: map[string]json.RawMessage{}, Env: map[string]string{}}, nil
|
||||
}
|
||||
var settings Settings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", m.path, err)
|
||||
}
|
||||
if settings.Raw == nil {
|
||||
settings.Raw = map[string]json.RawMessage{}
|
||||
}
|
||||
if settings.Env == nil {
|
||||
settings.Env = map[string]string{}
|
||||
}
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
func (m *Manager) saveLocked(settings *Settings) error {
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return m.writeFile(data)
|
||||
}
|
||||
|
||||
func (m *Manager) writeFile(data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(m.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := m.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, m.path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) backupLocked(settings *Settings) (string, error) {
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.MkdirAll(m.backupDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fmt.Sprintf("settings-%s.json", time.Now().Format("20060102-150405"))
|
||||
path := filepath.Join(m.backupDir, name)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
m.pruneBackupsLocked(20)
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (m *Manager) listBackupsLocked() ([]BackupInfo, error) {
|
||||
entries, err := os.ReadDir(m.backupDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []BackupInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := []BackupInfo{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, BackupInfo{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) pruneBackupsLocked(keep int) {
|
||||
backups, err := m.listBackupsLocked()
|
||||
if err != nil || len(backups) <= keep {
|
||||
return
|
||||
}
|
||||
for _, b := range backups[keep:] {
|
||||
_ = os.Remove(filepath.Join(m.backupDir, b.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// applyModelEnv writes the model env vars for the roles the provider
|
||||
// defines and removes the ones it does not.
|
||||
func applyModelEnv(env map[string]string, models config.Models) {
|
||||
main := firstNonEmpty(models.Sonnet, models.Default)
|
||||
setOrDelete(env, "ANTHROPIC_MODEL", main)
|
||||
setOrDelete(env, "ANTHROPIC_MODEL_NAME", main)
|
||||
setOrDelete(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", firstNonEmpty(models.Sonnet, models.Default))
|
||||
setOrDelete(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", firstNonEmpty(models.Sonnet, models.Default))
|
||||
setOrDelete(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", firstNonEmpty(models.Haiku, models.Default))
|
||||
setOrDelete(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", firstNonEmpty(models.Haiku, models.Default))
|
||||
setOrDelete(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", firstNonEmpty(models.Opus, models.Default))
|
||||
setOrDelete(env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", firstNonEmpty(models.Opus, models.Default))
|
||||
}
|
||||
|
||||
func setOrDelete(env map[string]string, key, value string) {
|
||||
if value == "" {
|
||||
delete(env, key)
|
||||
return
|
||||
}
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package claudecfg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
||||
)
|
||||
|
||||
const originalSettings = `{
|
||||
"theme": "dark",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-official",
|
||||
"ANTHROPIC_BASE_URL": "https://official.example.com",
|
||||
"DISABLE_AUTOUPDATER": "1"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func newManager(t *testing.T) (*Manager, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
settingsPath := filepath.Join(dir, "settings.json")
|
||||
if err := os.WriteFile(settingsPath, []byte(originalSettings), 0o600); err != nil {
|
||||
t.Fatalf("write settings: %v", err)
|
||||
}
|
||||
return New(settingsPath, filepath.Join(dir, "backups")), settingsPath
|
||||
}
|
||||
|
||||
func readSettings(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("parse settings: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestApplyPreservesUnknownKeys(t *testing.T) {
|
||||
mgr, path := newManager(t)
|
||||
if _, err := mgr.Apply(ApplyOptions{
|
||||
BaseURL: "http://127.0.0.1:8787",
|
||||
Token: "crl_test",
|
||||
Models: config.Models{Sonnet: "ds-chat", Haiku: "ds-haiku", Opus: "ds-opus", Default: "ds-chat"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
settings := readSettings(t, path)
|
||||
if settings["theme"] != "dark" {
|
||||
t.Errorf("theme lost: %v", settings["theme"])
|
||||
}
|
||||
env := settings["env"].(map[string]any)
|
||||
if env["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:8787" {
|
||||
t.Errorf("base url = %v", env["ANTHROPIC_BASE_URL"])
|
||||
}
|
||||
if env["ANTHROPIC_AUTH_TOKEN"] != "crl_test" {
|
||||
t.Errorf("relay token = %v", env["ANTHROPIC_AUTH_TOKEN"])
|
||||
}
|
||||
if env["DISABLE_AUTOUPDATER"] != "1" {
|
||||
t.Errorf("unrelated env key lost: %v", env)
|
||||
}
|
||||
if env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] != "ds-haiku" {
|
||||
t.Errorf("haiku model = %v", env["ANTHROPIC_DEFAULT_HAIKU_MODEL"])
|
||||
}
|
||||
if env["ANTHROPIC_MODEL"] != "ds-chat" {
|
||||
t.Errorf("main model = %v", env["ANTHROPIC_MODEL"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCreatesBackupWithOriginalContent(t *testing.T) {
|
||||
mgr, _ := newManager(t)
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "crl_x", Models: config.Models{Default: "m"}}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
backups, err := mgr.Backups()
|
||||
if err != nil {
|
||||
t.Fatalf("Backups: %v", err)
|
||||
}
|
||||
if len(backups) != 1 {
|
||||
t.Fatalf("backups = %d, want 1", len(backups))
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(mgr.backupDir, backups[0].Name))
|
||||
if err != nil {
|
||||
t.Fatalf("read backup: %v", err)
|
||||
}
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
t.Fatalf("parse backup: %v", err)
|
||||
}
|
||||
env := settings["env"].(map[string]any)
|
||||
if env["ANTHROPIC_BASE_URL"] != "https://official.example.com" {
|
||||
t.Errorf("backup holds post-apply content: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIsIdempotent(t *testing.T) {
|
||||
mgr, path := newManager(t)
|
||||
opts := ApplyOptions{BaseURL: "http://127.0.0.1:8787", Token: "crl_x", Models: config.Models{Default: "ds-chat"}}
|
||||
if _, err := mgr.Apply(opts); err != nil {
|
||||
t.Fatalf("first Apply: %v", err)
|
||||
}
|
||||
afterFirst := readSettings(t, path)
|
||||
if _, err := mgr.Apply(opts); err != nil {
|
||||
t.Fatalf("second Apply: %v", err)
|
||||
}
|
||||
afterSecond := readSettings(t, path)
|
||||
firstEnv, _ := json.Marshal(afterFirst["env"])
|
||||
secondEnv, _ := json.Marshal(afterSecond["env"])
|
||||
if string(firstEnv) != string(secondEnv) {
|
||||
t.Errorf("env changed on re-apply:\n%s\n%s", firstEnv, secondEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestore(t *testing.T) {
|
||||
mgr, path := newManager(t)
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:8787", Token: "crl_x", Models: config.Models{Default: "m"}}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
name, err := mgr.Restore()
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
if name == "" {
|
||||
t.Error("Restore returned no backup name")
|
||||
}
|
||||
env := readSettings(t, path)["env"].(map[string]any)
|
||||
if env["ANTHROPIC_BASE_URL"] != "https://official.example.com" {
|
||||
t.Errorf("base url after restore = %v", env["ANTHROPIC_BASE_URL"])
|
||||
}
|
||||
if env["ANTHROPIC_AUTH_TOKEN"] != "sk-official" {
|
||||
t.Errorf("token after restore = %v", env["ANTHROPIC_AUTH_TOKEN"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreWithoutBackupFails(t *testing.T) {
|
||||
mgr, _ := newManager(t)
|
||||
if _, err := mgr.Restore(); err == nil {
|
||||
t.Fatal("Restore succeeded without backups")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyModelRoles(t *testing.T) {
|
||||
mgr, path := newManager(t)
|
||||
// Only a default: every role falls back to it.
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Default: "only-default"}}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
env := readSettings(t, path)["env"].(map[string]any)
|
||||
for _, key := range []string{
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
} {
|
||||
if env[key] != "only-default" {
|
||||
t.Errorf("%s = %v, want only-default", key, env[key])
|
||||
}
|
||||
}
|
||||
// Empty roles remove the keys again.
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Sonnet: "s"}}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
env = readSettings(t, path)["env"].(map[string]any)
|
||||
if _, ok := env["ANTHROPIC_DEFAULT_OPUS_MODEL"]; ok {
|
||||
t.Error("opus key should be removed when the provider has no opus model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCreatesMissingSettingsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
mgr := New(path, filepath.Join(dir, "backups"))
|
||||
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Default: "m"}}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("settings.json not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsReturnsEmptySliceNotNil(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mgr := New(filepath.Join(dir, "settings.json"), filepath.Join(dir, "backups"))
|
||||
backups, err := mgr.Backups()
|
||||
if err != nil {
|
||||
t.Fatalf("Backups: %v", err)
|
||||
}
|
||||
if backups == nil {
|
||||
t.Fatal("Backups returned nil, which marshals to JSON null and breaks the UI")
|
||||
}
|
||||
if len(backups) != 0 {
|
||||
t.Errorf("backups = %v, want empty", backups)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user