Skip to content

Commit 027a8f2

Browse files
smallnestsmallnest
andauthored
fix(config): use ~/.config on macOS and enter setup when no provider (#371) (#372)
On macOS, DefaultUserConfigPath used Go's os.UserConfigDir which returns ~/Library/Application Support, while users (and Claude Code) place config at ~/.config/zero/config.json. The user config was never read, leaving activeProvider empty and the launch failing with `active provider "" not found` on a fresh `go run cmd/zero setup`. - Add config.UserConfigDir(): on darwin, resolve to ~/.config (honoring XDG_CONFIG_HOME), matching Linux and Claude Code. DefaultUserConfigPath, the TUI user-commands dir, and specialist manifests all route through it. - Add config.ErrNoActiveProvider sentinel; the interactive TUI now drops into the setup wizard when resolve fails solely due to a missing/ unresolvable active provider, instead of exiting fatally. Other resolve errors (malformed JSON, directory conflicts) remain fatal. Note: `zero setup`/`zero auth` already MkdirAll the config dir before writing, so the directory is auto-created on first write. Co-authored-by: smallnest <smallnest@gmail>
1 parent bd89a1f commit 027a8f2

9 files changed

Lines changed: 142 additions & 8 deletions

File tree

‎internal/cli/app.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
530530

531531
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
532532
if err != nil {
533-
return writeAppError(stderr, err.Error(), 1)
533+
// A resolve failure caused solely by a missing/unresolvable active provider
534+
// is not fatal for the interactive TUI: drop into the setup wizard with an
535+
// empty config so the user can onboard, instead of exiting with an error.
536+
// Any other error (malformed JSON, directory conflict, etc.) is still fatal.
537+
if !errors.Is(err, config.ErrNoActiveProvider) {
538+
return writeAppError(stderr, err.Error(), 1)
539+
}
540+
resolved = config.ResolvedConfig{}
541+
forceSetup = true
534542
}
535543
userConfigPath, err := deps.userConfigPath()
536544
if err != nil {

‎internal/cli/app_test.go‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"errors"
8+
"fmt"
89
"io"
910
"os"
1011
"path/filepath"
@@ -157,6 +158,80 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
157158
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
158159
}
159160

161+
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
162+
var stdout bytes.Buffer
163+
var stderr bytes.Buffer
164+
cwd := t.TempDir()
165+
setCLIUserConfigRoot(t)
166+
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
167+
var launchedOptions tui.Options
168+
launched := false
169+
170+
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
171+
getwd: func() (string, error) {
172+
return cwd, nil
173+
},
174+
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
175+
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
176+
},
177+
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
178+
t.Fatal("newProvider should not be called without a resolved provider")
179+
return nil, nil
180+
},
181+
userConfigPath: func() (string, error) {
182+
return userConfigPath, nil
183+
},
184+
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
185+
return noopMCPRuntime{}, nil
186+
},
187+
runTUI: func(ctx context.Context, options tui.Options) int {
188+
launched = true
189+
launchedOptions = options
190+
return 0
191+
},
192+
})
193+
194+
if exitCode != 0 {
195+
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
196+
}
197+
if !launched {
198+
t.Fatal("TUI was not launched; expected fallback into setup instead of a fatal error")
199+
}
200+
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
201+
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
202+
}
203+
if launchedOptions.Provider != nil {
204+
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
205+
}
206+
}
207+
208+
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
209+
var stdout bytes.Buffer
210+
var stderr bytes.Buffer
211+
cwd := t.TempDir()
212+
setCLIUserConfigRoot(t)
213+
214+
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
215+
getwd: func() (string, error) {
216+
return cwd, nil
217+
},
218+
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
219+
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON")
220+
},
221+
runTUI: func(ctx context.Context, options tui.Options) int {
222+
t.Fatal("TUI must not launch on a non-provider resolve error")
223+
return 0
224+
},
225+
})
226+
227+
if exitCode == 0 {
228+
t.Fatal("exit code = 0, want non-zero for fatal config error")
229+
}
230+
if !strings.Contains(stderr.String(), "invalid config JSON") {
231+
t.Fatalf("stderr = %q, want the underlying config error", stderr.String())
232+
}
233+
}
234+
160235
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
161236
var stdout bytes.Buffer
162237
var stderr bytes.Buffer

‎internal/cli/specialist_test.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"runtime"
99
"strings"
1010
"testing"
11+
12+
"github.com/Gitlawb/zero/internal/config"
1113
)
1214

1315
func TestRunSpecialistListShowAndPath(t *testing.T) {
@@ -326,7 +328,7 @@ func setSpecialistConfigRoot(t *testing.T) string {
326328
default:
327329
t.Setenv("XDG_CONFIG_HOME", root)
328330
}
329-
configRoot, err := os.UserConfigDir()
331+
configRoot, err := config.UserConfigDir()
330332
if err != nil {
331333
t.Fatalf("UserConfigDir() error = %v", err)
332334
}

‎internal/config/paths.go‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"runtime"
78
"strings"
89
)
910

@@ -33,13 +34,32 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
3334
}
3435

