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:
kate522 committed 2026-10-18 17:19:13 +08:00
1 parent 8ee5e98ea3
commit f8acf31ebc
71 files changed
+15727 -1

No files matched your search

+303
View File
@@ -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 ""
}