diff options
| author | Dax Raad <[email protected]> | 2025-05-30 20:47:56 -0400 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2025-05-30 20:48:36 -0400 |
| commit | f3da73553c45f17e04b1e77cb13eb0fca714d1bd (patch) | |
| tree | a24317a19e1ab2a89da50db669dc6894f15d00d1 /packages/tui/internal/config | |
| parent | 9a26b3058ffc1023e5c7e54b6d571c903d15888e (diff) | |
| download | opencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.tar.gz opencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.zip | |
sync
Diffstat (limited to 'packages/tui/internal/config')
| -rw-r--r-- | packages/tui/internal/config/config.go | 266 | ||||
| -rw-r--r-- | packages/tui/internal/config/init.go | 60 |
2 files changed, 326 insertions, 0 deletions
diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go new file mode 100644 index 000000000..2197f5aa4 --- /dev/null +++ b/packages/tui/internal/config/config.go @@ -0,0 +1,266 @@ +// 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 + }) +} diff --git a/packages/tui/internal/config/init.go b/packages/tui/internal/config/init.go new file mode 100644 index 000000000..5f8860f52 --- /dev/null +++ b/packages/tui/internal/config/init.go @@ -0,0 +1,60 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + // InitFlagFilename is the name of the file that indicates whether the project has been initialized + InitFlagFilename = "init" +) + +// ProjectInitFlag represents the initialization status for a project directory +type ProjectInitFlag struct { + Initialized bool `json:"initialized"` +} + +// ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory +func ShouldShowInitDialog() (bool, error) { + if cfg == nil { + return false, fmt.Errorf("config not loaded") + } + + // Create the flag file path + flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename) + + // Check if the flag file exists + _, err := os.Stat(flagFilePath) + if err == nil { + // File exists, don't show the dialog + return false, nil + } + + // If the error is not "file not found", return the error + if !os.IsNotExist(err) { + return false, fmt.Errorf("failed to check init flag file: %w", err) + } + + // File doesn't exist, show the dialog + return true, nil +} + +// MarkProjectInitialized marks the current project as initialized +func MarkProjectInitialized() error { + if cfg == nil { + return fmt.Errorf("config not loaded") + } + // Create the flag file path + flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename) + + // Create an empty file to mark the project as initialized + file, err := os.Create(flagFilePath) + if err != nil { + return fmt.Errorf("failed to create init flag file: %w", err) + } + defer file.Close() + + return nil +} |
