refactor: rename Permissions.SkipRequests to Overrides().SkipPermissionRequests

Ayman Bagabas created

Change summary

internal/app/app.go           |   2 
internal/backend/backend.go   |   9 -
internal/cmd/root.go          |   5 
internal/cmd/server.go        |   2 
internal/config/config.go     |   3 
internal/config/load.go       |   9 ++
internal/config/scope.go      |  18 ++++
internal/config/store.go      |  38 +++++++-
internal/config/store_test.go | 152 +++++++++++++++++++++++++++++++++++++
9 files changed, 218 insertions(+), 20 deletions(-)

Detailed changes

internal/app/app.go 🔗

@@ -81,7 +81,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, er
 	messages := message.NewService(q)
 	files := history.NewService(q, conn)
 	cfg := store.Config()
-	skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
+	skipPermissionsRequests := store.Overrides().SkipPermissionRequests
 	var allowedTools []string
 	if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
 		allowedTools = cfg.Permissions.AllowedTools

internal/backend/backend.go 🔗

@@ -95,10 +95,7 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
 		return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
 	}
 
-	if cfg.Config().Permissions == nil {
-		cfg.Config().Permissions = &config.Permissions{}
-	}
-	cfg.Config().Permissions.SkipRequests = args.YOLO
+	cfg.Overrides().SkipPermissionRequests = args.YOLO
 
 	if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
 		return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
@@ -140,7 +137,7 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
 		Path:    args.Path,
 		DataDir: cfg.Config().Options.DataDirectory,
 		Debug:   cfg.Config().Options.Debug,
-		YOLO:    cfg.Config().Permissions.SkipRequests,
+		YOLO:    cfg.Overrides().SkipPermissionRequests,
 		Config:  cfg.Config(),
 		Env:     args.Env,
 	}
