refactor: add read accessor methods to Service, migrate callers

Kujtim Hoxha created

Add WorkingDir, EnabledProviders, IsConfigured, GetModel,
GetProviderForModel, GetModelByType, LargeModel, SmallModel,
Resolve, and Resolver to Service. Migrate callers in coordinator,
app, and UI to use ConfigService() instead of Config() for these
methods. Config retains the methods for prompt.Build and internal
load flow compatibility.

🐾 Generated with Crush

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

Change summary

internal/agent/coordinator.go       | 32 ++++++++--------
internal/app/app.go                 |  4 +-
internal/app/lsp.go                 |  6 +-
internal/cmd/models.go              |  2 
internal/cmd/run.go                 |  2 
internal/config/service.go          | 56 +++++++++++++++++++++++++++++++
internal/ui/dialog/api_key_input.go |  2 
internal/ui/dialog/commands.go      |  6 +-
internal/ui/dialog/filepicker.go    |  2 
internal/ui/dialog/reasoning.go     |  2 
internal/ui/model/header.go         |  5 +-
internal/ui/model/landing.go        |  2 
internal/ui/model/onboarding.go     |  2 
internal/ui/model/sidebar.go        |  4 +-
internal/ui/model/ui.go             |  6 +-
15 files changed, 94 insertions(+), 39 deletions(-)

Detailed changes

internal/agent/coordinator.go 🔗

@@ -105,7 +105,7 @@ func NewCoordinator(
 	}
 
 	// TODO: make this dynamic when we support multiple agents
-	prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
+	prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfgSvc.WorkingDir()))
 	if err != nil {
 		return nil, err
 	}
@@ -416,26 +416,26 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 	// Get the model name for the agent
 	modelName := ""
 	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
-		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
+		if model := c.cfgSvc.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
 			modelName = model.Name
 		}
 	}
 
 	allTools = append(allTools,
-		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
+		tools.NewBashTool(c.permissions, c.cfgSvc.WorkingDir(), c.cfg.Options.Attribution, modelName),
 		tools.NewJobOutputTool(),
 		tools.NewJobKillTool(),
-		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
-		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
-		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
-		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
-		tools.NewGlobTool(c.cfg.WorkingDir()),
-		tools.NewGrepTool(c.cfg.WorkingDir()),
-		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
+		tools.NewDownloadTool(c.permissions, c.cfgSvc.WorkingDir(), nil),
+		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfgSvc.WorkingDir()),
+		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfgSvc.WorkingDir()),
+		tools.NewFetchTool(c.permissions, c.cfgSvc.WorkingDir(), nil),
+		tools.NewGlobTool(c.cfgSvc.WorkingDir()),
+		tools.NewGrepTool(c.cfgSvc.WorkingDir()),
+		tools.NewLsTool(c.permissions, c.cfgSvc.WorkingDir(), c.cfg.Tools.Ls),
 		tools.NewSourcegraphTool(nil),
 		tools.NewTodosTool(c.sessions),
-		tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
-		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfgSvc.WorkingDir(), c.cfg.Options.SkillsPaths...),
+		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfgSvc.WorkingDir()),
 	)
 
 	if c.lspClients.Len() > 0 {
@@ -449,7 +449,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		}
 	}
 
-	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
+	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfgSvc.WorkingDir()) {
 		if agent.AllowedMCP == nil {
 			// No MCP restrictions
 			filteredTools = append(filteredTools, tool)
@@ -781,8 +781,8 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
 		}
 	}
 
-	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
-	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
+	apiKey, _ := c.cfgSvc.Resolve(providerCfg.APIKey)
+	baseURL, _ := c.cfgSvc.Resolve(providerCfg.BaseURL)
 
 	switch providerCfg.Type {
 	case openai.Name:
@@ -905,7 +905,7 @@ func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config
 }
 
 func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
