refactor: introduce config.Service, Load/Init now return *Service

Kujtim Hoxha created

Service wraps *Config and owns internal state (store, resolver,
knownProviders, workingDir). A temporary Config() escape hatch
lets callers access the underlying struct during the migration.

🐾 Generated with Crush

Assisted-by: Claude Opus 4.6 via Crush <crush@charm.land>

Change summary

internal/agent/common_test.go | 14 +++++++-------
internal/cmd/logs.go          |  2 +-
internal/cmd/models.go        |  4 ++--
internal/cmd/root.go          |  3 ++-
internal/cmd/stats.go         |  4 ++--
internal/config/init.go       |  6 +++---
internal/config/load.go       | 22 ++++++++++++++++------
internal/config/service.go    | 22 ++++++++++++++++++++++
8 files changed, 55 insertions(+), 22 deletions(-)

Detailed changes

internal/agent/common_test.go 🔗

@@ -177,39 +177,39 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 
 	// NOTE(@andreynering): Set a fixed config to ensure cassettes match
 	// independently of user config on `$HOME/.config/crush/crush.json`.
-	cfg.Options.Attribution = &config.Attribution{
+	cfg.Config().Options.Attribution = &config.Attribution{
 		TrailerStyle:  "co-authored-by",
 		GeneratedWith: true,
 	}
 
 	// Clear skills paths to ensure test reproducibility - user's skills
 	// would be included in prompt and break VCR cassette matching.
-	cfg.Options.SkillsPaths = []string{}
+	cfg.Config().Options.SkillsPaths = []string{}
 
 	// Clear LSP config to ensure test reproducibility - user's LSP config
 	// would be included in prompt and break VCR cassette matching.
-	cfg.LSP = nil
+	cfg.Config().LSP = nil
 
-	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
+	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg.Config())
 	if err != nil {
 		return nil, err
 	}
 
 	// Get the model name for the bash tool
 	modelName := large.Model() // fallback to ID if Name not available
-	if model := cfg.GetModel(large.Provider(), large.Model()); model != nil {
+	if model := cfg.Config().GetModel(large.Provider(), large.Model()); model != nil {
 		modelName = model.Name
 	}
 
 	allTools := []fantasy.AgentTool{
-		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
+		tools.NewBashTool(env.permissions, env.workingDir, cfg.Config().Options.Attribution, modelName),
 		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
 		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
 		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewGlobTool(env.workingDir),
 		tools.NewGrepTool(env.workingDir),
-		tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
+		tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls),
 		tools.NewSourcegraphTool(r.GetDefaultClient()),
 		tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
 		tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),

internal/cmd/logs.go 🔗

