summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/config
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-30 20:47:56 -0400
committerDax Raad <[email protected]>2025-05-30 20:48:36 -0400
commitf3da73553c45f17e04b1e77cb13eb0fca714d1bd (patch)
treea24317a19e1ab2a89da50db669dc6894f15d00d1 /packages/tui/internal/config
parent9a26b3058ffc1023e5c7e54b6d571c903d15888e (diff)
downloadopencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.tar.gz
opencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.zip
sync
Diffstat (limited to 'packages/tui/internal/config')
-rw-r--r--packages/tui/internal/config/config.go266
-rw-r--r--packages/tui/internal/config/init.go60
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
+}