first commit
This commit is contained in:
474
cli/internal/harness/harness.go
Normal file
474
cli/internal/harness/harness.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
// Harness defines a coding assistant CLI that Bifrost can launch and manage.
|
||||
type Harness struct {
|
||||
ID string
|
||||
Label string
|
||||
Binary string
|
||||
InstallPkg string
|
||||
VersionArgs []string
|
||||
BasePath string
|
||||
BaseURLEnv string
|
||||
APIKeyEnv string
|
||||
AuthTokenEnv string
|
||||
ModelEnv string
|
||||
SupportsMCP bool
|
||||
SupportsWorktree bool
|
||||
RunArgsForMod func(model string) []string
|
||||
WorktreeArgs func(name string) []string
|
||||
// PreLaunch is called before launching the harness binary. It can write
|
||||
// config files and return extra environment variables to inject. The
|
||||
// returned cleanup function is deferred after the process exits.
|
||||
PreLaunch func(baseURL, apiKey, model string) (extraEnv []string, cleanup func(), err error)
|
||||
// WriteNativeConfig persists the bifrost connection settings into the
|
||||
// harness CLI's own config file so the same configuration is available
|
||||
// when users launch the CLI directly outside bifrost.
|
||||
WriteNativeConfig func(baseURL, apiKey, model string) error
|
||||
// NativeConfigPath is the human-readable path to the file that
|
||||
// WriteNativeConfig modifies (e.g. "~/.claude/settings.json").
|
||||
// Used to inform the user in the confirmation prompt.
|
||||
NativeConfigPath string
|
||||
}
|
||||
|
||||
var all = map[string]Harness{
|
||||
"claude": {
|
||||
ID: "claude",
|
||||
Label: "Claude Code",
|
||||
Binary: "claude",
|
||||
InstallPkg: "@anthropic-ai/claude-code",
|
||||
VersionArgs: []string{"--version"},
|
||||
BasePath: "/anthropic",
|
||||
BaseURLEnv: "ANTHROPIC_BASE_URL",
|
||||
APIKeyEnv: "ANTHROPIC_API_KEY",
|
||||
AuthTokenEnv: "ANTHROPIC_AUTH_TOKEN",
|
||||
SupportsMCP: true,
|
||||
SupportsWorktree: true,
|
||||
RunArgsForMod: func(model string) []string {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"--model", model}
|
||||
},
|
||||
WorktreeArgs: func(name string) []string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return []string{"--worktree"}
|
||||
}
|
||||
return []string{"--worktree", name}
|
||||
},
|
||||
PreLaunch: claudePreLaunch,
|
||||
WriteNativeConfig: claudeWriteNativeConfig,
|
||||
NativeConfigPath: "~/.claude/settings.json",
|
||||
},
|
||||
"codex": {
|
||||
ID: "codex",
|
||||
Label: "Codex CLI",
|
||||
Binary: "codex",
|
||||
InstallPkg: "@openai/codex",
|
||||
VersionArgs: []string{
|
||||
"--version",
|
||||
},
|
||||
BasePath: "/openai",
|
||||
BaseURLEnv: "OPENAI_BASE_URL",
|
||||
APIKeyEnv: "OPENAI_API_KEY",
|
||||
ModelEnv: "OPENAI_MODEL",
|
||||
RunArgsForMod: func(model string) []string {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"--model", model}
|
||||
},
|
||||
},
|
||||
"gemini": {
|
||||
ID: "gemini",
|
||||
Label: "Gemini CLI",
|
||||
Binary: "gemini",
|
||||
InstallPkg: "@google/gemini-cli",
|
||||
VersionArgs: []string{
|
||||
"--version",
|
||||
},
|
||||
BasePath: "/genai",
|
||||
BaseURLEnv: "GOOGLE_GEMINI_BASE_URL",
|
||||
APIKeyEnv: "GEMINI_API_KEY",
|
||||
ModelEnv: "GEMINI_MODEL",
|
||||
RunArgsForMod: func(model string) []string {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"--model", model}
|
||||
},
|
||||
},
|
||||
"opencode": {
|
||||
ID: "opencode",
|
||||
Label: "Opencode",
|
||||
Binary: "opencode",
|
||||
InstallPkg: "opencode-ai",
|
||||
VersionArgs: []string{
|
||||
"--version",
|
||||
},
|
||||
BasePath: "/openai",
|
||||
BaseURLEnv: "OPENAI_BASE_URL",
|
||||
APIKeyEnv: "OPENAI_API_KEY",
|
||||
RunArgsForMod: func(model string) []string {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"--model", opencodeModelRef(model)}
|
||||
},
|
||||
PreLaunch: opencodePreLaunch,
|
||||
},
|
||||
}
|
||||
|
||||
// Get returns the harness with the given ID and whether it exists.
|
||||
func Get(id string) (Harness, bool) {
|
||||
h, ok := all[id]
|
||||
return h, ok
|
||||
}
|
||||
|
||||
// IDs returns the sorted list of all registered harness IDs.
|
||||
func IDs() []string {
|
||||
ids := make([]string, 0, len(all))
|
||||
for id := range all {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// Labels returns display labels for all harnesses in the format "Label (id)".
|
||||
func Labels() []string {
|
||||
ids := IDs()
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, fmt.Sprintf("%s (%s)", all[id].Label, id))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseChoice extracts the harness ID from a label string like "Label (id)".
|
||||
func ParseChoice(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if i := strings.LastIndex(raw, "("); i >= 0 && strings.HasSuffix(raw, ")") {
|
||||
return strings.TrimSuffix(raw[i+1:], ")")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// DetectVersion runs the harness binary with its version flag and returns the version string.
|
||||
func DetectVersion(h Harness) string {
|
||||
if _, err := exec.LookPath(h.Binary); err != nil {
|
||||
return "not-installed"
|
||||
}
|
||||
|
||||
args := h.VersionArgs
|
||||
if len(args) == 0 {
|
||||
args = []string{"--version"}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, h.Binary, args...).CombinedOutput()
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "timeout"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
s := strings.TrimSpace(string(out))
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// opencodePreLaunch writes temporary OpenCode config files when Bifrost needs
|
||||
// to override runtime model/provider settings and/or supply an adaptive TUI
|
||||
// theme. The returned cleanup removes any generated temp files after exit.
|
||||
func opencodePreLaunch(baseURL, apiKey, model string) ([]string, func(), error) {
|
||||
var env []string
|
||||
var cleanupFns []func()
|
||||
|
||||
tuiEnv, tuiCleanup, err := opencodeTUIPreLaunch()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
env = append(env, tuiEnv...)
|
||||
if tuiCleanup != nil {
|
||||
cleanupFns = append(cleanupFns, tuiCleanup)
|
||||
}
|
||||
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return env, combineCleanup(cleanupFns), nil
|
||||
}
|
||||
|
||||
modelRef := opencodeModelRef(model)
|
||||
runtimeCfg := fmt.Sprintf(`{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": %q,
|
||||
"provider": {
|
||||
"bifrost": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Bifrost",
|
||||
"options": {
|
||||
"baseURL": %q,
|
||||
"apiKey": %q
|
||||
},
|
||||
"models": {
|
||||
%q: {
|
||||
"name": %q
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, modelRef, strings.TrimSpace(baseURL), strings.TrimSpace(apiKey), model, model)
|
||||
|
||||
f, err := os.CreateTemp("", "bifrost-opencode-*.json")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create opencode config: %w", err)
|
||||
}
|
||||
if _, err := f.WriteString(runtimeCfg); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return nil, nil, fmt.Errorf("write opencode config: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return nil, nil, fmt.Errorf("close opencode config: %w", err)
|
||||
}
|
||||
|
||||
env = append(env, "OPENCODE_CONFIG="+f.Name())
|
||||
cleanupFns = append(cleanupFns, func() { os.Remove(f.Name()) })
|
||||
return env, combineCleanup(cleanupFns), nil
|
||||
}
|
||||
|
||||
// opencodeModelRef returns the Opencode model reference.
|
||||
func opencodeModelRef(model string) string {
|
||||
return "bifrost/" + strings.TrimSpace(model)
|
||||
}
|
||||
|
||||
// opencodeTUIPreLaunch loads the Opencode TUI config from the user's home directory.
|
||||
func opencodeTUIPreLaunch() ([]string, func(), error) {
|
||||
path, err := opencodeTUIConfigPath()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve opencode tui config: %w", err)
|
||||
}
|
||||
|
||||
cfg, hasTheme, err := loadOpencodeTUIConfig(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if hasTheme {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
cfg["theme"] = "system"
|
||||
|
||||
b, err := sonic.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal opencode tui config: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", "bifrost-opencode-tui-*.json")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create opencode tui config: %w", err)
|
||||
}
|
||||
if _, err := f.Write(b); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return nil, nil, fmt.Errorf("write opencode tui config: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return nil, nil, fmt.Errorf("close opencode tui config: %w", err)
|
||||
}
|
||||
|
||||
return []string{"OPENCODE_TUI_CONFIG=" + f.Name()}, func() { os.Remove(f.Name()) }, nil
|
||||
}
|
||||
|
||||
// opencodeTUIConfigPath returns the path to the Opencode TUI config.
|
||||
func opencodeTUIConfigPath() (string, error) {
|
||||
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
|
||||
return filepath.Join(xdg, "opencode", "tui.json"), nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "opencode", "tui.json"), nil
|
||||
}
|
||||
|
||||
// loadOpencodeTUIConfig loads the Opencode TUI config from the given path.
|
||||
func loadOpencodeTUIConfig(path string) (map[string]any, bool, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("read opencode tui config: %w", err)
|
||||
}
|
||||
|
||||
normalized := normalizeJSONC(b)
|
||||
if len(bytes.TrimSpace(normalized)) == 0 {
|
||||
return map[string]any{}, false, nil
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := sonic.Unmarshal(normalized, &cfg); err != nil {
|
||||
return nil, false, fmt.Errorf("parse opencode tui config: %w", err)
|
||||
}
|
||||
theme, ok := cfg["theme"]
|
||||
return cfg, ok && strings.TrimSpace(fmt.Sprint(theme)) != "", nil
|
||||
}
|
||||
|
||||
// combineCleanup combines multiple cleanup functions into a single function.
|
||||
func combineCleanup(cleanups []func()) func() {
|
||||
return func() {
|
||||
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||
if cleanups[i] != nil {
|
||||
cleanups[i]()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeJSONC removes trailing commas and comments from JSONC data.
|
||||
func normalizeJSONC(data []byte) []byte {
|
||||
return stripTrailingCommas(stripJSONComments(data))
|
||||
}
|
||||
|
||||
// stripJSONComments removes comments from JSONC data.
|
||||
func stripJSONComments(data []byte) []byte {
|
||||
out := make([]byte, 0, len(data))
|
||||
inString := false
|
||||
escape := false
|
||||
inLineComment := false
|
||||
inBlockComment := false
|
||||
|
||||
for i := 0; i < len(data); i++ {
|
||||
ch := data[i]
|
||||
|
||||
if inLineComment {
|
||||
if ch == '\n' {
|
||||
inLineComment = false
|
||||
out = append(out, ch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inBlockComment {
|
||||
if ch == '*' && i+1 < len(data) && data[i+1] == '/' {
|
||||
inBlockComment = false
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inString {
|
||||
out = append(out, ch)
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escape = true
|
||||
} else if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if ch == '/' && i+1 < len(data) {
|
||||
switch data[i+1] {
|
||||
case '/':
|
||||
inLineComment = true
|
||||
i++
|
||||
continue
|
||||
case '*':
|
||||
inBlockComment = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, ch)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func stripTrailingCommas(data []byte) []byte {
|
||||
out := make([]byte, 0, len(data))
|
||||
inString := false
|
||||
escape := false
|
||||
|
||||
for i := 0; i < len(data); i++ {
|
||||
ch := data[i]
|
||||
|
||||
if inString {
|
||||
out = append(out, ch)
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escape = true
|
||||
} else if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if ch == ',' {
|
||||
j := i + 1
|
||||
for j < len(data) {
|
||||
switch data[j] {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
j++
|
||||
continue
|
||||
case '}', ']':
|
||||
ch = 0
|
||||
}
|
||||
break
|
||||
}
|
||||
if ch == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, ch)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
246
cli/internal/harness/harness_test.go
Normal file
246
cli/internal/harness/harness_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
func TestClaudePreLaunchPinsSelectedModelAcrossClaudeTiers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env, cleanup, err := claudePreLaunch("https://example.com/anthropic", "test-key", "openai/gpt-5")
|
||||
if err != nil {
|
||||
t.Fatalf("claudePreLaunch() error = %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
for _, want := range []string{
|
||||
"CLAUDE_CODE_SIMPLE=1",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=openai/gpt-5",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=openai/gpt-5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=openai/gpt-5",
|
||||
} {
|
||||
parts := strings.SplitN(want, "=", 2)
|
||||
if got := envValue(env, parts[0]); got != parts[1] {
|
||||
t.Fatalf("env[%q] = %q, want %q", parts[0], got, parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if got := envValue(env, "ANTHROPIC_MODEL"); got != "" {
|
||||
t.Fatalf("did not expect ANTHROPIC_MODEL in env, got %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeWriteNativeConfigPinsTierDefaults(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
settingsDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(settingsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir settings dir: %v", err)
|
||||
}
|
||||
settingsPath := filepath.Join(settingsDir, "settings.json")
|
||||
initial := `{"env":{"EXISTING":"keep","ANTHROPIC_MODEL":"stale-model"}}`
|
||||
if err := os.WriteFile(settingsPath, []byte(initial), 0o600); err != nil {
|
||||
t.Fatalf("write initial settings: %v", err)
|
||||
}
|
||||
|
||||
if err := claudeWriteNativeConfig("https://example.com/anthropic", "test-key", "openai/gpt-5"); err != nil {
|
||||
t.Fatalf("claudeWriteNativeConfig() error = %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if err := sonic.Unmarshal(b, &settings); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
}
|
||||
|
||||
envRaw, ok := settings["env"]
|
||||
if !ok {
|
||||
t.Fatalf("expected env map in settings, got %#v", settings)
|
||||
}
|
||||
envMap, ok := envRaw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("env map type = %T, want map[string]any", envRaw)
|
||||
}
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"EXISTING": "keep",
|
||||
"ANTHROPIC_BASE_URL": "https://example.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "test-key",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "openai/gpt-5",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "openai/gpt-5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "openai/gpt-5",
|
||||
} {
|
||||
if got, _ := envMap[key].(string); got != want {
|
||||
t.Fatalf("env[%q] = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := envMap["ANTHROPIC_MODEL"]; ok {
|
||||
t.Fatalf("did not expect legacy ANTHROPIC_MODEL in settings env: %#v", envMap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeModelRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := opencodeModelRef("gpt-4.1"); got != "bifrost/gpt-4.1" {
|
||||
t.Fatalf("opencodeModelRef() = %q, want %q", got, "bifrost/gpt-4.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodePreLaunchWritesCustomProviderConfig(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
|
||||
env, cleanup, err := opencodePreLaunch("https://example.com/openai", "test-key", "gpt-4.1")
|
||||
if err != nil {
|
||||
t.Fatalf("opencodePreLaunch() error = %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if len(env) != 2 {
|
||||
t.Fatalf("unexpected env returned: %#v", env)
|
||||
}
|
||||
|
||||
configPath := envValue(env, "OPENCODE_CONFIG")
|
||||
if configPath == "" {
|
||||
t.Fatalf("expected OPENCODE_CONFIG in env, got %#v", env)
|
||||
}
|
||||
b, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated config: %v", err)
|
||||
}
|
||||
|
||||
cfg := string(b)
|
||||
for _, want := range []string{
|
||||
`"model": "bifrost/gpt-4.1"`,
|
||||
`"bifrost": {`,
|
||||
`"npm": "@ai-sdk/openai-compatible"`,
|
||||
`"baseURL": "https://example.com/openai"`,
|
||||
`"apiKey": "test-key"`,
|
||||
`"gpt-4.1": {`,
|
||||
} {
|
||||
if !strings.Contains(cfg, want) {
|
||||
t.Fatalf("expected generated config to contain %q, got %s", want, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
tuiPath := envValue(env, "OPENCODE_TUI_CONFIG")
|
||||
if tuiPath == "" {
|
||||
t.Fatalf("expected OPENCODE_TUI_CONFIG in env, got %#v", env)
|
||||
}
|
||||
tuiCfg, err := os.ReadFile(tuiPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated tui config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(tuiCfg), `"theme": "system"`) {
|
||||
t.Fatalf("expected generated tui config to set system theme, got %s", string(tuiCfg))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodePreLaunchPreservesExistingTheme(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
|
||||
tuiPath := filepath.Join(xdg, "opencode", "tui.json")
|
||||
if err := os.MkdirAll(filepath.Dir(tuiPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir tui dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(tuiPath, []byte("{\n \"theme\": \"light\"\n}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write tui config: %v", err)
|
||||
}
|
||||
|
||||
env, cleanup, err := opencodePreLaunch("https://example.com/openai", "test-key", "gpt-4.1")
|
||||
if err != nil {
|
||||
t.Fatalf("opencodePreLaunch() error = %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if got := envValue(env, "OPENCODE_TUI_CONFIG"); got != "" {
|
||||
t.Fatalf("did not expect OPENCODE_TUI_CONFIG override when user theme exists, got %#v", env)
|
||||
}
|
||||
if got := envValue(env, "OPENCODE_CONFIG"); got == "" {
|
||||
t.Fatalf("expected OPENCODE_CONFIG to remain present, got %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodePreLaunchAddsSystemThemeWithoutModel(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
|
||||
env, cleanup, err := opencodePreLaunch("https://example.com/openai", "test-key", "")
|
||||
if err != nil {
|
||||
t.Fatalf("opencodePreLaunch() error = %v", err)
|
||||
}
|
||||
|
||||
tuiPath := envValue(env, "OPENCODE_TUI_CONFIG")
|
||||
if tuiPath == "" {
|
||||
t.Fatalf("expected OPENCODE_TUI_CONFIG in env, got %#v", env)
|
||||
}
|
||||
if got := envValue(env, "OPENCODE_CONFIG"); got != "" {
|
||||
t.Fatalf("did not expect OPENCODE_CONFIG without a model, got %#v", env)
|
||||
}
|
||||
if _, err := os.Stat(tuiPath); err != nil {
|
||||
t.Fatalf("expected generated tui config to exist: %v", err)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
if _, err := os.Stat(tuiPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected generated tui config to be removed after cleanup, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpencodeTUIConfigSupportsJSONC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "tui.json")
|
||||
content := "{\n // keep my choice\n \"theme\": \"light\",\n \"foo\": true,\n}\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write tui config: %v", err)
|
||||
}
|
||||
|
||||
cfg, hasTheme, err := loadOpencodeTUIConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadOpencodeTUIConfig() error = %v", err)
|
||||
}
|
||||
if !hasTheme {
|
||||
t.Fatal("expected theme to be detected")
|
||||
}
|
||||
if cfg["theme"] != "light" {
|
||||
t.Fatalf("cfg[theme] = %#v, want %q", cfg["theme"], "light")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeTUIConfigPathPrefersXDG(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
|
||||
got, err := opencodeTUIConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("opencodeTUIConfigPath() error = %v", err)
|
||||
}
|
||||
want := filepath.Join(xdg, "opencode", "tui.json")
|
||||
if got != want {
|
||||
t.Fatalf("opencodeTUIConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func envValue(env []string, key string) string {
|
||||
prefix := key + "="
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, prefix) {
|
||||
return strings.TrimPrefix(entry, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
98
cli/internal/harness/native_config.go
Normal file
98
cli/internal/harness/native_config.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/maximhq/bifrost/cli/internal/config"
|
||||
)
|
||||
|
||||
// claudePreLaunch forces Claude Code into its simpler terminal mode when
|
||||
// launched inside Bifrost's tab multiplexer. This avoids Claude-specific
|
||||
// full-screen terminal behavior that doesn't restore reliably across tab swaps.
|
||||
func claudePreLaunch(baseURL, apiKey, model string) ([]string, func(), error) {
|
||||
env := []string{"CLAUDE_CODE_SIMPLE=1"}
|
||||
if model = strings.TrimSpace(model); model != "" {
|
||||
env = append(env, claudeTierModelEnv(model)...)
|
||||
}
|
||||
return env, func() {}, nil
|
||||
}
|
||||
|
||||
// claudeWriteNativeConfig writes the bifrost endpoint, API key, and model
|
||||
// into Claude Code's settings file (~/.claude/settings.json) so the same
|
||||
// configuration is available when users launch Claude Code directly.
|
||||
//
|
||||
// It merges into the existing file, preserving any user-defined settings.
|
||||
func claudeWriteNativeConfig(baseURL, apiKey, model string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(home, ".claude")
|
||||
settingsPath := filepath.Join(dir, "settings.json")
|
||||
|
||||
// Read existing settings or start fresh
|
||||
settings := make(map[string]any)
|
||||
if b, err := os.ReadFile(settingsPath); err == nil {
|
||||
if err := sonic.Unmarshal(b, &settings); err != nil {
|
||||
return fmt.Errorf("parse existing claude settings: %w", err)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read claude settings: %w", err)
|
||||
}
|
||||
|
||||
// Get or create the env map
|
||||
envRaw, ok := settings["env"]
|
||||
var envMap map[string]any
|
||||
if ok {
|
||||
envMap, ok = envRaw.(map[string]any)
|
||||
if !ok {
|
||||
envMap = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
envMap = make(map[string]any)
|
||||
}
|
||||
|
||||
envMap["ANTHROPIC_BASE_URL"] = baseURL
|
||||
envMap["ANTHROPIC_API_KEY"] = apiKey
|
||||
if model = strings.TrimSpace(model); model != "" {
|
||||
for key, value := range claudeTierModelEnvMap(model) {
|
||||
envMap[key] = value
|
||||
}
|
||||
delete(envMap, "ANTHROPIC_MODEL")
|
||||
}
|
||||
|
||||
settings["env"] = envMap
|
||||
|
||||
b, err := sonic.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal claude settings: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create claude config dir: %w", err)
|
||||
}
|
||||
return config.WriteAtomic(settingsPath, b, 0o600)
|
||||
}
|
||||
|
||||
func claudeTierModelEnv(model string) []string {
|
||||
envMap := claudeTierModelEnvMap(model)
|
||||
return []string{
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=" + envMap["ANTHROPIC_DEFAULT_SONNET_MODEL"],
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=" + envMap["ANTHROPIC_DEFAULT_OPUS_MODEL"],
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=" + envMap["ANTHROPIC_DEFAULT_HAIKU_MODEL"],
|
||||
}
|
||||
}
|
||||
|
||||
func claudeTierModelEnvMap(model string) map[string]string {
|
||||
return map[string]string{
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": model,
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": model,
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": model,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user