refactor: remove internal fields from Config, change csync.Map callers

Kujtim Hoxha created

Remove workingDir, resolver, and knownProviders from Config struct.
Remove WorkingDir(), Resolve(), and Resolver() methods from Config.
Change prompt.Build, MCP Initialize, commands, and init functions
to accept *Service instead of *Config. Config is now a pure data
struct with only read-only computed methods remaining.

🐾 Generated with Crush

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

Change summary

internal/agent/agentic_fetch_tool.go |  2 
internal/agent/common_test.go        |  2 
internal/agent/coordinator.go        |  4 +-
internal/agent/prompt/prompt.go      | 48 ++++++++++++++++-------------
internal/agent/prompts.go            |  4 +-
internal/agent/tools/mcp-tools.go    |  4 +-
internal/agent/tools/mcp/init.go     |  8 ++--
internal/agent/tools/mcp/prompts.go  |  2 
internal/agent/tools/mcp/tools.go    | 10 +++---
internal/app/app.go                  |  2 
internal/commands/commands.go        |  8 ++--
internal/config/config.go            | 21 -------------
internal/config/init.go              | 23 ++++++-------
internal/config/load.go              |  4 --
internal/config/load_test.go         |  1 
internal/ui/model/onboarding.go      |  6 +-
internal/ui/model/ui.go              |  6 +-
17 files changed, 66 insertions(+), 89 deletions(-)

Detailed changes

internal/agent/agentic_fetch_tool.go 🔗

@@ -151,7 +151,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
 			}
 
-			systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), *c.cfg)
+			systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), c.cfgSvc)
 			if err != nil {
 				return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
 			}

internal/agent/common_test.go 🔗

@@ -190,7 +190,7 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 	// would be included in prompt and break VCR cassette matching.
 	cfg.Config().LSP = nil
 
-	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg.Config())
+	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
 	if err != nil {
 		return nil, err
 	}

internal/agent/coordinator.go 🔗

@@ -375,7 +375,7 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
 	})
 
 	c.readyWg.Go(func() error {
-		systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
+		systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), c.cfgSvc)
 		if err != nil {
 			return err
 		}