3536
func DefaultUserConfigPath() (string, error) {
36-
userConfigDir, err := os.UserConfigDir()
37+
userConfigDir, err := UserConfigDir()
3738
if err != nil {
3839
return "", fmt.Errorf("resolve user config directory: %w", err)
3940
}
4041
return filepath.Join(userConfigDir, "zero", "config.json"), nil
4142
}
4243

44+
// UserConfigDir returns the base directory Zero stores per-user config under.
45+
// It mirrors os.UserConfigDir everywhere except macOS: there Go defaults to
46+
// ~/Library/Application Support, but Zero deliberately uses ~/.config (XDG-style,
47+
// matching Linux and Claude Code) so a single config path works cross-platform.
48+
// $XDG_CONFIG_HOME still wins on macOS when set.
49+
func UserConfigDir() (string, error) {
50+
if runtime.GOOS == "darwin" {
51+
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
52+
return xdg, nil
53+
}
54+
home, err := os.UserHomeDir()
55+
if err != nil {
56+
return "", fmt.Errorf("resolve home directory: %w", err)
57+
}
58+
return filepath.Join(home, ".config"), nil
59+
}
60+
return os.UserConfigDir()
61+
}
62+
4363
func existingConfigFile(path string) (string, error) {
4464
info, err := os.Stat(path)
4565
if err != nil {

‎internal/config/paths_test.go‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func setUserConfigRoot(t *testing.T) string {
105105
t.Setenv("XDG_CONFIG_HOME", root)
106106
}
107107

108-
configRoot, err := os.UserConfigDir()
108+
configRoot, err := UserConfigDir()
109109
if err != nil {
110110
t.Fatalf("UserConfigDir() error = %v", err)
111111
}
@@ -130,3 +130,21 @@ func mkdirAll(t *testing.T, path string) {
130130
t.Fatalf("create directory %s: %v", path, err)
131131
}
132132
}
133+
134+
func TestDefaultUserConfigPathUsesXDGConfigOnMacOS(t *testing.T) {
135+
if runtime.GOOS != "darwin" {
136+
t.Skip("macOS-specific config path behavior")
137+
}
138+
home := t.TempDir()
139+
t.Setenv("HOME", home)
140+
t.Setenv("XDG_CONFIG_HOME", "")
141+
142+
path, err := DefaultUserConfigPath()
143+
if err != nil {
144+
t.Fatalf("DefaultUserConfigPath() error = %v", err)
145+
}
146+
want := filepath.Join(home, ".config", "zero", "config.json")
147+
if path != want {
148+
t.Fatalf("DefaultUserConfigPath() = %q, want %q", path, want)
149+
}
150+
}

‎internal/config/resolver.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"net/url"
78
"os"
@@ -14,6 +15,11 @@ import (
1415
"github.com/Gitlawb/zero/internal/sandbox"
1516
)
1617

18+
// ErrNoActiveProvider marks a resolve failure caused solely by a missing or
19+
// unresolvable active provider. The interactive TUI treats this as "needs
20+
// onboarding" (drop into the setup wizard) rather than a fatal config error.
21+
var ErrNoActiveProvider = errors.New("no active provider configured")
22+
1723
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
1824
// too low for real multi-step agentic work (agents ran out mid-task before reaching
1925
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
@@ -750,7 +756,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
750756
}
751757
if len(providers) == 0 {
752758
if activeName != "" {
753-
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
759+
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
754760
}
755761
return []ProviderProfile{}, ProviderProfile{}, nil
756762
}
@@ -782,7 +788,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
782788
}
783789

784790
if !activeFound {
785-
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
791+
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
786792
}
787793
if active.Model == "" {
788794
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)

‎internal/config/resolver_test.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"errors"
45
"os"
56
"path/filepath"
67
"reflect"
@@ -898,6 +899,9 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
898899
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
899900
t.Fatalf("error = %q, want active provider missing message", err.Error())
900901
}
902+
if !errors.Is(err, ErrNoActiveProvider) {
903+
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
904+
}
901905
if resolved.ActiveProvider != "" {
902906
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
903907
}

‎internal/specialist/manifest.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"time"
1313

14+
"github.com/Gitlawb/zero/internal/config"
1415
"github.com/Gitlawb/zero/internal/modelregistry"
1516
)
1617

@@ -120,7 +121,7 @@ var knownToolNames = map[string]bool{
120121
}
121122

122123
func DefaultPaths(workspaceRoot string) (Paths, error) {
123-
userConfigDir, err := os.UserConfigDir()
124+
userConfigDir, err := config.UserConfigDir()
124125
if err != nil {
125126
return Paths{}, fmt.Errorf("resolve user config directory: %w", err)
126127
}

‎internal/tui/model.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,7 @@ func newModel(ctx context.Context, options Options) model {
635635
}
636636
}
637637

638-
userConfigDir, _ := os.UserConfigDir()
638+
userConfigDir, _ := config.UserConfigDir()
639639
loadedUserCommands := usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))
640640

641641
registry := options.Registry

0 commit comments

Comments
 (0)