on main help sign in
Merge branch 'main' into ui
commit6f38a7935b31abf7e528e653392b6f1021e54bfb authorAyman Bagabas <ayman.bagabas@gmail.com> dateWed 10 Dec 2025, 13:02 (1 year ago) parentb25730dee5e50a
11 files changed+249−153
.github/cla-signatures.json+8 go.mod+2−2 go.sum+2−2 internal/agent/coordinator.go+67−24 internal/cmd/run.go+7−2 internal/cmd/update_providers.go+3−3 internal/config/config.go+5−2 internal/config/load.go+1−22 internal/config/provider.go+56−53 internal/config/provider_empty_test.go+7−23 internal/config/provider_test.go+91−20
.github/cla-signatures.json+8
@@ -935,6 +935,14 @@
935935 "created_at": "2025-12-06T18:13:11Z",
936936 "repoId": 987670088,
937937 "pullRequestNo": 1560
938+ },
939+ {
940+ "name": "mengwong",
941+ "id": 1480631,
942+ "comment_id": 3636765109,
943+ "created_at": "2025-12-10T12:04:50Z",
944+ "repoId": 987670088,
945+ "pullRequestNo": 1592
938946 }
939947 ]
940948}
go.mod+2−2
@@ -1,6 +1,6 @@
11module github.com/charmbracelet/crush
22
3−go 1.25.0
3+go 1.25.5
44
55require (
66 charm.land/bubbles/v2 v2.0.0-rc.1
@@ -16,7 +16,7 @@ require (
1616 github.com/aymanbagabas/go-udiff v0.3.1
1717 github.com/bmatcuk/doublestar/v4 v4.9.1
1818 github.com/charlievieth/fastwalk v1.0.14
19− github.com/charmbracelet/catwalk v0.9.5
19+ github.com/charmbracelet/catwalk v0.9.7-0.20251208190755-350e2a004c74
2020 github.com/charmbracelet/colorprofile v0.3.3
2121 github.com/charmbracelet/fang v0.4.4
2222 github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
go.sum+2−2
@@ -88,8 +88,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
8888github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
8989github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
9090github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
91−github.com/charmbracelet/catwalk v0.9.5 h1:QLqajLJfjGTVh2MIVIdAhww2XvPklxmu+p0Z4wrT7KU=
92−github.com/charmbracelet/catwalk v0.9.5/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
91+github.com/charmbracelet/catwalk v0.9.7-0.20251208190755-350e2a004c74 h1:pJI8ivIgSWOeCNnZFXeZzL6px1vZVS3LU4Cqk3Gx37I=
92+github.com/charmbracelet/catwalk v0.9.7-0.20251208190755-350e2a004c74/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
9393github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
9494github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
9595github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
internal/agent/coordinator.go+67−24
@@ -10,6 +10,7 @@ import (
1010 "io"
1111 "log/slog"
1212 "maps"
13+ "net/http"
1314 "os"
1415 "slices"
1516 "strings"
@@ -130,32 +131,42 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
130131
131132 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
132133
133− if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
134− slog.Info("Detected expired OAuth token, attempting refresh", "provider", providerCfg.ID)
135− if refreshErr := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); refreshErr != nil {
136− slog.Error("Failed to refresh OAuth token", "provider", providerCfg.ID, "error", refreshErr)
137− return nil, refreshErr
138− }
139−
140− // Rebuild models with refreshed token
141− if updateErr := c.UpdateModels(ctx); updateErr != nil {
142− slog.Error("Failed to update models after token refresh", "error", updateErr)
143− return nil, updateErr
134+ run := func() (*fantasy.AgentResult, error) {
135+ return c.currentAgent.Run(ctx, SessionAgentCall{
136+ SessionID: sessionID,
137+ Prompt: prompt,
138+ Attachments: attachments,
139+ MaxOutputTokens: maxTokens,
140+ ProviderOptions: mergedOptions,
141+ Temperature: temp,
142+ TopP: topP,
143+ TopK: topK,
144+ FrequencyPenalty: freqPenalty,
145+ PresencePenalty: presPenalty,
146+ })
147+ }
148+ result, originalErr := run()
149+
150+ if c.isUnauthorized(originalErr) {
151+ switch {
152+ case providerCfg.OAuthToken != nil:
153+ slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
154+ if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
155+ return nil, originalErr
156+ }
157+ slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
158+ return run()
159+ case strings.Contains(providerCfg.APIKeyTemplate, "$"):
160+ slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
161+ if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
162+ return nil, originalErr
163+ }
164+ slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
165+ return run()
144166 }
145167 }
146− result, err := c.currentAgent.Run(ctx, SessionAgentCall{
147− SessionID: sessionID,
148− Prompt: prompt,
149− Attachments: attachments,
150− MaxOutputTokens: maxTokens,
151− ProviderOptions: mergedOptions,
152− Temperature: temp,
153− TopP: topP,
154− TopK: topK,
155− FrequencyPenalty: freqPenalty,
156− PresencePenalty: presPenalty,
157− })
158− return result, err
168+
169+ return result, originalErr
159170}
160171
161172func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
@@ -773,3 +784,35 @@ func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
773784 }
774785 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
775786}
787+
788+func (c *coordinator) isUnauthorized(err error) bool {
789+ var providerErr *fantasy.ProviderError
790+ return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
791+}
792+
793+func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
794+ if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
795+ slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
796+ return err
797+ }
798+ if err := c.UpdateModels(ctx); err != nil {
799+ return err
800+ }
801+ return nil
802+}
803+
804+func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
805+ newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
806+ if err != nil {
807+ slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
808+ return err
809+ }
810+
811+ providerCfg.APIKey = newAPIKey
812+ c.cfg.Providers.Set(providerCfg.ID, providerCfg)
813+
814+ if err := c.UpdateModels(ctx); err != nil {
815+ return err
816+ }
817+ return nil
818+}
internal/cmd/run.go+7−2
@@ -1,9 +1,11 @@
11package cmd
22
33import (
4+ "context"
45 "fmt"
56 "log/slog"
67 "os"
8+ "os/signal"
79 "strings"
810
911 "github.com/spf13/cobra"
@@ -30,6 +32,10 @@ crush run --quiet "Generate a README for this project"
3032 RunE: func(cmd *cobra.Command, args []string) error {
3133 quiet, _ := cmd.Flags().GetBool("quiet")
3234
35+ // Cancel on SIGINT or SIGTERM.
36+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
37+ defer cancel()
38+
3339 app, err := setupApp(cmd)
3440 if err != nil {
3541 return err
@@ -57,8 +63,7 @@ crush run --quiet "Generate a README for this project"
5763 // crush run "Do something fancy" > output.txt
5864 // echo "Do something fancy" | crush run > output.txt
5965 //
60− // TODO: We currently need to press ^c twice to cancel. Fix that.
61− return app.RunNonInteractive(cmd.Context(), os.Stdout, prompt, quiet)
66+ return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
6267 },
6368}
6469
internal/cmd/update_providers.go+3−3
@@ -31,12 +31,12 @@ crush update-providers embedded
3131 // NOTE(@andreynering): We want to skip logging output do stdout here.
3232 slog.SetDefault(slog.New(slog.DiscardHandler))
3333
34− var pathOrUrl string
34+ var pathOrURL string
3535 if len(args) > 0 {
36− pathOrUrl = args[0]
36+ pathOrURL = args[0]
3737 }
3838
39− if err := config.UpdateProviders(pathOrUrl); err != nil {
39+ if err := config.UpdateProviders(pathOrURL); err != nil {
4040 return err
4141 }
4242
internal/config/config.go+5−2
@@ -95,6 +95,8 @@ type ProviderConfig struct {
9595 Type catwalk.Type `json:"type,omitempty" jsonschema:"description=Provider type that determines the API format,enum=openai,enum=openai-compat,enum=anthropic,enum=gemini,enum=azure,enum=vertexai,default=openai"`
9696 // The provider's API key.
9797 APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
98+ // The original API key template before resolution (for re-resolution on auth errors).
99+ APIKeyTemplate string `json:"-"`
98100 // OAuthToken for providers that use OAuth2 authentication.
99101 OAuthToken *oauth.Token `json:"oauth,omitempty" jsonschema:"description=OAuth2 token for authentication with the provider"`
100102 // Marks the provider as disabled.
@@ -469,6 +471,7 @@ func (c *Config) SetConfigField(key string, value any) error {
469471 return nil
470472}
471473
474+// RefreshOAuthToken refreshes the OAuth token for the given provider.
472475func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
473476 providerConfig, exists := c.Providers.Get(providerID)
474477 if !exists {
@@ -479,7 +482,7 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
479482 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
480483 }
481484
482− // Only Anthropic provider uses OAuth for now
485+ // Only Anthropic provider uses OAuth for now.
483486 if providerID != string(catwalk.InferenceProviderAnthropic) {
484487 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
485488 }
@@ -489,7 +492,7 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
489492 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
490493 }
491494
492− slog.Info("Successfully refreshed OAuth token in background", "provider", providerID)
495+ slog.Info("Successfully refreshed OAuth token", "provider", providerID)
493496 providerConfig.OAuthToken = newToken
494497 providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
495498 providerConfig.SetupClaudeCode()
internal/config/load.go+1−22
@@ -1,7 +1,6 @@
11package config
22
33import (
4− "cmp"
54 "context"
65 "encoding/json"
76 "fmt"
@@ -19,11 +18,9 @@ import (
1918 "github.com/charmbracelet/catwalk/pkg/catwalk"
2019 "github.com/charmbracelet/crush/internal/csync"
2120 "github.com/charmbracelet/crush/internal/env"
22− "github.com/charmbracelet/crush/internal/event"
2321 "github.com/charmbracelet/crush/internal/fsext"
2422 "github.com/charmbracelet/crush/internal/home"
2523 "github.com/charmbracelet/crush/internal/log"
26− "github.com/charmbracelet/crush/internal/oauth/claude"
2724 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
2825)
2926
@@ -189,6 +186,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
189186 Name: p.Name,
190187 BaseURL: p.APIEndpoint,
191188 APIKey: p.APIKey,
189+ APIKeyTemplate: p.APIKey, // Store original template for re-resolution
192190 OAuthToken: config.OAuthToken,
193191 Type: p.Type,
194192 Disable: config.Disable,
@@ -200,25 +198,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
200198 }
201199
202200 if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
203− if config.OAuthToken.IsExpired() {
204− newToken, err := claude.RefreshToken(context.TODO(), config.OAuthToken.RefreshToken)
205− if err == nil {
206− slog.Info("Successfully refreshed Anthropic OAuth token")
207− config.OAuthToken = newToken
208− prepared.OAuthToken = newToken
209− if err := cmp.Or(
210− c.SetConfigField("providers.anthropic.api_key", newToken.AccessToken),
211− c.SetConfigField("providers.anthropic.oauth", newToken),
212− ); err != nil {
213− return err
214− }
215− } else {
216− slog.Error("Failed to refresh Anthropic OAuth token", "error", err)
217− event.Error(err)
218− }
219− } else {
220− slog.Info("Using existing non-expired Anthropic OAuth token")
221− }
222201 prepared.SetupClaudeCode()
223202 }
224203
internal/config/provider.go+56−53
@@ -2,7 +2,9 @@ package config
22
33import (
44 "cmp"
5+ "context"
56 "encoding/json"
7+ "errors"
68 "fmt"
79 "log/slog"
810 "os"
@@ -17,7 +19,7 @@ import (
1719)
1820
1921type ProviderClient interface {
20− GetProviders() ([]catwalk.Provider, error)
22+ GetProviders(context.Context, string) ([]catwalk.Provider, error)
2123}
2224
2325var (
@@ -53,7 +55,7 @@ func saveProvidersInCache(path string, providers []catwalk.Provider) error {
5355 return fmt.Errorf("failed to create directory for provider cache: %w", err)
5456 }
5557
56− data, err := json.MarshalIndent(providers, "", " ")
58+ data, err := json.Marshal(providers)
5759 if err != nil {
5860 return fmt.Errorf("failed to marshal provider data: %w", err)
5961 }
@@ -64,34 +66,35 @@ func saveProvidersInCache(path string, providers []catwalk.Provider) error {
6466 return nil
6567}
6668
67−func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
69+func loadProvidersFromCache(path string) ([]catwalk.Provider, string, error) {
6870 data, err := os.ReadFile(path)
6971 if err != nil {
70− return nil, fmt.Errorf("failed to read provider cache file: %w", err)
72+ return nil, "", fmt.Errorf("failed to read provider cache file: %w", err)
7173 }
7274
7375 var providers []catwalk.Provider
7476 if err := json.Unmarshal(data, &providers); err != nil {
75− return nil, fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
77+ return nil, "", fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
7678 }
77− return providers, nil
79+
80+ return providers, catwalk.Etag(data), nil
7881}
7982
80−func UpdateProviders(pathOrUrl string) error {
83+func UpdateProviders(pathOrURL string) error {
8184 var providers []catwalk.Provider
82− pathOrUrl = cmp.Or(pathOrUrl, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
85+ pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
8386
8487 switch {
85− case pathOrUrl == "embedded":
88+ case pathOrURL == "embedded":
8689 providers = embedded.GetAll()
87− case strings.HasPrefix(pathOrUrl, "http://") || strings.HasPrefix(pathOrUrl, "https://"):
90+ case strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://"):
8891 var err error
89− providers, err = catwalk.NewWithURL(pathOrUrl).GetProviders()
92+ providers, err = catwalk.NewWithURL(pathOrURL).GetProviders(context.Background(), "")
9093 if err != nil {
9194 return fmt.Errorf("failed to fetch providers from Catwalk: %w", err)
9295 }
9396 default:
94− content, err := os.ReadFile(pathOrUrl)
97+ content, err := os.ReadFile(pathOrURL)
9598 if err != nil {
9699 return fmt.Errorf("failed to read file: %w", err)
97100 }
@@ -108,61 +111,61 @@ func UpdateProviders(pathOrUrl string) error {
108111 return fmt.Errorf("failed to save providers to cache: %w", err)
109112 }
110113
111− slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrUrl, "to", cachePath)
114+ slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrURL, "to", cachePath)
112115 return nil
113116}
114117
118+// Providers returns the list of providers, taking into account cached results
119+// and whether or not auto update is enabled.
120+//
121+// It will:
122+// 1. if auto update is disabled, it'll return the embedded providers at the
123+// time of release.
124+// 2. load the cached providers
125+// 3. try to get the fresh list of providers, and return either this new list,
126+// the cached list, or the embedded list if all others fail.
115127func Providers(cfg *Config) ([]catwalk.Provider, error) {
116128 providerOnce.Do(func() {
117129 catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
118130 client := catwalk.NewWithURL(catwalkURL)
119131 path := providerCacheFileData()
120132
121− autoUpdateDisabled := cfg.Options.DisableProviderAutoUpdate
122− providerList, providerErr = loadProviders(autoUpdateDisabled, client, path)
123− })
124− return providerList, providerErr
125−}
126−
127−func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
128− catwalkGetAndSave := func() ([]catwalk.Provider, error) {
129− providers, err := client.GetProviders()
130− if err != nil {
131− return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
133+ if cfg.Options.DisableProviderAutoUpdate {
134+ slog.Info("Using embedded Catwalk providers")
135+ providerList, providerErr = embedded.GetAll(), nil
136+ return
132137 }
133− if len(providers) == 0 {
134− return nil, fmt.Errorf("empty providers list from catwalk")
135− }
136− if err := saveProvidersInCache(path, providers); err != nil {
137− return nil, err
138− }
139− return providers, nil
140− }
141−
142− switch {
143− case autoUpdateDisabled:
144− slog.Warn("Providers auto-update is disabled")
145138
146− if _, err := os.Stat(path); err == nil {
147− slog.Warn("Using locally cached providers")
148− return loadProvidersFromCache(path)
139+ cached, etag, cachedErr := loadProvidersFromCache(path)
140+ if len(cached) == 0 || cachedErr != nil {
141+ // if cached file is empty, default to embedded providers
142+ cached = embedded.GetAll()
149143 }
150144
151− slog.Warn("Saving embedded providers to cache")
152− providers := embedded.GetAll()
153− if err := saveProvidersInCache(path, providers); err != nil {
154− return nil, err
145+ providerList, providerErr = loadProviders(client, etag, path)
146+ if errors.Is(providerErr, catwalk.ErrNotModified) {
147+ slog.Info("Catwalk providers not modified")
148+ providerList, providerErr = cached, nil
155149 }
156− return providers, nil
157−
158− default:
159− slog.Info("Fetching providers from Catwalk.", "path", path)
150+ })
151+ if providerErr != nil {
152+ catwalkURL := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
153+ return nil, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help.\n\nCause: %w", catwalkURL, providerErr) //nolint:staticcheck
154+ }
155+ return providerList, nil
156+}
160157
161− providers, err := catwalkGetAndSave()
162− if err != nil {
163− catwalkUrl := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
164− return nil, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help. %w", catwalkUrl, err) //nolint:staticcheck
165− }
166− return providers, nil
158+func loadProviders(client ProviderClient, etag, path string) ([]catwalk.Provider, error) {
159+ slog.Info("Fetching providers from Catwalk.", "path", path)
160+ providers, err := client.GetProviders(context.Background(), etag)
161+ if err != nil {
162+ return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
163+ }
164+ if len(providers) == 0 {
165+ return nil, errors.New("empty providers list from catwalk")
167166 }
167+ if err := saveProvidersInCache(path, providers); err != nil {
168+ return nil, err
169+ }
170+ return providers, nil
168171}
internal/config/provider_empty_test.go+7−23
@@ -1,8 +1,7 @@
11package config
22
33import (
4− "encoding/json"
5− "os"
4+ "context"
65 "testing"
76
87 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -11,37 +10,22 @@ import (
1110
1211type emptyProviderClient struct{}
1312
14−func (m *emptyProviderClient) GetProviders() ([]catwalk.Provider, error) {
13+func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
1514 return []catwalk.Provider{}, nil
1615}
1716
17+// TestProvider_loadProvidersEmptyResult tests that loadProviders returns an
18+// error when the client returns an empty list. This ensures we don't cache
19+// empty provider lists.
1820func TestProvider_loadProvidersEmptyResult(t *testing.T) {
1921 client := &emptyProviderClient{}
2022 tmpPath := t.TempDir() + "/providers.json"
2123
22− providers, err := loadProviders(false, client, tmpPath)
23− require.Contains(t, err.Error(), "Crush was unable to fetch an updated list of providers")
24+ providers, err := loadProviders(client, "", tmpPath)
25+ require.Contains(t, err.Error(), "empty providers list from catwalk")
2426 require.Empty(t, providers)
2527 require.Len(t, providers, 0)
2628
2729 // Check that no cache file was created for empty results
2830 require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
2931}
30−
31−func TestProvider_loadProvidersEmptyCache(t *testing.T) {
32− client := &mockProviderClient{shouldFail: false}
33− tmpPath := t.TempDir() + "/providers.json"
34−
35− // Create an empty cache file
36− emptyProviders := []catwalk.Provider{}
37− data, err := json.Marshal(emptyProviders)
38− require.NoError(t, err)
39− require.NoError(t, os.WriteFile(tmpPath, data, 0o644))
40−
41− // Should refresh and get real providers instead of using empty cache
42− providers, err := loadProviders(false, client, tmpPath)
43− require.NoError(t, err)
44− require.NotNil(t, providers)
45− require.Len(t, providers, 1)
46− require.Equal(t, "Mock", providers[0].Name)
47−}
internal/config/provider_test.go+91−20
@@ -1,9 +1,11 @@
11package config
22
33import (
4+ "context"
45 "encoding/json"
56 "errors"
67 "os"
8+ "sync"
79 "testing"
810
911 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -11,10 +13,14 @@ import (
1113)
1214
1315type mockProviderClient struct {
14− shouldFail bool
16+ shouldFail bool
17+ shouldReturnErr error
1518}
1619
17−func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
20+func (m *mockProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
21+ if m.shouldReturnErr != nil {
22+ return nil, m.shouldReturnErr
23+ }
1824 if m.shouldFail {
1925 return nil, errors.New("failed to load providers")
2026 }
@@ -25,10 +31,16 @@ func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
2531 }, nil
2632}
2733
34+func resetProviderState() {
35+ providerOnce = sync.Once{}
36+ providerList = nil
37+ providerErr = nil
38+}
39+
2840func TestProvider_loadProvidersNoIssues(t *testing.T) {
2941 client := &mockProviderClient{shouldFail: false}
3042 tmpPath := t.TempDir() + "/providers.json"
31− providers, err := loadProviders(false, client, tmpPath)
43+ providers, err := loadProviders(client, "", tmpPath)
3244 require.NoError(t, err)
3345 require.NotNil(t, providers)
3446 require.Len(t, providers, 1)
@@ -39,35 +51,94 @@ func TestProvider_loadProvidersNoIssues(t *testing.T) {
3951 require.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
4052}
4153
42−func TestProvider_loadProvidersWithIssues(t *testing.T) {
43− client := &mockProviderClient{shouldFail: true}
44− tmpPath := t.TempDir() + "/providers.json"
45− // store providers to a temporary file
46− oldProviders := []catwalk.Provider{
47− {
48− Name: "OldProvider",
54+func TestProvider_DisableAutoUpdate(t *testing.T) {
55+ t.Setenv("XDG_DATA_HOME", t.TempDir())
56+
57+ resetProviderState()
58+ defer resetProviderState()
59+
60+ cfg := &Config{
61+ Options: &Options{
62+ DisableProviderAutoUpdate: true,
4963 },
5064 }
51− data, err := json.Marshal(oldProviders)
52− if err != nil {
53− t.Fatalf("Failed to marshal old providers: %v", err)
54− }
5565
56− err = os.WriteFile(tmpPath, data, 0o644)
57− if err != nil {
58− t.Fatalf("Failed to write old providers to file: %v", err)
66+ providers, err := Providers(cfg)
67+ require.NoError(t, err)
68+ require.NotNil(t, providers)
69+ require.Greater(t, len(providers), 5, "Expected embedded providers")
70+}
71+
72+func TestProvider_WithValidCache(t *testing.T) {
73+ tmpDir := t.TempDir()
74+ t.Setenv("XDG_DATA_HOME", tmpDir)
75+
76+ resetProviderState()
77+ defer resetProviderState()
78+
79+ cachePath := tmpDir + "/crush/providers.json"
80+ require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
81+ cachedProviders := []catwalk.Provider{
82+ {Name: "Cached"},
5983 }
60− providers, err := loadProviders(true, client, tmpPath)
84+ data, err := json.Marshal(cachedProviders)
85+ require.NoError(t, err)
86+ require.NoError(t, os.WriteFile(cachePath, data, 0o644))
87+
88+ mockClient := &mockProviderClient{shouldFail: false}
89+
90+ providers, err := loadProviders(mockClient, "", cachePath)
6191 require.NoError(t, err)
6292 require.NotNil(t, providers)
6393 require.Len(t, providers, 1)
64− require.Equal(t, "OldProvider", providers[0].Name, "Expected to keep old provider when loading fails")
94+ require.Equal(t, "Mock", providers[0].Name, "Expected fresh provider from fetch")
95+}
96+
97+func TestProvider_NotModifiedUsesCached(t *testing.T) {
98+ tmpDir := t.TempDir()
99+ t.Setenv("XDG_DATA_HOME", tmpDir)
100+
101+ resetProviderState()
102+ defer resetProviderState()
103+
104+ cachePath := tmpDir + "/crush/providers.json"
105+ require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
106+ cachedProviders := []catwalk.Provider{
107+ {Name: "Cached"},
108+ }
109+ data, err := json.Marshal(cachedProviders)
110+ require.NoError(t, err)
111+ require.NoError(t, os.WriteFile(cachePath, data, 0o644))
112+
113+ mockClient := &mockProviderClient{shouldReturnErr: catwalk.ErrNotModified}
114+ providers, err := loadProviders(mockClient, "", cachePath)
115+ require.ErrorIs(t, err, catwalk.ErrNotModified)
116+ require.Nil(t, providers)
117+}
118+
119+func TestProvider_EmptyCacheDefaultsToEmbedded(t *testing.T) {
120+ tmpDir := t.TempDir()
121+ t.Setenv("XDG_DATA_HOME", tmpDir)
122+
123+ resetProviderState()
124+ defer resetProviderState()
125+
126+ cachePath := tmpDir + "/crush/providers.json"
127+ require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
128+ emptyProviders := []catwalk.Provider{}
129+ data, err := json.Marshal(emptyProviders)
130+ require.NoError(t, err)
131+ require.NoError(t, os.WriteFile(cachePath, data, 0o644))
132+
133+ cached, _, err := loadProvidersFromCache(cachePath)
134+ require.NoError(t, err)
135+ require.Empty(t, cached, "Expected empty cache")
65136}
66137
67138func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
68139 client := &mockProviderClient{shouldFail: true}
69140 tmpPath := t.TempDir() + "/providers.json"
70− providers, err := loadProviders(false, client, tmpPath)
141+ providers, err := loadProviders(client, "", tmpPath)
71142 require.Error(t, err)
72143 require.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
73144}