@@ -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.cfgSvc.WorkingDir()) {
+	for _, tool := range tools.GetMCPTools(c.permissions, c.cfgSvc, c.cfgSvc.WorkingDir()) {
 		if agent.AllowedMCP == nil {
 			// No MCP restrictions
 			filteredTools = append(filteredTools, tool)

internal/agent/prompt/prompt.go 🔗

@@ -29,7 +29,7 @@ type Prompt struct {
 type PromptDat struct {
 	Provider      string
 	Model         string
-	Config        config.Config
+	Config        promptConfig
 	WorkingDir    string
 	IsGitRepo     bool
 	Platform      string
@@ -39,6 +39,10 @@ type PromptDat struct {
 	AvailSkillXML string
 }
 
+type promptConfig struct {
+	LSP config.LSPs
+}
+
 type ContextFile struct {
 	Path    string
 	Content string
@@ -76,13 +80,13 @@ func NewPrompt(name, promptTemplate string, opts ...Option) (*Prompt, error) {
 	return p, nil
 }
 
-func (p *Prompt) Build(ctx context.Context, provider, model string, cfg config.Config) (string, error) {
+func (p *Prompt) Build(ctx context.Context, provider, model string, svc *config.Service) (string, error) {
 	t, err := template.New(p.name).Parse(p.template)
 	if err != nil {
 		return "", fmt.Errorf("parsing template: %w", err)
 	}
 	var sb strings.Builder
-	d, err := p.promptData(ctx, provider, model, cfg)
+	d, err := p.promptData(ctx, provider, model, svc)
 	if err != nil {
 		return "", err
 	}
@@ -104,11 +108,11 @@ func processFile(filePath string) *ContextFile {
 	}
 }
 
-func processContextPath(p string, cfg config.Config) []ContextFile {
+func processContextPath(p string, workingDir string) []ContextFile {
 	var contexts []ContextFile
 	fullPath := p
 	if !filepath.IsAbs(p) {
-		fullPath = filepath.Join(cfg.WorkingDir(), p)
+		fullPath = filepath.Join(workingDir, p)
 	}
 	info, err := os.Stat(fullPath)
 	if err != nil {
@@ -136,51 +140,51 @@ func processContextPath(p string, cfg config.Config) []ContextFile {
 }
 
 // expandPath expands ~ and environment variables in file paths
-func expandPath(path string, cfg config.Config) string {
+func expandPath(path string, resolver config.VariableResolver) string {
 	path = home.Long(path)
-	// Handle environment variable expansion using the same pattern as config
 	if strings.HasPrefix(path, "$") {
-		if expanded, err := cfg.Resolver().ResolveValue(path); err == nil {
-			path = expanded
+		if resolver != nil {
+			if expanded, err := resolver.ResolveValue(path); err == nil {
+				path = expanded
+			}
 		}
 	}
 
 	return path
 }
 
-func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) {
-	workingDir := cmp.Or(p.workingDir, cfg.WorkingDir())
+func (p *Prompt) promptData(ctx context.Context, provider, model string, svc *config.Service) (PromptDat, error) {
+	workingDir := cmp.Or(p.workingDir, svc.WorkingDir())
 	platform := cmp.Or(p.platform, runtime.GOOS)
 
 	files := map[string][]ContextFile{}
 
-	for _, pth := range cfg.Options.ContextPaths {
-		expanded := expandPath(pth, cfg)
+	for _, pth := range svc.ContextPaths() {
+		expanded := expandPath(pth, svc.Resolver())
 		pathKey := strings.ToLower(expanded)
 		if _, ok := files[pathKey]; ok {
 			continue
 		}
-		content := processContextPath(expanded, cfg)
+		content := processContextPath(expanded, svc.WorkingDir())
 		files[pathKey] = content
 	}
 
-	// Discover and load skills metadata.
 	var availSkillXML string
-	if len(cfg.Options.SkillsPaths) > 0 {
-		expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
-		for _, pth := range cfg.Options.SkillsPaths {
-			expandedPaths = append(expandedPaths, expandPath(pth, cfg))
+	if len(svc.SkillsPaths()) > 0 {
+		expandedPaths := make([]string, 0, len(svc.SkillsPaths()))
+		for _, pth := range svc.SkillsPaths() {
+			expandedPaths = append(expandedPaths, expandPath(pth, svc.Resolver()))
 		}
 		if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
 			availSkillXML = skills.ToPromptXML(discoveredSkills)
 		}
 	}
 
-	isGit := isGitRepo(cfg.WorkingDir())
+	isGit := isGitRepo(svc.WorkingDir())
 	data := PromptDat{
 		Provider:      provider,
 		Model:         model,
-		Config:        cfg,
+		Config:        promptConfig{LSP: svc.LSP()},
 		WorkingDir:    filepath.ToSlash(workingDir),
 		IsGitRepo:     isGit,
 		Platform:      platform,
@@ -189,7 +193,7 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
 	}
 	if isGit {
 		var err error
-		data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir())
+		data.GitStatus, err = getGitStatus(ctx, svc.WorkingDir())
 		if err != nil {
 			return PromptDat{}, err
 		}

internal/agent/prompts.go 🔗

@@ -33,10 +33,10 @@ func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
 	return systemPrompt, nil
 }
 
-func InitializePrompt(cfg config.Config) (string, error) {
+func InitializePrompt(svc *config.Service) (string, error) {
 	systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
 	if err != nil {
 		return "", err
 	}
-	return systemPrompt.Build(context.Background(), "", "", cfg)
+	return systemPrompt.Build(context.Background(), "", "", svc)
 }

internal/agent/tools/mcp-tools.go 🔗

@@ -11,7 +11,7 @@ import (
 )
 
 // GetMCPTools gets all the currently available MCP tools.
-func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) []*Tool {
+func GetMCPTools(permissions permission.Service, cfg *config.Service, wd string) []*Tool {
 	var result []*Tool
 	for mcpName, tools := range mcp.Tools() {
 		for _, tool := range tools {
@@ -31,7 +31,7 @@ func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string)
 type Tool struct {
 	mcpName         string
 	tool            *mcp.Tool
-	cfg             *config.Config
+	cfg             *config.Service
 	permissions     permission.Service
 	workingDir      string
 	providerOptions fantasy.ProviderOptions

internal/agent/tools/mcp/init.go 🔗

@@ -134,11 +134,11 @@ func Close() error {
 }
 
 // Initialize initializes MCP clients based on the provided configuration.
-func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) {
+func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Service) {
 	slog.Info("Initializing MCP clients")
 	var wg sync.WaitGroup
 	// Initialize states for all configured MCPs
-	for name, m := range cfg.MCP {
+	for name, m := range cfg.MCP() {
 		if m.Disabled {
 			updateState(name, StateDisabled, nil, nil, Counts{})
 			slog.Debug("Skipping disabled MCP", "name", name)
@@ -214,13 +214,13 @@ func WaitForInit(ctx context.Context) error {
 	}
 }
 
-func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*mcp.ClientSession, error) {
+func getOrRenewClient(ctx context.Context, cfg *config.Service, name string) (*mcp.ClientSession, error) {
 	sess, ok := sessions.Get(name)
 	if !ok {
 		return nil, fmt.Errorf("mcp '%s' not available", name)
 	}
 
-	m := cfg.MCP[name]
+	m := cfg.MCP()[name]
 	state, _ := states.Get(name)
 
 	timeout := mcpTimeout(m)

internal/agent/tools/mcp/prompts.go 🔗

@@ -20,7 +20,7 @@ func Prompts() iter.Seq2[string, []*Prompt] {
 }
 
 // GetPromptMessages retrieves the content of an MCP prompt with the given arguments.
-func GetPromptMessages(ctx context.Context, cfg *config.Config, clientName, promptName string, args map[string]string) ([]string, error) {
+func GetPromptMessages(ctx context.Context, cfg *config.Service, clientName, promptName string, args map[string]string) ([]string, error) {
 	c, err := getOrRenewClient(ctx, cfg, clientName)
 	if err != nil {
 		return nil, err

internal/agent/tools/mcp/tools.go 🔗

@@ -32,7 +32,7 @@ func Tools() iter.Seq2[string, []*Tool] {
 }
 
 // RunTool runs an MCP tool with the given input parameters.
-func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, input string) (ToolResult, error) {
+func RunTool(ctx context.Context, cfg *config.Service, name, toolName string, input string) (ToolResult, error) {
 	var args map[string]any
 	if err := json.Unmarshal([]byte(input), &args); err != nil {
 		return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err)
@@ -108,7 +108,7 @@ func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, inp
 
 // RefreshTools gets the updated list of tools from the MCP and updates the
 // global state.
-func RefreshTools(ctx context.Context, cfg *config.Config, name string) {
+func RefreshTools(ctx context.Context, cfg *config.Service, name string) {
 	session, ok := sessions.Get(name)
 	if !ok {
 		slog.Warn("Refresh tools: no session", "name", name)
@@ -139,7 +139,7 @@ func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error)
 	return result.Tools, nil
 }
 
-func updateTools(cfg *config.Config, name string, tools []*Tool) int {
+func updateTools(cfg *config.Service, name string, tools []*Tool) int {
 	tools = filterDisabledTools(cfg, name, tools)
 	if len(tools) == 0 {
 		allTools.Del(name)
@@ -150,8 +150,8 @@ func updateTools(cfg *config.Config, name string, tools []*Tool) int {
 }
 
 // filterDisabledTools removes tools that are disabled via config.
-func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool {
-	mcpCfg, ok := cfg.MCP[mcpName]
+func filterDisabledTools(cfg *config.Service, mcpName string, tools []*Tool) []*Tool {
+	mcpCfg, ok := cfg.MCP()[mcpName]
 	if !ok || len(mcpCfg.DisabledTools) == 0 {
 		return tools
 	}

internal/app/app.go 🔗

@@ -110,7 +110,7 @@ func New(ctx context.Context, conn *sql.DB, cfgSvc *config.Service) (*App, error
 	// Check for updates in the background.
 	go app.checkForUpdates(ctx)
 
-	go mcp.Initialize(ctx, app.Permissions, cfg)
+	go mcp.Initialize(ctx, app.Permissions, cfgSvc)
 
 	// cleanup database upon app shutdown
 	app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close)

internal/commands/commands.go 🔗

@@ -53,7 +53,7 @@ type commandSource struct {
 
 // LoadCustomCommands loads custom commands from multiple sources including
 // XDG config directory, home directory, and project directory.
-func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
+func LoadCustomCommands(cfg *config.Service) ([]CustomCommand, error) {
 	return loadAll(buildCommandSources(cfg))
 }
 
@@ -89,7 +89,7 @@ func LoadMCPPrompts() ([]MCPPrompt, error) {
 	return commands, nil
 }
 
-func buildCommandSources(cfg *config.Config) []commandSource {
+func buildCommandSources(cfg *config.Service) []commandSource {
 	var sources []commandSource
 
 	// XDG config directory
@@ -110,7 +110,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 
 	// Project directory
 	sources = append(sources, commandSource{
-		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
+		path:   filepath.Join(cfg.DataDirectory(), "commands"),
 		prefix: projectCommandPrefix,
 	})
 
@@ -227,7 +227,7 @@ func isMarkdownFile(name string) bool {
 	return strings.HasSuffix(strings.ToLower(name), ".md")
 }
 
-func GetMCPPrompt(cfg *config.Config, clientID, promptID string, args map[string]string) (string, error) {
+func GetMCPPrompt(cfg *config.Service, clientID, promptID string, args map[string]string) (string, error) {
 	// TODO: we should pass the context down
 	result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args)
 	if err != nil {

internal/config/config.go 🔗

@@ -379,16 +379,6 @@ type Config struct {
 	Tools Tools `json:"tools,omitempty" jsonschema:"description=Tool configurations"`
 
 	Agents map[string]Agent `json:"-"`
-
-	// Internal
-	workingDir string `json:"-"`
-	// TODO: find a better way to do this this should probably not be part of the config
-	resolver       VariableResolver
-	knownProviders []catwalk.Provider `json:"-"`
-}
-
-func (c *Config) WorkingDir() string {
-	return c.workingDir
 }
 
 func (c *Config) EnabledProviders() []ProviderConfig {
@@ -452,13 +442,6 @@ func (c *Config) SmallModel() *catwalk.Model {
 	return c.GetModel(model.Provider, model.Model)
 }
 
-func (c *Config) Resolve(key string) (string, error) {
-	if c.resolver == nil {
-		return "", fmt.Errorf("no variable resolver configured")
-	}
-	return c.resolver.ResolveValue(key)
-}
-
 func allToolNames() []string {
 	return []string{
 		"agent",
@@ -509,10 +492,6 @@ func filterSlice(data []string, mask []string, include bool) []string {
 	return filtered
 }
 
-func (c *Config) Resolver() VariableResolver {
-	return c.resolver
-}
-
 func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 	testURL := ""
 	headers := make(map[string]string)

internal/config/init.go 🔗

@@ -26,12 +26,12 @@ func Init(workingDir, dataDir string, debug bool) (*Service, error) {
 	return svc, nil
 }
 
-func ProjectNeedsInitialization(cfg *Config) (bool, error) {
-	if cfg == nil {
+func ProjectNeedsInitialization(svc *Service) (bool, error) {
+	if svc == nil {
 		return false, fmt.Errorf("config not loaded")
 	}
 
-	flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
+	flagFilePath := filepath.Join(svc.DataDirectory(), InitFlagFilename)
 
 	_, err := os.Stat(flagFilePath)
 	if err == nil {
@@ -42,7 +42,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
 		return false, fmt.Errorf("failed to check init flag file: %w", err)
 	}
 
-	someContextFileExists, err := contextPathsExist(cfg.WorkingDir())
+	someContextFileExists, err := contextPathsExist(svc.WorkingDir())
 	if err != nil {
 		return false, fmt.Errorf("failed to check for context files: %w", err)
 	}
@@ -50,8 +50,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
 		return false, nil
 	}
 
-	// If the working directory has no non-ignored files, skip initialization step
-	empty, err := dirHasNoVisibleFiles(cfg.WorkingDir())
+	empty, err := dirHasNoVisibleFiles(svc.WorkingDir())
 	if err != nil {
 		return false, fmt.Errorf("failed to check if directory is empty: %w", err)
 	}
@@ -99,11 +98,11 @@ func dirHasNoVisibleFiles(dir string) (bool, error) {
 	return len(files) == 0, nil
 }
 
-func MarkProjectInitialized(cfg *Config) error {
-	if cfg == nil {
+func MarkProjectInitialized(svc *Service) error {
+	if svc == nil {
 		return fmt.Errorf("config not loaded")
 	}
-	flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
+	flagFilePath := filepath.Join(svc.DataDirectory(), InitFlagFilename)
 
 	file, err := os.Create(flagFilePath)
 	if err != nil {
@@ -114,13 +113,13 @@ func MarkProjectInitialized(cfg *Config) error {
 	return nil
 }
 
-func HasInitialDataConfig(cfg *Config) bool {
-	if cfg == nil {
+func HasInitialDataConfig(svc *Service) bool {
+	if svc == nil {
 		return false
 	}
 	cfgPath := GlobalConfigData()
 	if _, err := os.Stat(cfgPath); err != nil {
 		return false
 	}
-	return cfg.IsConfigured()
+	return svc.IsConfigured()
 }

internal/config/load.go 🔗

@@ -79,13 +79,10 @@ func Load(workingDir, dataDir string, debug bool) (*Service, error) {
 		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 := svc.configureProviders(env, valueResolver, svc.knownProviders); err != nil {
 		return nil, fmt.Errorf("failed to configure providers: %w", err)
 	}
@@ -344,7 +341,6 @@ func (s *Service) configureProviders(env env.Env, resolver VariableResolver, kno
 }
 
 func (c *Config) setDefaults(workingDir, dataDir string) {
-	c.workingDir = workingDir
 	if c.Options == nil {
 		c.Options = &Options{}
 	}

internal/config/load_test.go 🔗

@@ -57,7 +57,6 @@ func TestConfig_setDefaults(t *testing.T) {
 	for _, path := range defaultContextPaths {
 		require.Contains(t, cfg.Options.ContextPaths, path)
 	}
-	require.Equal(t, "/tmp", cfg.workingDir)
 }
 
 func TestConfig_configureProviders(t *testing.T) {

internal/ui/model/onboarding.go 🔗

@@ -19,7 +19,7 @@ import (
 // markProjectInitialized marks the current project as initialized in the config.
 func (m *UI) markProjectInitialized() tea.Msg {
 	// TODO: handle error so we show it in the tui footer
-	err := config.MarkProjectInitialized(m.com.ConfigService().Config())
+	err := config.MarkProjectInitialized(m.com.ConfigService())
 	if err != nil {
 		slog.Error(err.Error())
 	}
@@ -52,10 +52,10 @@ func (m *UI) initializeProject() tea.Cmd {
 	if cmd := m.newSession(); cmd != nil {
 		cmds = append(cmds, cmd)
 	}
-	cfg := m.com.ConfigService().Config()
+	svc := m.com.ConfigService()
 
 	initialize := func() tea.Msg {
-		initPrompt, err := agent.InitializePrompt(*cfg)
+		initPrompt, err := agent.InitializePrompt(svc)
 		if err != nil {
 			return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
 		}

internal/ui/model/ui.go 🔗

@@ -298,7 +298,7 @@ func New(com *common.Common) *UI {
 	desiredFocus := uiFocusEditor
 	if !com.ConfigService().IsConfigured() {
 		desiredState = uiOnboarding
-	} else if n, _ := config.ProjectNeedsInitialization(com.ConfigService().Config()); n {
+	} else if n, _ := config.ProjectNeedsInitialization(com.ConfigService()); n {
 		desiredState = uiInitialize
 	}
 
@@ -345,7 +345,7 @@ func (m *UI) setState(state uiState, focus uiFocusState) {
 // loadCustomCommands loads the custom commands asynchronously.
 func (m *UI) loadCustomCommands() tea.Cmd {
 	return func() tea.Msg {
-		customCommands, err := commands.LoadCustomCommands(m.com.ConfigService().Config())
+		customCommands, err := commands.LoadCustomCommands(m.com.ConfigService())
 		if err != nil {
 			slog.Error("Failed to load custom commands", "error", err)
 		}
@@ -3063,7 +3063,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
 
 func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
 	load := func() tea.Msg {
-		prompt, err := commands.GetMCPPrompt(m.com.ConfigService().Config(), clientID, promptID, arguments)
+		prompt, err := commands.GetMCPPrompt(m.com.ConfigService(), clientID, promptID, arguments)
 		if err != nil {
 			// TODO: make this better
 			return util.ReportError(err)()