@@ -199,7 +196,7 @@ func workspaceToProto(ws *Workspace) proto.Workspace {
 	return proto.Workspace{
 		ID:      ws.ID,
 		Path:    ws.Path,
-		YOLO:    cfg.Permissions != nil && cfg.Permissions.SkipRequests,
+		YOLO:    ws.Cfg.Overrides().SkipPermissionRequests,
 		DataDir: cfg.Options.DataDirectory,
 		Debug:   cfg.Options.Debug,
 		Config:  cfg,

internal/cmd/root.go 🔗

@@ -240,11 +240,8 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
 		return nil, err
 	}
 
+	store.Overrides().SkipPermissionRequests = yolo
 	cfg := store.Config()
-	if cfg.Permissions == nil {
-		cfg.Permissions = &config.Permissions{}
-	}
-	cfg.Permissions.SkipRequests = yolo
 
 	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
 		return nil, err

internal/cmd/server.go 🔗

@@ -34,7 +34,7 @@ var serverCmd = &cobra.Command{
 			return fmt.Errorf("failed to get debug flag: %v", err)
 		}
 
-		cfg, err := config.Load("", dataDir, debug)
+		cfg, err := config.Load(config.GlobalWorkspaceDir(), dataDir, debug)
 		if err != nil {
 			return fmt.Errorf("failed to load configuration: %v", err)
 		}

internal/config/config.go 🔗

@@ -212,8 +212,7 @@ func (c Completions) Limits() (depth, items int) {
 }
 
 type Permissions struct {
-	AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"` // Tools that don't require permission prompts
-	SkipRequests bool     `json:"-"`                                                                                                                              // Automatically accept all permissions (YOLO mode)
+	AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
 }
 
 type TrailerStyle string

internal/config/load.go 🔗

@@ -792,6 +792,15 @@ func GlobalConfigData() string {
 	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 }
 
+// GlobalWorkspaceDir returns the path to the global server workspace
+// directory. This directory acts as a meta-workspace for the server
+// process, giving it a real workingDir so that config loading, scoped
+// writes, and provider resolution behave identically to project
+// workspaces.
+func GlobalWorkspaceDir() string {
+	return filepath.Dir(GlobalConfigData())
+}
+
 func assignIfNil[T any](ptr **T, val T) {
 	if *ptr == nil {
 		*ptr = &val

internal/config/scope.go 🔗

@@ -1,5 +1,7 @@
 package config
 
+import "fmt"
+
 // Scope determines which config file is targeted for read/write operations.
 type Scope int
 
@@ -9,3 +11,19 @@ const (
 	// ScopeWorkspace targets the workspace config (.crush/crush.json).
 	ScopeWorkspace
 )
+
+// String returns a human-readable label for the scope.
+func (s Scope) String() string {
+	switch s {
+	case ScopeGlobal:
+		return "global"
+	case ScopeWorkspace:
+		return "workspace"
+	default:
+		return fmt.Sprintf("Scope(%d)", int(s))
+	}
+}
+
+// ErrNoWorkspaceConfig is returned when a workspace-scoped write is
+// attempted on a ConfigStore that has no workspace config path.
+var ErrNoWorkspaceConfig = fmt.Errorf("no workspace config path configured")

internal/config/store.go 🔗

@@ -18,6 +18,13 @@ import (
 	"github.com/tidwall/sjson"
 )
 
+// RuntimeOverrides holds per-session settings that are never persisted to
+// disk. They are applied on top of the loaded Config and survive only for
+// the lifetime of the process (or workspace).
+type RuntimeOverrides struct {
+	SkipPermissionRequests bool
+}
+
 // ConfigStore is the single entry point for all config access. It owns the
 // pure-data Config, runtime state (working directory, resolver, known
 // providers), and persistence to both global and workspace config files.
@@ -28,6 +35,7 @@ type ConfigStore struct {
 	globalDataPath string // ~/.local/share/crush/crush.json
 	workspacePath  string // .crush/crush.json
 	knownProviders []catwalk.Provider
+	overrides      RuntimeOverrides
 }
 
 // Config returns the pure-data config struct (read-only after load).
@@ -63,20 +71,32 @@ func (s *ConfigStore) SetupAgents() {
 	s.config.SetupAgents()
 }
 
+// Overrides returns the runtime overrides for this store.
+func (s *ConfigStore) Overrides() *RuntimeOverrides {
+	return &s.overrides
+}
+
 // configPath returns the file path for the given scope.
-func (s *ConfigStore) configPath(scope Scope) string {
+func (s *ConfigStore) configPath(scope Scope) (string, error) {
 	switch scope {
 	case ScopeWorkspace:
-		return s.workspacePath
+		if s.workspacePath == "" {
+			return "", ErrNoWorkspaceConfig
+		}
+		return s.workspacePath, nil
 	default:
-		return s.globalDataPath
+		return s.globalDataPath, nil
 	}
 }
 
 // HasConfigField checks whether a key exists in the config file for the given
 // scope.
 func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
-	data, err := os.ReadFile(s.configPath(scope))
+	path, err := s.configPath(scope)
+	if err != nil {
+		return false
+	}
+	data, err := os.ReadFile(path)
 	if err != nil {
 		return false
 	}
@@ -85,7 +105,10 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
 
 // SetConfigField sets a key/value pair in the config file for the given scope.
 func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
-	path := s.configPath(scope)
+	path, err := s.configPath(scope)
+	if err != nil {
+		return fmt.Errorf("%s: %w", key, err)
+	}
 	data, err := os.ReadFile(path)
 	if err != nil {
 		if os.IsNotExist(err) {
@@ -110,7 +133,10 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
 
 // RemoveConfigField removes a key from the config file for the given scope.
 func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
-	path := s.configPath(scope)
+	path, err := s.configPath(scope)
+	if err != nil {
+		return fmt.Errorf("%s: %w", key, err)
+	}
 	data, err := os.ReadFile(path)
 	if err != nil {
 		return fmt.Errorf("failed to read config file: %w", err)

internal/config/store_test.go 🔗

@@ -0,0 +1,152 @@
+package config
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		globalDataPath: "/some/global/crush.json",
+	}
+
+	path, err := store.configPath(ScopeGlobal)
+	require.NoError(t, err)
+	require.Equal(t, "/some/global/crush.json", path)
+}
+
+func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		workspacePath: "/some/workspace/.crush/crush.json",
+	}
+
+	path, err := store.configPath(ScopeWorkspace)
+	require.NoError(t, err)
+	require.Equal(t, "/some/workspace/.crush/crush.json", path)
+}
+
+func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		globalDataPath: "/some/global/crush.json",
+		workspacePath:  "",
+	}
+
+	_, err := store.configPath(ScopeWorkspace)
+	require.Error(t, err)
+	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		config:         &Config{},
+		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+		workspacePath:  "",
+	}
+
+	err := store.SetConfigField(ScopeWorkspace, "foo", "bar")
+	require.Error(t, err)
+	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	globalPath := filepath.Join(dir, "crush.json")
+	store := &ConfigStore{
+		config:         &Config{},
+		globalDataPath: globalPath,
+	}
+
+	err := store.SetConfigField(ScopeGlobal, "foo", "bar")
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(globalPath)
+	require.NoError(t, err)
+	require.Contains(t, string(data), `"foo"`)
+}
+
+func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		config:         &Config{},
+		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+		workspacePath:  "",
+	}
+
+	err := store.RemoveConfigField(ScopeWorkspace, "foo")
+	require.Error(t, err)
+	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		config:         &Config{},
+		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+		workspacePath:  "",
+	}
+
+	has := store.HasConfigField(ScopeWorkspace, "foo")
+	require.False(t, has)
+}
+
+func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) {
+	t.Parallel()
+
+	store1 := &ConfigStore{config: &Config{}}
+	store2 := &ConfigStore{config: &Config{}}
+
+	require.False(t, store1.Overrides().SkipPermissionRequests)
+	require.False(t, store2.Overrides().SkipPermissionRequests)
+
+	store1.Overrides().SkipPermissionRequests = true
+
+	require.True(t, store1.Overrides().SkipPermissionRequests)
+	require.False(t, store2.Overrides().SkipPermissionRequests)
+}
+
+func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{config: &Config{}}
+	overrides := store.Overrides()
+
+	require.False(t, overrides.SkipPermissionRequests)
+
+	overrides.SkipPermissionRequests = true
+	require.True(t, store.Overrides().SkipPermissionRequests)
+}
+
+func TestGlobalWorkspaceDir(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("CRUSH_GLOBAL_DATA", dir)
+
+	wsDir := GlobalWorkspaceDir()
+	globalData := GlobalConfigData()
+
+	require.Equal(t, filepath.Dir(globalData), wsDir)
+	require.Equal(t, dir, wsDir)
+}
+
+func TestScope_String(t *testing.T) {
+	t.Parallel()
+
+	require.Equal(t, "global", ScopeGlobal.String())
+	require.Equal(t, "workspace", ScopeWorkspace.String())
+	require.Contains(t, Scope(99).String(), "Scope(99)")
+}