-	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
+	newAPIKey, err := c.cfgSvc.Resolve(providerCfg.APIKeyTemplate)
 	if err != nil {
 		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
 		return err

internal/app/app.go 🔗

@@ -90,7 +90,7 @@ func New(ctx context.Context, conn *sql.DB, cfgSvc *config.Service) (*App, error
 		Sessions:    sessions,
 		Messages:    messages,
 		History:     files,
-		Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
+		Permissions: permission.NewPermissionService(cfgSvc.WorkingDir(), skipPermissionsRequests, allowedTools),
 		FileTracker: filetracker.NewService(q),
 		LSPClients:  csync.NewMap[string, *lsp.Client](),
 
@@ -118,7 +118,7 @@ func New(ctx context.Context, conn *sql.DB, cfgSvc *config.Service) (*App, error
 	app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close)
 
 	// TODO: remove the concept of agent config, most likely.
-	if !cfg.IsConfigured() {
+	if !cfgSvc.IsConfigured() {
 		slog.Warn("No agent configuration found")
 		return app, nil
 	}

internal/app/lsp.go 🔗

@@ -53,7 +53,7 @@ func (app *App) initLSPClients(ctx context.Context) {
 	}
 
 	servers := manager.GetServers()
-	filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
+	filtered := lsp.FilterMatching(app.configService.WorkingDir(), servers)
 
 	for _, name := range userConfiguredLSPs {
 		if _, ok := filtered[name]; !ok {
@@ -114,7 +114,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
 	updateLSPState(name, lsp.StateStarting, nil, nil, 0)
 
 	// Create LSP client.
-	lspClient, err := lsp.New(ctx, name, config, app.config.Resolver(), app.config.Options.DebugLSP)
+	lspClient, err := lsp.New(ctx, name, config, app.configService.Resolver(), app.config.Options.DebugLSP)
 	if err != nil {
 		if !userConfigured {
 			slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
@@ -134,7 +134,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
 	defer cancel()
 
 	// Initialize LSP client.
-	_, err = lspClient.Initialize(initCtx, app.config.WorkingDir())
+	_, err = lspClient.Initialize(initCtx, app.configService.WorkingDir())
 	if err != nil {
 		slog.Error("LSP client initialization failed", "name", name, "error", err)
 		updateLSPState(name, lsp.StateError, err, lspClient, 0)

internal/cmd/models.go 🔗

@@ -38,7 +38,7 @@ crush models gpt5`,
 			return err
 		}
 
-		if !cfg.Config().IsConfigured() {
+		if !cfg.IsConfigured() {
 			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
 		}
 

internal/cmd/run.go 🔗

@@ -50,7 +50,7 @@ crush run --verbose "Generate a README for this project"
 		}
 		defer app.Shutdown()
 
-		if !app.Config().IsConfigured() {
+		if !app.ConfigService().IsConfigured() {
 			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
 		}
 

internal/config/service.go 🔗

@@ -34,6 +34,62 @@ func (s *Service) Config() *Config {
 	return s.cfg
 }
 
+// WorkingDir returns the working directory.
+func (s *Service) WorkingDir() string {
+	return s.workingDir
+}
+
+// EnabledProviders returns all non-disabled provider configs.
+func (s *Service) EnabledProviders() []ProviderConfig {
+	return s.cfg.EnabledProviders()
+}
+
+// IsConfigured returns true if at least one provider is enabled.
+func (s *Service) IsConfigured() bool {
+	return s.cfg.IsConfigured()
+}
+
+// GetModel returns the catwalk model for the given provider and model
+// ID, or nil if not found.
+func (s *Service) GetModel(provider, model string) *catwalk.Model {
+	return s.cfg.GetModel(provider, model)
+}
+
+// GetProviderForModel returns the provider config for the given model
+// type, or nil.
+func (s *Service) GetProviderForModel(modelType SelectedModelType) *ProviderConfig {
+	return s.cfg.GetProviderForModel(modelType)
+}
+
+// GetModelByType returns the catwalk model for the given model type,
+// or nil.
+func (s *Service) GetModelByType(modelType SelectedModelType) *catwalk.Model {
+	return s.cfg.GetModelByType(modelType)
+}
+
+// LargeModel returns the catwalk model for the large model type.
+func (s *Service) LargeModel() *catwalk.Model {
+	return s.cfg.LargeModel()
+}
+
+// SmallModel returns the catwalk model for the small model type.
+func (s *Service) SmallModel() *catwalk.Model {
+	return s.cfg.SmallModel()
+}
+
+// Resolve resolves a variable value using the configured resolver.
+func (s *Service) Resolve(key string) (string, error) {
+	if s.resolver == nil {
+		return "", fmt.Errorf("no variable resolver configured")
+	}
+	return s.resolver.ResolveValue(key)
+}
+
+// Resolver returns the variable resolver.
+func (s *Service) Resolver() VariableResolver {
+	return s.resolver
+}
+
 // HasConfigField returns true if the given dotted key path exists in
 // the persisted config data.
 func (s *Service) HasConfigField(key string) bool {

internal/ui/dialog/api_key_input.go 🔗

@@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
 		Type:    m.provider.Type,
 		BaseURL: m.provider.APIEndpoint,
 	}
-	err := providerConfig.TestConnection(m.com.Config().Resolver())
+	err := providerConfig.TestConnection(m.com.ConfigService().Resolver())
 
 	// intentionally wait for at least 750ms to make sure the user sees the spinner
 	elapsed := time.Since(start)

internal/ui/dialog/commands.go 🔗

@@ -398,8 +398,8 @@ func (c *Commands) defaultCommands() []*CommandItem {
 	// Add reasoning toggle for models that support it
 	cfg := c.com.Config()
 	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
-		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
-		model := cfg.GetModelByType(agentCfg.Model)
+		providerCfg := c.com.ConfigService().GetProviderForModel(agentCfg.Model)
+		model := c.com.ConfigService().GetModelByType(agentCfg.Model)
 		if providerCfg != nil && model != nil && model.CanReason {
 			selectedModel := cfg.Models[agentCfg.Model]
 
@@ -427,7 +427,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
 	if c.sessionID != "" {
 		cfg := c.com.Config()
 		agentCfg := cfg.Agents[config.AgentCoder]
-		model := cfg.GetModelByType(agentCfg.Model)
+		model := c.com.ConfigService().GetModelByType(agentCfg.Model)
 		if model != nil && model.SupportsImages {
 			commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
 				// TODO: Pass in the file picker dialog id

internal/ui/dialog/filepicker.go 🔗

@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
 
 // WorkingDir returns the current working directory of the [FilePicker].
 func (f *FilePicker) WorkingDir() string {
-	wd := f.com.Config().WorkingDir()
+	wd := f.com.ConfigService().WorkingDir()
 	if len(wd) > 0 {
 		return wd
 	}

internal/ui/dialog/reasoning.go 🔗

@@ -227,7 +227,7 @@ func (r *Reasoning) setReasoningItems() error {
 	}
 
 	selectedModel := cfg.Models[agentCfg.Model]
-	model := cfg.GetModelByType(agentCfg.Model)
+	model := r.com.ConfigService().GetModelByType(agentCfg.Model)
 	if model == nil {
 		return errors.New("model configuration not found")
 	}

internal/ui/model/header.go 🔗

@@ -118,7 +118,7 @@ func renderHeaderDetails(
 	}
 
 	agentCfg := com.Config().Agents[config.AgentCoder]
-	model := com.Config().GetModelByType(agentCfg.Model)
+	model := com.ConfigService().GetModelByType(agentCfg.Model)
 	percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
 	formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
 	parts = append(parts, formattedPercentage)
@@ -135,8 +135,7 @@ func renderHeaderDetails(
 	metadata = dot + metadata
 
 	const dirTrimLimit = 4
-	cfg := com.Config()
-	cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
+	cwd := fsext.DirTrim(fsext.PrettyPath(com.ConfigService().WorkingDir()), dirTrimLimit)
 	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
 	cwd = t.Header.WorkingDir.Render(cwd)
 

internal/ui/model/landing.go 🔗

@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
 func (m *UI) landingView() string {
 	t := m.com.Styles
 	width := m.layout.main.Dx()
-	cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
+	cwd := common.PrettyPath(t, m.com.ConfigService().WorkingDir(), width)
 
 	parts := []string{
 		cwd,

internal/ui/model/onboarding.go 🔗

@@ -79,7 +79,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
 func (m *UI) initializeView() string {
 	cfg := m.com.Config()
 	s := m.com.Styles.Initialize
-	cwd := home.Short(cfg.WorkingDir())
+	cwd := home.Short(m.com.ConfigService().WorkingDir())
 	initFile := cfg.Options.InitializeAs
 
 	header := s.Header.Render("Would you like to initialize this project?")

internal/ui/model/sidebar.go 🔗

@@ -114,7 +114,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
 	height := area.Dy()
 
 	title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
-	cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
+	cwd := common.PrettyPath(t, m.com.ConfigService().WorkingDir(), width)
 	sidebarLogo := m.sidebarLogo
 	if height < logoHeightBreakpoint {
 		sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -140,7 +140,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
 
 	lspSection := m.lspInfo(width, maxLSPs, true)
 	mcpSection := m.mcpInfo(width, maxMCPs, true)
-	filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
+	filesSection := m.filesInfo(m.com.ConfigService().WorkingDir(), width, maxFiles, true)
 
 	uv.NewStyledString(
 		lipgloss.NewStyle().

internal/ui/model/ui.go 🔗

@@ -296,7 +296,7 @@ func New(com *common.Common) *UI {
 
 	desiredState := uiLanding
 	desiredFocus := uiFocusEditor
-	if !com.Config().IsConfigured() {
+	if !com.ConfigService().IsConfigured() {
 		desiredState = uiOnboarding
 	} else if n, _ := config.ProjectNeedsInitialization(com.Config()); n {
 		desiredState = uiInitialize
@@ -1920,7 +1920,7 @@ func (m *UI) View() tea.View {
 		v.BackgroundColor = m.com.Styles.Background
 	}
 	v.MouseMode = tea.MouseModeCellMotion
-	v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
+	v.WindowTitle = "crush " + home.Short(m.com.ConfigService().WorkingDir())
 
 	canvas := uv.NewScreenBuffer(m.width, m.height)
 	v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -3045,7 +3045,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
 
 	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
 	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
-	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
+	filesSection := m.filesInfo(m.com.ConfigService().WorkingDir(), sectionWidth, maxItemsPerSection, false)
 	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
 	uv.NewStyledString(
 		s.CompactDetails.View.