@@ -55,7 +55,7 @@ var logsCmd = &cobra.Command{
 		if err != nil {
 			return fmt.Errorf("failed to load configuration: %v", err)
 		}
-		logsFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
+		logsFile := filepath.Join(cfg.Config().Options.DataDirectory, "logs", "crush.log")
 		_, err = os.Stat(logsFile)
 		if os.IsNotExist(err) {
 			log.Warn("Looks like you are not in a crush project. No logs found.")

internal/cmd/models.go 🔗

@@ -38,7 +38,7 @@ crush models gpt5`,
 			return err
 		}
 
-		if !cfg.IsConfigured() {
+		if !cfg.Config().IsConfigured() {
 			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
 		}
 
@@ -55,7 +55,7 @@ crush models gpt5`,
 		var providerIDs []string
 		providerModels := make(map[string][]string)
 
-		for providerID, provider := range cfg.Providers.Seq2() {
+		for providerID, provider := range cfg.Config().Providers.Seq2() {
 			if provider.Disable {
 				continue
 			}

internal/cmd/root.go 🔗

@@ -195,10 +195,11 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
 		return nil, err
 	}
 
-	cfg, err := config.Init(cwd, dataDir, debug)
+	svc, err := config.Init(cwd, dataDir, debug)
 	if err != nil {
 		return nil, err
 	}
+	cfg := svc.Config()
 
 	if cfg.Permissions == nil {
 		cfg.Permissions = &config.Permissions{}

internal/cmd/stats.go 🔗

@@ -124,11 +124,11 @@ func runStats(cmd *cobra.Command, _ []string) error {
 	ctx := cmd.Context()
 
 	if dataDir == "" {
-		cfg, err := config.Init("", "", false)
+		svc, err := config.Init("", "", false)
 		if err != nil {
 			return fmt.Errorf("failed to initialize config: %w", err)
 		}
-		dataDir = cfg.Options.DataDirectory
+		dataDir = svc.Config().Options.DataDirectory
 	}
 
 	conn, err := db.Connect(ctx, dataDir)

internal/config/init.go 🔗

@@ -18,12 +18,12 @@ type ProjectInitFlag struct {
 	Initialized bool `json:"initialized"`
 }
 
-func Init(workingDir, dataDir string, debug bool) (*Config, error) {
-	cfg, err := Load(workingDir, dataDir, debug)
+func Init(workingDir, dataDir string, debug bool) (*Service, error) {
+	svc, err := Load(workingDir, dataDir, debug)
 	if err != nil {
 		return nil, err
 	}
-	return cfg, nil
+	return svc, nil
 }
 
 func ProjectNeedsInitialization(cfg *Config) (bool, error) {

internal/config/load.go 🔗

@@ -30,7 +30,7 @@ import (
 const defaultCatwalkURL = "https://catwalk.charm.sh"
 
 // Load loads the configuration from the default paths.
-func Load(workingDir, dataDir string, debug bool) (*Config, error) {
+func Load(workingDir, dataDir string, debug bool) (*Service, error) {
 	configPaths := lookupConfigs(workingDir)
 
 	cfg, err := loadFromConfigPaths(configPaths)
@@ -38,8 +38,16 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
 		return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
 	}
 
+	store := NewFileStore(GlobalConfigData())
+
+	svc := &Service{
+		cfg:        cfg,
+		store:      store,
+		workingDir: workingDir,
+	}
+
+	// Keep dataConfigDir in sync for the transitional configStore() accessor.
 	cfg.dataConfigDir = GlobalConfigData()
-	cfg.store = NewFileStore(cfg.dataConfigDir)
 
 	cfg.setDefaults(workingDir, dataDir)
 
@@ -73,26 +81,28 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
 	if err != nil {
 		return nil, err
 	}
+	svc.knownProviders = providers
 	cfg.knownProviders = providers
 
 	env := env.New()
 	// Configure providers
 	valueResolver := NewShellVariableResolver(env)
+	svc.resolver = valueResolver
 	cfg.resolver = valueResolver
-	if err := cfg.configureProviders(env, valueResolver, cfg.knownProviders); err != nil {
+	if err := cfg.configureProviders(env, valueResolver, svc.knownProviders); err != nil {
 		return nil, fmt.Errorf("failed to configure providers: %w", err)
 	}
 
 	if !cfg.IsConfigured() {
 		slog.Warn("No providers configured")
-		return cfg, nil
+		return svc, nil
 	}
 
-	if err := cfg.configureSelectedModels(cfg.knownProviders); err != nil {
+	if err := cfg.configureSelectedModels(svc.knownProviders); err != nil {
 		return nil, fmt.Errorf("failed to configure selected models: %w", err)
 	}
 	cfg.SetupAgents()
-	return cfg, nil
+	return svc, nil
 }
 
 func PushPopCrushEnv() func() {

internal/config/service.go 🔗

@@ -0,0 +1,22 @@
+package config
+
+import "charm.land/catwalk/pkg/catwalk"
+
+// Service is the central access point for configuration. It wraps the
+// raw Config data and owns all internal state that was previously held
+// as unexported fields on Config (resolver, store, known providers,
+// working directory).
+type Service struct {
+	cfg            *Config
+	store          Store
+	resolver       VariableResolver
+	workingDir     string
+	knownProviders []catwalk.Provider
+}
+
+// Config returns the underlying Config struct. This is a temporary
+// escape hatch that will be removed once all callers migrate to
+// Service getter methods.
+func (s *Service) Config() *Config {
+	return s.cfg
+}