on main help sign in
Merge branch 'main' into ui
6f38a79Ayman Bagabas
.github/cla-signatures.json+8
@@ -935,6 +935,14 @@
935 "created_at": "2025-12-06T18:13:11Z",
935 "created_at": "2025-12-06T18:13:11Z",
936 "repoId": 987670088,
936 "repoId": 987670088,
937 "pullRequestNo": 1560
937 "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
938 }
946 }
939 ]
947 ]
940}
948}
go.mod+2−2
@@ -1,6 +1,6 @@
1module github.com/charmbracelet/crush
1module github.com/charmbracelet/crush
2
2
3−go 1.25.0
3+go 1.25.5
4
4
5require (
5require (
6 charm.land/bubbles/v2 v2.0.0-rc.1
6 charm.land/bubbles/v2 v2.0.0-rc.1
@@ -16,7 +16,7 @@ require (
16 github.com/aymanbagabas/go-udiff v0.3.1
16 github.com/aymanbagabas/go-udiff v0.3.1
17 github.com/bmatcuk/doublestar/v4 v4.9.1
17 github.com/bmatcuk/doublestar/v4 v4.9.1
18 github.com/charlievieth/fastwalk v1.0.14
18 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
20 github.com/charmbracelet/colorprofile v0.3.3
20 github.com/charmbracelet/colorprofile v0.3.3
21 github.com/charmbracelet/fang v0.4.4
21 github.com/charmbracelet/fang v0.4.4
22 github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
22 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
88github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
88github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
89github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
89github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
90github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
90github.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=
91+github.com/charmbracelet/catwalk v0.9.7-0.20251208190755-350e2a004c74 h1:pJI8ivIgSWOeCNnZFXeZzL6px1vZVS3LU4Cqk3Gx37I=
92−github.com/charmbracelet/catwalk v0.9.5/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
92+github.com/charmbracelet/catwalk v0.9.7-0.20251208190755-350e2a004c74/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
93github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
93github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
94github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
94github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
95github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
95github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
internal/agent/coordinator.go+67−24
@@ -10,6 +10,7 @@ import (
10 "io"
10 "io"
11 "log/slog"
11 "log/slog"
12 "maps"
12 "maps"
13+ "net/http"
13 "os"
14 "os"
14 "slices"
15 "slices"
15 "strings"
16 "strings"
@@ -130,32 +131,42 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
130
131
131 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
132 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
132
133
133− if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
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:
134− slog.Info("Detected expired OAuth token, attempting refresh", "provider", providerCfg.ID)
153+ slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
135− if refreshErr := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); refreshErr != nil {
154+ if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
155+ return nil, originalErr
156+ }
136− slog.Error("Failed to refresh OAuth token", "provider", providerCfg.ID, "error", refreshErr)
157+ slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
137− return nil, refreshErr
158+ return run()
138− }
159+ case strings.Contains(providerCfg.APIKeyTemplate, "$"):
139−
160+ slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
140− // Rebuild models with refreshed token
161+ if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
141− if updateErr := c.UpdateModels(ctx); updateErr != nil {
162+ return nil, originalErr
142− slog.Error("Failed to update models after token refresh", "error", updateErr)
163+ }
164+ slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
143− return nil, updateErr
165+ return run()
144 }
166 }
145 }
167 }
146− result, err := c.currentAgent.Run(ctx, SessionAgentCall{
168+
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
169+ return result, originalErr
159}
170}
160
171
161func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
172func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
@@ -773,3 +784,35 @@ func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
773 }
784 }
774 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
785 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
775}
786}
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 @@
1package cmd
1package cmd
2
2
3import (
3import (
4+ "context"
4 "fmt"
5 "fmt"
5 "log/slog"
6 "log/slog"
6 "os"
7 "os"
8+ "os/signal"
7 "strings"
9 "strings"
8
10
9 "github.com/spf13/cobra"
11 "github.com/spf13/cobra"
@@ -30,6 +32,10 @@ crush run --quiet "Generate a README for this project"
30 RunE: func(cmd *cobra.Command, args []string) error {
32 RunE: func(cmd *cobra.Command, args []string) error {
31 quiet, _ := cmd.Flags().GetBool("quiet")
33 quiet, _ := cmd.Flags().GetBool("quiet")
32
34
35+ // Cancel on SIGINT or SIGTERM.
36+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
37+ defer cancel()
38+
33 app, err := setupApp(cmd)
39 app, err := setupApp(cmd)
34 if err != nil {
40 if err != nil {
35 return err
41 return err
@@ -57,8 +63,7 @@ crush run --quiet "Generate a README for this project"
57 // crush run "Do something fancy" > output.txt
63 // crush run "Do something fancy" > output.txt
58 // echo "Do something fancy" | crush run > output.txt
64 // echo "Do something fancy" | crush run > output.txt
59 //
65 //
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)
62 },
67 },
63}
68}
64
69
internal/cmd/update_providers.go+3−3
@@ -31,12 +31,12 @@ crush update-providers embedded
31 // NOTE(@andreynering): We want to skip logging output do stdout here.
31 // NOTE(@andreynering): We want to skip logging output do stdout here.
32 slog.SetDefault(slog.New(slog.DiscardHandler))
32 slog.SetDefault(slog.New(slog.DiscardHandler))
33
33
34− var pathOrUrl string
34+ var pathOrURL string
35 if len(args) > 0 {
35 if len(args) > 0 {
36− pathOrUrl = args[0]
36+ pathOrURL = args[0]
37 }
37 }
38
38
39− if err := config.UpdateProviders(pathOrUrl); err != nil {
39+ if err := config.UpdateProviders(pathOrURL); err != nil {
40 return err
40 return err
41 }
41 }
42
42
internal/config/config.go+5−2
@@ -95,6 +95,8 @@ type ProviderConfig struct {
95 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"`
95 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"`
96 // The provider's API key.
96 // The provider's API key.
97 APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
97 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:"-"`
98 // OAuthToken for providers that use OAuth2 authentication.
100 // OAuthToken for providers that use OAuth2 authentication.
99 OAuthToken *oauth.Token `json:"oauth,omitempty" jsonschema:"description=OAuth2 token for authentication with the provider"`
101 OAuthToken *oauth.Token `json:"oauth,omitempty" jsonschema:"description=OAuth2 token for authentication with the provider"`
100 // Marks the provider as disabled.
102 // Marks the provider as disabled.
@@ -469,6 +471,7 @@ func (c *Config) SetConfigField(key string, value any) error {
469 return nil
471 return nil
470}
472}
471
473
474+// RefreshOAuthToken refreshes the OAuth token for the given provider.
472func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
475func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
473 providerConfig, exists := c.Providers.Get(providerID)
476 providerConfig, exists := c.Providers.Get(providerID)
474 if !exists {
477 if !exists {
@@ -479,7 +482,7 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
479 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
482 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
480 }
483 }
481
484
482− // Only Anthropic provider uses OAuth for now
485+ // Only Anthropic provider uses OAuth for now.
483 if providerID != string(catwalk.InferenceProviderAnthropic) {
486 if providerID != string(catwalk.InferenceProviderAnthropic) {
484 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
487 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
485 }
488 }
@@ -489,7 +492,7 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
489 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
492 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
490 }
493 }
491
494
492− slog.Info("Successfully refreshed OAuth token in background", "provider", providerID)
495+ slog.Info("Successfully refreshed OAuth token", "provider", providerID)
493 providerConfig.OAuthToken = newToken
496 providerConfig.OAuthToken = newToken
494 providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
497 providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
495 providerConfig.SetupClaudeCode()
498 providerConfig.SetupClaudeCode()
internal/config/load.go+1−22
@@ -1,7 +1,6 @@
1package config
1package config
2
2
3import (
3import (
4− "cmp"
5 "context"
4 "context"
6 "encoding/json"
5 "encoding/json"
7 "fmt"
6 "fmt"
@@ -19,11 +18,9 @@ import (
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
18 "github.com/charmbracelet/catwalk/pkg/catwalk"
20 "github.com/charmbracelet/crush/internal/csync"
19 "github.com/charmbracelet/crush/internal/csync"
21 "github.com/charmbracelet/crush/internal/env"
20 "github.com/charmbracelet/crush/internal/env"
22− "github.com/charmbracelet/crush/internal/event"
23 "github.com/charmbracelet/crush/internal/fsext"
21 "github.com/charmbracelet/crush/internal/fsext"
24 "github.com/charmbracelet/crush/internal/home"
22 "github.com/charmbracelet/crush/internal/home"
25 "github.com/charmbracelet/crush/internal/log"
23 "github.com/charmbracelet/crush/internal/log"
26− "github.com/charmbracelet/crush/internal/oauth/claude"
27 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
24 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
28)
25)
29
26
@@ -189,6 +186,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
189 Name: p.Name,
186 Name: p.Name,
190 BaseURL: p.APIEndpoint,
187 BaseURL: p.APIEndpoint,
191 APIKey: p.APIKey,
188 APIKey: p.APIKey,
189+ APIKeyTemplate: p.APIKey, // Store original template for re-resolution
192 OAuthToken: config.OAuthToken,
190 OAuthToken: config.OAuthToken,
193 Type: p.Type,
191 Type: p.Type,
194 Disable: config.Disable,
192 Disable: config.Disable,
@@ -200,25 +198,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
200 }
198 }
201
199
202 if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
200 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− }
222 prepared.SetupClaudeCode()
201 prepared.SetupClaudeCode()
223 }
202 }
224
203
internal/config/provider.go+56−53
@@ -2,7 +2,9 @@ package config
2
2
3import (
3import (
4 "cmp"
4 "cmp"
5+ "context"
5 "encoding/json"
6 "encoding/json"
7+ "errors"
6 "fmt"
8 "fmt"
7 "log/slog"
9 "log/slog"
8 "os"
10 "os"
@@ -17,7 +19,7 @@ import (
17)
19)
18
20
19type ProviderClient interface {
21type ProviderClient interface {
20− GetProviders() ([]catwalk.Provider, error)
22+ GetProviders(context.Context, string) ([]catwalk.Provider, error)
21}
23}
22
24
23var (
25var (
@@ -53,7 +55,7 @@ func saveProvidersInCache(path string, providers []catwalk.Provider) error {
53 return fmt.Errorf("failed to create directory for provider cache: %w", err)
55 return fmt.Errorf("failed to create directory for provider cache: %w", err)
54 }
56 }
55
57
56− data, err := json.MarshalIndent(providers, "", " ")
58+ data, err := json.Marshal(providers)
57 if err != nil {
59 if err != nil {
58 return fmt.Errorf("failed to marshal provider data: %w", err)
60 return fmt.Errorf("failed to marshal provider data: %w", err)
59 }
61 }
@@ -64,34 +66,35 @@ func saveProvidersInCache(path string, providers []catwalk.Provider) error {
64 return nil
66 return nil
65}
67}
66
68
67−func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
69+func loadProvidersFromCache(path string) ([]catwalk.Provider, string, error) {
68 data, err := os.ReadFile(path)
70 data, err := os.ReadFile(path)
69 if err != nil {
71 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)
71 }
73 }
72
74
73 var providers []catwalk.Provider
75 var providers []catwalk.Provider
74 if err := json.Unmarshal(data, &providers); err != nil {
76 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)
76 }
78 }
79+
77− return providers, nil
80+ return providers, catwalk.Etag(data), nil
78}
81}
79
82
80−func UpdateProviders(pathOrUrl string) error {
83+func UpdateProviders(pathOrURL string) error {
81 var providers []catwalk.Provider
84 var providers []catwalk.Provider
82− pathOrUrl = cmp.Or(pathOrUrl, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
85+ pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
83
86
84 switch {
87 switch {
85− case pathOrUrl == "embedded":
88+ case pathOrURL == "embedded":
86 providers = embedded.GetAll()
89 providers = embedded.GetAll()
87− case strings.HasPrefix(pathOrUrl, "http://") || strings.HasPrefix(pathOrUrl, "https://"):
90+ case strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://"):
88 var err error
91 var err error
89− providers, err = catwalk.NewWithURL(pathOrUrl).GetProviders()
92+ providers, err = catwalk.NewWithURL(pathOrURL).GetProviders(context.Background(), "")
90 if err != nil {
93 if err != nil {
91 return fmt.Errorf("failed to fetch providers from Catwalk: %w", err)
94 return fmt.Errorf("failed to fetch providers from Catwalk: %w", err)
92 }
95 }
93 default:
96 default:
94− content, err := os.ReadFile(pathOrUrl)
97+ content, err := os.ReadFile(pathOrURL)
95 if err != nil {
98 if err != nil {
96 return fmt.Errorf("failed to read file: %w", err)
99 return fmt.Errorf("failed to read file: %w", err)
97 }
100 }
@@ -108,61 +111,61 @@ func UpdateProviders(pathOrUrl string) error {
108 return fmt.Errorf("failed to save providers to cache: %w", err)
111 return fmt.Errorf("failed to save providers to cache: %w", err)
109 }
112 }
110
113
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)
112 return nil
115 return nil
113}
116}
114
117
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.
115func Providers(cfg *Config) ([]catwalk.Provider, error) {
127func Providers(cfg *Config) ([]catwalk.Provider, error) {
116 providerOnce.Do(func() {
128 providerOnce.Do(func() {
117 catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
129 catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
118 client := catwalk.NewWithURL(catwalkURL)
130 client := catwalk.NewWithURL(catwalkURL)
119 path := providerCacheFileData()
131 path := providerCacheFileData()
120
132
121− autoUpdateDisabled := cfg.Options.DisableProviderAutoUpdate
133+ if cfg.Options.DisableProviderAutoUpdate {
134+ slog.Info("Using embedded Catwalk providers")
122− providerList, providerErr = loadProviders(autoUpdateDisabled, client, path)
135+ providerList, providerErr = embedded.GetAll(), nil
123− })
136+ return
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)
132 }
137 }
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")
145
138
146− if _, err := os.Stat(path); err == nil {
139+ cached, etag, cachedErr := loadProvidersFromCache(path)
147− slog.Warn("Using locally cached providers")
140+ if len(cached) == 0 || cachedErr != nil {
148− return loadProvidersFromCache(path)
141+ // if cached file is empty, default to embedded providers
142+ cached = embedded.GetAll()
149 }
143 }
150
144
151− slog.Warn("Saving embedded providers to cache")
145+ providerList, providerErr = loadProviders(client, etag, path)
152− providers := embedded.GetAll()
146+ if errors.Is(providerErr, catwalk.ErrNotModified) {
153− if err := saveProvidersInCache(path, providers); err != nil {
147+ slog.Info("Catwalk providers not modified")
154− return nil, err
148+ providerList, providerErr = cached, nil
155 }
149 }
156− return providers, nil
150+ })
157−
151+ if providerErr != nil {
158− default:
152+ catwalkURL := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
159− slog.Info("Fetching providers from Catwalk.", "path", path)
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+}
160
157
161− providers, err := catwalkGetAndSave()
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)
162− if err != nil {
161+ if err != nil {
163− catwalkUrl := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
162+ return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
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− }
163+ }
166− return providers, nil
164+ if len(providers) == 0 {
165+ return nil, errors.New("empty providers list from catwalk")
167 }
166 }
167+ if err := saveProvidersInCache(path, providers); err != nil {
168+ return nil, err
169+ }
170+ return providers, nil
168}
171}
internal/config/provider_empty_test.go+7−23
@@ -1,8 +1,7 @@
1package config
1package config
2
2
3import (
3import (
4− "encoding/json"
4+ "context"
5− "os"
6 "testing"
5 "testing"
7
6
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
7 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -11,37 +10,22 @@ import (
11
10
12type emptyProviderClient struct{}
11type emptyProviderClient struct{}
13
12
14−func (m *emptyProviderClient) GetProviders() ([]catwalk.Provider, error) {
13+func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
15 return []catwalk.Provider{}, nil
14 return []catwalk.Provider{}, nil
16}
15}
17
16
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.
18func TestProvider_loadProvidersEmptyResult(t *testing.T) {
20func TestProvider_loadProvidersEmptyResult(t *testing.T) {
19 client := &emptyProviderClient{}
21 client := &emptyProviderClient{}
20 tmpPath := t.TempDir() + "/providers.json"
22 tmpPath := t.TempDir() + "/providers.json"
21
23
22− providers, err := loadProviders(false, client, tmpPath)
24+ providers, err := loadProviders(client, "", tmpPath)
23− require.Contains(t, err.Error(), "Crush was unable to fetch an updated list of providers")
25+ require.Contains(t, err.Error(), "empty providers list from catwalk")
24 require.Empty(t, providers)
26 require.Empty(t, providers)
25 require.Len(t, providers, 0)
27 require.Len(t, providers, 0)
26
28
27 // Check that no cache file was created for empty results
29 // Check that no cache file was created for empty results
28 require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
30 require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
29}
31}
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 @@
1package config
1package config
2
2
3import (
3import (
4+ "context"
4 "encoding/json"
5 "encoding/json"
5 "errors"
6 "errors"
6 "os"
7 "os"
8+ "sync"
7 "testing"
9 "testing"
8
10
9 "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -11,10 +13,14 @@ import (
11)
13)
12
14
13type mockProviderClient struct {
15type mockProviderClient struct {
14− shouldFail bool
16+ shouldFail bool
17+ shouldReturnErr error
15}
18}
16
19
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+ }
18 if m.shouldFail {
24 if m.shouldFail {
19 return nil, errors.New("failed to load providers")
25 return nil, errors.New("failed to load providers")
20 }
26 }
@@ -25,10 +31,16 @@ func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
25 }, nil
31 }, nil
26}
32}
27
33
34+func resetProviderState() {
35+ providerOnce = sync.Once{}
36+ providerList = nil
37+ providerErr = nil
38+}
39+
28func TestProvider_loadProvidersNoIssues(t *testing.T) {
40func TestProvider_loadProvidersNoIssues(t *testing.T) {
29 client := &mockProviderClient{shouldFail: false}
41 client := &mockProviderClient{shouldFail: false}
30 tmpPath := t.TempDir() + "/providers.json"
42 tmpPath := t.TempDir() + "/providers.json"
31− providers, err := loadProviders(false, client, tmpPath)
43+ providers, err := loadProviders(client, "", tmpPath)
32 require.NoError(t, err)
44 require.NoError(t, err)
33 require.NotNil(t, providers)
45 require.NotNil(t, providers)
34 require.Len(t, providers, 1)
46 require.Len(t, providers, 1)
@@ -39,35 +51,94 @@ func TestProvider_loadProvidersNoIssues(t *testing.T) {
39 require.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
51 require.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
40}
52}
41
53
42−func TestProvider_loadProvidersWithIssues(t *testing.T) {
54+func TestProvider_DisableAutoUpdate(t *testing.T) {
43− client := &mockProviderClient{shouldFail: true}
55+ t.Setenv("XDG_DATA_HOME", t.TempDir())
44− tmpPath := t.TempDir() + "/providers.json"
56+
45− // store providers to a temporary file
57+ resetProviderState()
46− oldProviders := []catwalk.Provider{
58+ defer resetProviderState()
47− {
59+
48− Name: "OldProvider",
60+ cfg := &Config{
61+ Options: &Options{
62+ DisableProviderAutoUpdate: true,
49 },
63 },
50 }
64 }
51− data, err := json.Marshal(oldProviders)
52− if err != nil {
53− t.Fatalf("Failed to marshal old providers: %v", err)
54− }
55
65
56− err = os.WriteFile(tmpPath, data, 0o644)
66+ providers, err := Providers(cfg)
57− if err != nil {
67+ require.NoError(t, err)
58− t.Fatalf("Failed to write old providers to file: %v", 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"},
59 }
83 }
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+
60− providers, err := loadProviders(true, client, tmpPath)
90+ providers, err := loadProviders(mockClient, "", cachePath)
61 require.NoError(t, err)
91 require.NoError(t, err)
62 require.NotNil(t, providers)
92 require.NotNil(t, providers)
63 require.Len(t, providers, 1)
93 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")
65}
136}
66
137
67func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
138func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
68 client := &mockProviderClient{shouldFail: true}
139 client := &mockProviderClient{shouldFail: true}
69 tmpPath := t.TempDir() + "/providers.json"
140 tmpPath := t.TempDir() + "/providers.json"
70− providers, err := loadProviders(false, client, tmpPath)
141+ providers, err := loadProviders(client, "", tmpPath)
71 require.Error(t, err)
142 require.Error(t, err)
72 require.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
143 require.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
73}
144}