on main help sign in
refactor: rename Permissions.SkipRequests to Overrides().SkipPermissionRequests
64f6cdfAyman Bagabas
internal/app/app.go+1−1
@@ -81,7 +81,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, er
81 messages := message.NewService(q)
81 messages := message.NewService(q)
82 files := history.NewService(q, conn)
82 files := history.NewService(q, conn)
83 cfg := store.Config()
83 cfg := store.Config()
84− skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
84+ skipPermissionsRequests := store.Overrides().SkipPermissionRequests
85 var allowedTools []string
85 var allowedTools []string
86 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
86 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
87 allowedTools = cfg.Permissions.AllowedTools
87 allowedTools = cfg.Permissions.AllowedTools
internal/backend/backend.go+3−6
@@ -95,10 +95,7 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
95 return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
95 return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
96 }
96 }
97
97
98− if cfg.Config().Permissions == nil {
98+ cfg.Overrides().SkipPermissionRequests = args.YOLO
99− cfg.Config().Permissions = &config.Permissions{}
100− }
101− cfg.Config().Permissions.SkipRequests = args.YOLO
102
99
103 if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
100 if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
104 return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
101 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
140 Path: args.Path,
137 Path: args.Path,
141 DataDir: cfg.Config().Options.DataDirectory,
138 DataDir: cfg.Config().Options.DataDirectory,
142 Debug: cfg.Config().Options.Debug,
139 Debug: cfg.Config().Options.Debug,
143− YOLO: cfg.Config().Permissions.SkipRequests,
140+ YOLO: cfg.Overrides().SkipPermissionRequests,
144 Config: cfg.Config(),
141 Config: cfg.Config(),
145 Env: args.Env,
142 Env: args.Env,
146 }
143 }
@@ -199,7 +196,7 @@ func workspaceToProto(ws *Workspace) proto.Workspace {
199 return proto.Workspace{
196 return proto.Workspace{
200 ID: ws.ID,
197 ID: ws.ID,
201 Path: ws.Path,
198 Path: ws.Path,
202− YOLO: cfg.Permissions != nil && cfg.Permissions.SkipRequests,
199+ YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
203 DataDir: cfg.Options.DataDirectory,
200 DataDir: cfg.Options.DataDirectory,
204 Debug: cfg.Options.Debug,
201 Debug: cfg.Options.Debug,
205 Config: cfg,
202 Config: cfg,
internal/cmd/root.go+1−4
@@ -240,11 +240,8 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
240 return nil, err
240 return nil, err
241 }
241 }
242
242
243+ store.Overrides().SkipPermissionRequests = yolo
243 cfg := store.Config()
244 cfg := store.Config()
244− if cfg.Permissions == nil {
245− cfg.Permissions = &config.Permissions{}
246− }
247− cfg.Permissions.SkipRequests = yolo
248
245
249 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
246 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
250 return nil, err
247 return nil, err
internal/cmd/server.go+1−1
@@ -34,7 +34,7 @@ var serverCmd = &cobra.Command{
34 return fmt.Errorf("failed to get debug flag: %v", err)
34 return fmt.Errorf("failed to get debug flag: %v", err)
35 }
35 }
36
36
37− cfg, err := config.Load("", dataDir, debug)
37+ cfg, err := config.Load(config.GlobalWorkspaceDir(), dataDir, debug)
38 if err != nil {
38 if err != nil {
39 return fmt.Errorf("failed to load configuration: %v", err)
39 return fmt.Errorf("failed to load configuration: %v", err)
40 }
40 }
internal/config/config.go+1−2
@@ -212,8 +212,7 @@ func (c Completions) Limits() (depth, items int) {
212}
212}
213
213
214type Permissions struct {
214type Permissions struct {
215− 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
215+ AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
216− SkipRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
217}
216}
218
217
219type TrailerStyle string
218type TrailerStyle string
internal/config/load.go+9
@@ -792,6 +792,15 @@ func GlobalConfigData() string {
792 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
792 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
793}
793}
794
794
795+// GlobalWorkspaceDir returns the path to the global server workspace
796+// directory. This directory acts as a meta-workspace for the server
797+// process, giving it a real workingDir so that config loading, scoped
798+// writes, and provider resolution behave identically to project
799+// workspaces.
800+func GlobalWorkspaceDir() string {
801+ return filepath.Dir(GlobalConfigData())
802+}
803+
795func assignIfNil[T any](ptr **T, val T) {
804func assignIfNil[T any](ptr **T, val T) {
796 if *ptr == nil {
805 if *ptr == nil {
797 *ptr = &val
806 *ptr = &val
internal/config/scope.go+18
@@ -1,5 +1,7 @@
1package config
1package config
2
2
3+import "fmt"
4+
3// Scope determines which config file is targeted for read/write operations.
5// Scope determines which config file is targeted for read/write operations.
4type Scope int
6type Scope int
5
7
@@ -9,3 +11,19 @@ const (
9 // ScopeWorkspace targets the workspace config (.crush/crush.json).
11 // ScopeWorkspace targets the workspace config (.crush/crush.json).
10 ScopeWorkspace
12 ScopeWorkspace
11)
13)
14+
15+// String returns a human-readable label for the scope.
16+func (s Scope) String() string {
17+ switch s {
18+ case ScopeGlobal:
19+ return "global"
20+ case ScopeWorkspace:
21+ return "workspace"
22+ default:
23+ return fmt.Sprintf("Scope(%d)", int(s))
24+ }
25+}
26+
27+// ErrNoWorkspaceConfig is returned when a workspace-scoped write is
28+// attempted on a ConfigStore that has no workspace config path.
29+var ErrNoWorkspaceConfig = fmt.Errorf("no workspace config path configured")
internal/config/store.go+32−6
@@ -18,6 +18,13 @@ import (
18 "github.com/tidwall/sjson"
18 "github.com/tidwall/sjson"
19)
19)
20
20
21+// RuntimeOverrides holds per-session settings that are never persisted to
22+// disk. They are applied on top of the loaded Config and survive only for
23+// the lifetime of the process (or workspace).
24+type RuntimeOverrides struct {
25+ SkipPermissionRequests bool
26+}
27+
21// ConfigStore is the single entry point for all config access. It owns the
28// ConfigStore is the single entry point for all config access. It owns the
22// pure-data Config, runtime state (working directory, resolver, known
29// pure-data Config, runtime state (working directory, resolver, known
23// providers), and persistence to both global and workspace config files.
30// providers), and persistence to both global and workspace config files.
@@ -28,6 +35,7 @@ type ConfigStore struct {
28 globalDataPath string // ~/.local/share/crush/crush.json
35 globalDataPath string // ~/.local/share/crush/crush.json
29 workspacePath string // .crush/crush.json
36 workspacePath string // .crush/crush.json
30 knownProviders []catwalk.Provider
37 knownProviders []catwalk.Provider
38+ overrides RuntimeOverrides
31}
39}
32
40
33// Config returns the pure-data config struct (read-only after load).
41// Config returns the pure-data config struct (read-only after load).
@@ -63,20 +71,32 @@ func (s *ConfigStore) SetupAgents() {
63 s.config.SetupAgents()
71 s.config.SetupAgents()
64}
72}
65
73
74+// Overrides returns the runtime overrides for this store.
75+func (s *ConfigStore) Overrides() *RuntimeOverrides {
76+ return &s.overrides
77+}
78+
66// configPath returns the file path for the given scope.
79// configPath returns the file path for the given scope.
67−func (s *ConfigStore) configPath(scope Scope) string {
80+func (s *ConfigStore) configPath(scope Scope) (string, error) {
68 switch scope {
81 switch scope {
69 case ScopeWorkspace:
82 case ScopeWorkspace:
83+ if s.workspacePath == "" {
84+ return "", ErrNoWorkspaceConfig
85+ }
70− return s.workspacePath
86+ return s.workspacePath, nil
71 default:
87 default:
72− return s.globalDataPath
88+ return s.globalDataPath, nil
73 }
89 }
74}
90}
75
91
76// HasConfigField checks whether a key exists in the config file for the given
92// HasConfigField checks whether a key exists in the config file for the given
77// scope.
93// scope.
78func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
94func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
95+ path, err := s.configPath(scope)
96+ if err != nil {
97+ return false
98+ }
79− data, err := os.ReadFile(s.configPath(scope))
99+ data, err := os.ReadFile(path)
80 if err != nil {
100 if err != nil {
81 return false
101 return false
82 }
102 }
@@ -85,7 +105,10 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
85
105
86// SetConfigField sets a key/value pair in the config file for the given scope.
106// SetConfigField sets a key/value pair in the config file for the given scope.
87func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
107func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
88− path := s.configPath(scope)
108+ path, err := s.configPath(scope)
109+ if err != nil {
110+ return fmt.Errorf("%s: %w", key, err)
111+ }
89 data, err := os.ReadFile(path)
112 data, err := os.ReadFile(path)
90 if err != nil {
113 if err != nil {
91 if os.IsNotExist(err) {
114 if os.IsNotExist(err) {
@@ -110,7 +133,10 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
110
133
111// RemoveConfigField removes a key from the config file for the given scope.
134// RemoveConfigField removes a key from the config file for the given scope.
112func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
135func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
113− path := s.configPath(scope)
136+ path, err := s.configPath(scope)
137+ if err != nil {
138+ return fmt.Errorf("%s: %w", key, err)
139+ }
114 data, err := os.ReadFile(path)
140 data, err := os.ReadFile(path)
115 if err != nil {
141 if err != nil {
116 return fmt.Errorf("failed to read config file: %w", err)
142 return fmt.Errorf("failed to read config file: %w", err)
internal/config/store_test.gonew file+152
@@ -0,0 +1,152 @@
1+package config
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) {
13+ t.Parallel()
14+
15+ store := &ConfigStore{
16+ globalDataPath: "/some/global/crush.json",
17+ }
18+
19+ path, err := store.configPath(ScopeGlobal)
20+ require.NoError(t, err)
21+ require.Equal(t, "/some/global/crush.json", path)
22+}
23+
24+func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) {
25+ t.Parallel()
26+
27+ store := &ConfigStore{
28+ workspacePath: "/some/workspace/.crush/crush.json",
29+ }
30+
31+ path, err := store.configPath(ScopeWorkspace)
32+ require.NoError(t, err)
33+ require.Equal(t, "/some/workspace/.crush/crush.json", path)
34+}
35+
36+func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) {
37+ t.Parallel()
38+
39+ store := &ConfigStore{
40+ globalDataPath: "/some/global/crush.json",
41+ workspacePath: "",
42+ }
43+
44+ _, err := store.configPath(ScopeWorkspace)
45+ require.Error(t, err)
46+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
47+}
48+
49+func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) {
50+ t.Parallel()
51+
52+ store := &ConfigStore{
53+ config: &Config{},
54+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
55+ workspacePath: "",
56+ }
57+
58+ err := store.SetConfigField(ScopeWorkspace, "foo", "bar")
59+ require.Error(t, err)
60+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
61+}
62+
63+func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) {
64+ t.Parallel()
65+
66+ dir := t.TempDir()
67+ globalPath := filepath.Join(dir, "crush.json")
68+ store := &ConfigStore{
69+ config: &Config{},
70+ globalDataPath: globalPath,
71+ }
72+
73+ err := store.SetConfigField(ScopeGlobal, "foo", "bar")
74+ require.NoError(t, err)
75+
76+ data, err := os.ReadFile(globalPath)
77+ require.NoError(t, err)
78+ require.Contains(t, string(data), `"foo"`)
79+}
80+
81+func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) {
82+ t.Parallel()
83+
84+ store := &ConfigStore{
85+ config: &Config{},
86+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
87+ workspacePath: "",
88+ }
89+
90+ err := store.RemoveConfigField(ScopeWorkspace, "foo")
91+ require.Error(t, err)
92+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
93+}
94+
95+func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) {
96+ t.Parallel()
97+
98+ store := &ConfigStore{
99+ config: &Config{},
100+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
101+ workspacePath: "",
102+ }
103+
104+ has := store.HasConfigField(ScopeWorkspace, "foo")
105+ require.False(t, has)
106+}
107+
108+func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) {
109+ t.Parallel()
110+
111+ store1 := &ConfigStore{config: &Config{}}
112+ store2 := &ConfigStore{config: &Config{}}
113+
114+ require.False(t, store1.Overrides().SkipPermissionRequests)
115+ require.False(t, store2.Overrides().SkipPermissionRequests)
116+
117+ store1.Overrides().SkipPermissionRequests = true
118+
119+ require.True(t, store1.Overrides().SkipPermissionRequests)
120+ require.False(t, store2.Overrides().SkipPermissionRequests)
121+}
122+
123+func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) {
124+ t.Parallel()
125+
126+ store := &ConfigStore{config: &Config{}}
127+ overrides := store.Overrides()
128+
129+ require.False(t, overrides.SkipPermissionRequests)
130+
131+ overrides.SkipPermissionRequests = true
132+ require.True(t, store.Overrides().SkipPermissionRequests)
133+}
134+
135+func TestGlobalWorkspaceDir(t *testing.T) {
136+ dir := t.TempDir()
137+ t.Setenv("CRUSH_GLOBAL_DATA", dir)
138+
139+ wsDir := GlobalWorkspaceDir()
140+ globalData := GlobalConfigData()
141+
142+ require.Equal(t, filepath.Dir(globalData), wsDir)
143+ require.Equal(t, dir, wsDir)
144+}
145+
146+func TestScope_String(t *testing.T) {
147+ t.Parallel()
148+
149+ require.Equal(t, "global", ScopeGlobal.String())
150+ require.Equal(t, "workspace", ScopeWorkspace.String())
151+ require.Contains(t, Scope(99).String(), "Scope(99)")
152+}