Detailed changes
@@ -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
@@ -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,
@@ -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
@@ -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)
}
@@ -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
@@ -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
@@ -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")
@@ -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)
@@ -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)")
+}