mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
266 lines
6.6 KiB
Go
266 lines
6.6 KiB
Go
// Package config manages application configuration from various sources.
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// Data defines storage configuration.
|
|
type Data struct {
|
|
Directory string `json:"directory,omitempty"`
|
|
}
|
|
|
|
// TUIConfig defines the configuration for the Terminal User Interface.
|
|
type TUIConfig struct {
|
|
Theme string `json:"theme,omitempty"`
|
|
CustomTheme map[string]any `json:"customTheme,omitempty"`
|
|
}
|
|
|
|
// ShellConfig defines the configuration for the shell used by the bash tool.
|
|
type ShellConfig struct {
|
|
Path string `json:"path,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
}
|
|
|
|
// Config is the main configuration structure for the application.
|
|
type Config struct {
|
|
Data Data `json:"data"`
|
|
WorkingDir string `json:"wd,omitempty"`
|
|
Debug bool `json:"debug,omitempty"`
|
|
DebugLSP bool `json:"debugLSP,omitempty"`
|
|
ContextPaths []string `json:"contextPaths,omitempty"`
|
|
TUI TUIConfig `json:"tui"`
|
|
Shell ShellConfig `json:"shell,omitempty"`
|
|
}
|
|
|
|
// Application constants
|
|
const (
|
|
defaultDataDirectory = ".opencode"
|
|
defaultLogLevel = "info"
|
|
appName = "opencode"
|
|
|
|
MaxTokensFallbackDefault = 4096
|
|
)
|
|
|
|
var defaultContextPaths = []string{
|
|
".github/copilot-instructions.md",
|
|
".cursorrules",
|
|
".cursor/rules/",
|
|
"CLAUDE.md",
|
|
"CLAUDE.local.md",
|
|
"CONTEXT.md",
|
|
"CONTEXT.local.md",
|
|
"opencode.md",
|
|
"opencode.local.md",
|
|
"OpenCode.md",
|
|
"OpenCode.local.md",
|
|
"OPENCODE.md",
|
|
"OPENCODE.local.md",
|
|
}
|
|
|
|
// Global configuration instance
|
|
var cfg *Config
|
|
|
|
// Load initializes the configuration from environment variables and config files.
|
|
// If debug is true, debug mode is enabled and log level is set to debug.
|
|
// It returns an error if configuration loading fails.
|
|
func Load(workingDir string, debug bool) (*Config, error) {
|
|
if cfg != nil {
|
|
return cfg, nil
|
|
}
|
|
|
|
cfg = &Config{
|
|
WorkingDir: workingDir,
|
|
}
|
|
|
|
configureViper()
|
|
setDefaults(debug)
|
|
|
|
// Read global config
|
|
if err := readConfig(viper.ReadInConfig()); err != nil {
|
|
return cfg, err
|
|
}
|
|
|
|
// Load and merge local config
|
|
mergeLocalConfig(workingDir)
|
|
|
|
// Apply configuration to the struct
|
|
if err := viper.Unmarshal(cfg); err != nil {
|
|
return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
|
|
}
|
|
|
|
defaultLevel := slog.LevelInfo
|
|
if cfg.Debug {
|
|
defaultLevel = slog.LevelDebug
|
|
}
|
|
slog.SetLogLoggerLevel(defaultLevel)
|
|
|
|
// Validate configuration
|
|
if err := Validate(); err != nil {
|
|
return cfg, fmt.Errorf("config validation failed: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// configureViper sets up viper's configuration paths and environment variables.
|
|
func configureViper() {
|
|
viper.SetConfigName(fmt.Sprintf(".%s", appName))
|
|
viper.SetConfigType("json")
|
|
viper.AddConfigPath("$HOME")
|
|
viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
|
|
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
|
|
viper.SetEnvPrefix(strings.ToUpper(appName))
|
|
viper.AutomaticEnv()
|
|
}
|
|
|
|
// setDefaults configures default values for configuration options.
|
|
func setDefaults(debug bool) {
|
|
viper.SetDefault("data.directory", defaultDataDirectory)
|
|
viper.SetDefault("contextPaths", defaultContextPaths)
|
|
viper.SetDefault("tui.theme", "opencode")
|
|
|
|
if debug {
|
|
viper.SetDefault("debug", true)
|
|
viper.Set("log.level", "debug")
|
|
} else {
|
|
viper.SetDefault("debug", false)
|
|
viper.SetDefault("log.level", defaultLogLevel)
|
|
}
|
|
}
|
|
|
|
// readConfig handles the result of reading a configuration file.
|
|
func readConfig(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// It's okay if the config file doesn't exist
|
|
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("failed to read config: %w", err)
|
|
}
|
|
|
|
// mergeLocalConfig loads and merges configuration from the local directory.
|
|
func mergeLocalConfig(workingDir string) {
|
|
local := viper.New()
|
|
local.SetConfigName(fmt.Sprintf(".%s", appName))
|
|
local.SetConfigType("json")
|
|
local.AddConfigPath(workingDir)
|
|
|
|
// Merge local config if it exists
|
|
if err := local.ReadInConfig(); err == nil {
|
|
viper.MergeConfigMap(local.AllSettings())
|
|
}
|
|
}
|
|
|
|
// Validate checks if the configuration is valid and applies defaults where needed.
|
|
func Validate() error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("config not loaded")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get returns the current configuration.
|
|
// It's safe to call this function multiple times.
|
|
func Get() *Config {
|
|
return cfg
|
|
}
|
|
|
|
// WorkingDirectory returns the current working directory from the configuration.
|
|
func WorkingDirectory() string {
|
|
if cfg == nil {
|
|
panic("config not loaded")
|
|
}
|
|
return cfg.WorkingDir
|
|
}
|
|
|
|
// GetHostname returns the system hostname or "User" if it can't be determined
|
|
func GetHostname() (string, error) {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return "User", err
|
|
}
|
|
return hostname, nil
|
|
}
|
|
|
|
// GetUsername returns the current user's username
|
|
func GetUsername() (string, error) {
|
|
currentUser, err := user.Current()
|
|
if err != nil {
|
|
return "User", err
|
|
}
|
|
return currentUser.Username, nil
|
|
}
|
|
|
|
func updateCfgFile(updateCfg func(config *Config)) error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("config not loaded")
|
|
}
|
|
|
|
// Get the config file path
|
|
configFile := viper.ConfigFileUsed()
|
|
var configData []byte
|
|
if configFile == "" {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get home directory: %w", err)
|
|
}
|
|
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
|
|
slog.Info("config file not found, creating new one", "path", configFile)
|
|
configData = []byte(`{}`)
|
|
} else {
|
|
// Read the existing config file
|
|
data, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
configData = data
|
|
}
|
|
|
|
// Parse the JSON
|
|
var userCfg *Config
|
|
if err := json.Unmarshal(configData, &userCfg); err != nil {
|
|
return fmt.Errorf("failed to parse config file: %w", err)
|
|
}
|
|
|
|
updateCfg(userCfg)
|
|
|
|
// Write the updated config back to file
|
|
updatedData, err := json.MarshalIndent(userCfg, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal config: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
|
|
return fmt.Errorf("failed to write config file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateTheme updates the theme in the configuration and writes it to the config file.
|
|
func UpdateTheme(themeName string) error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("config not loaded")
|
|
}
|
|
|
|
// Update the in-memory config
|
|
cfg.TUI.Theme = themeName
|
|
|
|
// Update the file config
|
|
return updateCfgFile(func(config *Config) {
|
|
config.TUI.Theme = themeName
|
|
})
|
|
}
|