on main help sign in
feat: hyper integration (#1642)
5291b1dCarlos Alexandro Becker
.gitattributes+1
@@ -1,3 +1,4 @@
1*.golden linguist-generated=true -text
1*.golden linguist-generated=true -text
2.github/crush-schema.json linguist-generated=true
2.github/crush-schema.json linguist-generated=true
3+internal/agent/hyper/provider.json linguist-generated=true
3internal/agent/testdata/**/*.yaml -diff linguist-generated=true
4internal/agent/testdata/**/*.yaml -diff linguist-generated=true
.github/workflows/schema-update.yml+4−2
@@ -1,10 +1,11 @@
1−name: Update Schema
1+name: Update files
2
2
3on:
3on:
4 push:
4 push:
5 branches: [main]
5 branches: [main]
6 paths:
6 paths:
7 - "internal/config/**"
7 - "internal/config/**"
8+ - "internal/agent/hyper/**"
8
9
9jobs:
10jobs:
10 update-schema:
11 update-schema:
@@ -17,9 +18,10 @@ jobs:
17 with:
18 with:
18 go-version-file: go.mod
19 go-version-file: go.mod
19 - run: go run . schema > ./schema.json
20 - run: go run . schema > ./schema.json
21+ - run: go generate ./internal/agent/hyper/...
20 - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
22 - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
21 with:
23 with:
22− commit_message: "chore: auto-update generated files"
24+ commit_message: "chore: auto-update files"
23 branch: main
25 branch: main
24 commit_user_name: Charm
26 commit_user_name: Charm
25 commit_user_email: 124303983+charmcli@users.noreply.github.com
27 commit_user_email: 124303983+charmcli@users.noreply.github.com
Taskfile.yaml+7
@@ -101,6 +101,13 @@ tasks:
101 generates:
101 generates:
102 - schema.json
102 - schema.json
103
103
104+ hyper:
105+ desc: Update Hyper embedded provider.json
106+ cmds:
107+ - go generate ./internal/agent/hyper/...
108+ generates:
109+ - ./internal/agent/hyper/provider.json
110+
104 release:
111 release:
105 desc: Create and push a new tag following semver
112 desc: Create and push a new tag following semver
106 vars:
113 vars:
go.mod+1−1
@@ -23,6 +23,7 @@ require (
23 github.com/charmbracelet/fang v0.4.4
23 github.com/charmbracelet/fang v0.4.4
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
25 github.com/charmbracelet/x/ansi v0.11.3
25 github.com/charmbracelet/x/ansi v0.11.3
26+ github.com/charmbracelet/x/etag v0.2.0
26 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
27 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
27 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
28 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
28 github.com/charmbracelet/x/exp/ordered v0.1.0
29 github.com/charmbracelet/x/exp/ordered v0.1.0
@@ -91,7 +92,6 @@ require (
91 github.com/bahlo/generic-list-go v0.2.0 // indirect
92 github.com/bahlo/generic-list-go v0.2.0 // indirect
92 github.com/buger/jsonparser v1.1.1 // indirect
93 github.com/buger/jsonparser v1.1.1 // indirect
93 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
94 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
94− github.com/charmbracelet/x/etag v0.2.0 // indirect
95 github.com/charmbracelet/x/json v0.2.0 // indirect
95 github.com/charmbracelet/x/json v0.2.0 // indirect
96 github.com/charmbracelet/x/termios v0.1.1 // indirect
96 github.com/charmbracelet/x/termios v0.1.1 // indirect
97 github.com/charmbracelet/x/windows v0.2.2 // indirect
97 github.com/charmbracelet/x/windows v0.2.2 // indirect
internal/agent/agent.go+6
@@ -27,7 +27,9 @@ import (
27 "charm.land/fantasy/providers/google"
27 "charm.land/fantasy/providers/google"
28 "charm.land/fantasy/providers/openai"
28 "charm.land/fantasy/providers/openai"
29 "charm.land/fantasy/providers/openrouter"
29 "charm.land/fantasy/providers/openrouter"
30+ "charm.land/lipgloss/v2"
30 "github.com/charmbracelet/catwalk/pkg/catwalk"
31 "github.com/charmbracelet/catwalk/pkg/catwalk"
32+ "github.com/charmbracelet/crush/internal/agent/hyper"
31 "github.com/charmbracelet/crush/internal/agent/tools"
33 "github.com/charmbracelet/crush/internal/agent/tools"
32 "github.com/charmbracelet/crush/internal/config"
34 "github.com/charmbracelet/crush/internal/config"
33 "github.com/charmbracelet/crush/internal/csync"
35 "github.com/charmbracelet/crush/internal/csync"
@@ -454,6 +456,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
454 currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
456 currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
455 } else if isPermissionErr {
457 } else if isPermissionErr {
456 currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
458 currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
459+ } else if errors.Is(err, hyper.ErrNoCredits) {
460+ url := hyper.BaseURL()
461+ link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
462+ currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
457 } else if errors.As(err, &providerErr) {
463 } else if errors.As(err, &providerErr) {
458 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
464 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
459 } else if errors.As(err, &fantasyErr) {
465 } else if errors.As(err, &fantasyErr) {
internal/agent/coordinator.go+15
@@ -17,6 +17,7 @@ import (
17
17
18 "charm.land/fantasy"
18 "charm.land/fantasy"
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
20 "github.com/charmbracelet/crush/internal/agent/prompt"
21 "github.com/charmbracelet/crush/internal/agent/prompt"
21 "github.com/charmbracelet/crush/internal/agent/tools"
22 "github.com/charmbracelet/crush/internal/agent/tools"
22 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
@@ -668,6 +669,18 @@ func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, optio
668 return google.New(opts...)
669 return google.New(opts...)
669}
670}
670
671
672+func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
673+ opts := []hyper.Option{
674+ hyper.WithBaseURL(baseURL),
675+ hyper.WithAPIKey(apiKey),
676+ }
677+ if c.cfg.Options.Debug {
678+ httpClient := log.NewHTTPClient()
679+ opts = append(opts, hyper.WithHTTPClient(httpClient))
680+ }
681+ return hyper.New(opts...)
682+}
683+
671func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
684func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
672 if model.Think {
685 if model.Think {
673 return true
686 return true
@@ -728,6 +741,8 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
728 providerCfg.ExtraBody["tool_stream"] = true
741 providerCfg.ExtraBody["tool_stream"] = true
729 }
742 }
730 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
743 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
744+ case hyper.Name:
745+ return c.buildHyperProvider(baseURL, apiKey)
731 default:
746 default:
732 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
747 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
733 }
748 }
internal/agent/hyper/provider.gonew file+330
@@ -0,0 +1,330 @@
1+// Package hyper provides a fantasy.Provider that proxies requests to Hyper.
2+package hyper
3+
4+import (
5+ "bufio"
6+ "bytes"
7+ "cmp"
8+ "context"
9+ _ "embed"
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "io"
14+ "log/slog"
15+ "maps"
16+ "net/http"
17+ "net/url"
18+ "os"
19+ "strconv"
20+ "strings"
21+ "sync"
22+ "time"
23+
24+ "charm.land/fantasy"
25+ "charm.land/fantasy/object"
26+ "github.com/charmbracelet/catwalk/pkg/catwalk"
27+ "github.com/charmbracelet/crush/internal/event"
28+)
29+
30+//go:generate wget -O provider.json https://console.charm.land/api/v1/provider
31+
32+//go:embed provider.json
33+var embedded []byte
34+
35+// Enabled returns true if hyper is enabled.
36+var Enabled = sync.OnceValue(func() bool {
37+ b, _ := strconv.ParseBool(
38+ cmp.Or(
39+ os.Getenv("HYPER"),
40+ os.Getenv("HYPERCRUSH"),
41+ os.Getenv("HYPER_ENABLE"),
42+ os.Getenv("HYPER_ENABLED"),
43+ ),
44+ )
45+ return b
46+})
47+
48+// Embedded returns the embedded Hyper provider.
49+var Embedded = sync.OnceValue(func() catwalk.Provider {
50+ var provider catwalk.Provider
51+ if err := json.Unmarshal(embedded, &provider); err != nil {
52+ slog.Error("could not use embedded provider data", "err", err)
53+ }
54+ return provider
55+})
56+
57+const (
58+ // Name is the default name of this meta provider.
59+ Name = "hyper"
60+ // defaultBaseURL is the default proxy URL.
61+ // TODO: change this to production URL when ready.
62+ defaultBaseURL = "https://console.charm.land"
63+)
64+
65+// BaseURL returns the base URL, which is either $HYPER_URL or the default.
66+var BaseURL = sync.OnceValue(func() string {
67+ return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
68+})
69+
70+var ErrNoCredits = errors.New("you're out of credits")
71+
72+type options struct {
73+ baseURL string
74+ apiKey string
75+ name string
76+ headers map[string]string
77+ client *http.Client
78+}
79+
80+// Option configures the proxy provider.
81+type Option = func(*options)
82+
83+// New creates a new proxy provider.
84+func New(opts ...Option) (fantasy.Provider, error) {
85+ o := options{
86+ baseURL: BaseURL() + "/api/v1/fantasy",
87+ name: Name,
88+ headers: map[string]string{
89+ "x-crush-id": event.GetID(),
90+ },
91+ client: &http.Client{Timeout: 0}, // stream-safe
92+ }
93+ for _, opt := range opts {
94+ opt(&o)
95+ }
96+ return &provider{options: o}, nil
97+}
98+
99+// WithBaseURL sets the proxy base URL (e.g. http://localhost:8080).
100+func WithBaseURL(url string) Option { return func(o *options) { o.baseURL = url } }
101+
102+// WithName sets the provider name.
103+func WithName(name string) Option { return func(o *options) { o.name = name } }
104+
105+// WithHeaders sets extra headers sent to the proxy.
106+func WithHeaders(headers map[string]string) Option {
107+ return func(o *options) {
108+ maps.Copy(o.headers, headers)
109+ }
110+}
111+
112+// WithHTTPClient sets custom HTTP client.
113+func WithHTTPClient(c *http.Client) Option { return func(o *options) { o.client = c } }
114+
115+// WithAPIKey sets the API key.
116+func WithAPIKey(key string) Option {
117+ return func(o *options) {
118+ o.apiKey = key
119+ }
120+}
121+
122+type provider struct{ options options }
123+
124+func (p *provider) Name() string { return p.options.name }
125+
126+// LanguageModel implements fantasy.Provider.
127+func (p *provider) LanguageModel(_ context.Context, modelID string) (fantasy.LanguageModel, error) {
128+ if modelID == "" {
129+ return nil, errors.New("missing model id")
130+ }
131+ return &languageModel{modelID: modelID, provider: p.options.name, opts: p.options}, nil
132+}
133+
134+type languageModel struct {
135+ provider string
136+ modelID string
137+ opts options
138+}
139+
140+// GenerateObject implements fantasy.LanguageModel.
141+func (m *languageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
142+ return object.GenerateWithTool(ctx, m, call)
143+}
144+
145+// StreamObject implements fantasy.LanguageModel.
146+func (m *languageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
147+ return object.StreamWithTool(ctx, m, call)
148+}
149+
150+func (m *languageModel) Provider() string { return m.provider }
151+func (m *languageModel) Model() string { return m.modelID }
152+
153+// Generate implements fantasy.LanguageModel by calling the proxy JSON endpoint.
154+func (m *languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
155+ resp, err := m.doRequest(ctx, false, call)
156+ if err != nil {
157+ return nil, err
158+ }
159+ defer func() { _ = resp.Body.Close() }()
160+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
161+ b, _ := ioReadAllLimit(resp.Body, 64*1024)
162+ return nil, fmt.Errorf("proxy generate error: %s", strings.TrimSpace(string(b)))
163+ }
164+ var out fantasy.Response
165+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
166+ return nil, err
167+ }
168+ return &out, nil
169+}
170+
171+// Stream implements fantasy.LanguageModel using SSE from the proxy.
172+func (m *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
173+ // Prefer explicit /stream endpoint
174+ resp, err := m.doRequest(ctx, true, call)
175+ if err != nil {
176+ return nil, err
177+ }
178+ switch resp.StatusCode {
179+ case http.StatusTooManyRequests:
180+ _ = resp.Body.Close()
181+ return nil, toProviderError(resp, retryAfter(resp))
182+ case http.StatusUnauthorized:
183+ _ = resp.Body.Close()
184+ return nil, toProviderError(resp, "")
185+ case http.StatusPaymentRequired:
186+ _ = resp.Body.Close()
187+ return nil, ErrNoCredits
188+ }
189+
190+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
191+ defer func() { _ = resp.Body.Close() }()
192+ b, _ := ioReadAllLimit(resp.Body, 64*1024)
193+ return nil, &fantasy.ProviderError{
194+ Title: "Stream Error",
195+ Message: strings.TrimSpace(string(b)),
196+ StatusCode: resp.StatusCode,
197+ }
198+ }
199+
200+ return func(yield func(fantasy.StreamPart) bool) {
201+ defer func() { _ = resp.Body.Close() }()
202+ scanner := bufio.NewScanner(resp.Body)
203+ buf := make([]byte, 0, 64*1024)
204+ scanner.Buffer(buf, 4*1024*1024)
205+
206+ var (
207+ event string
208+ dataBuf bytes.Buffer
209+ sawFinish bool
210+ dispatch = func() bool {
211+ if dataBuf.Len() == 0 || event == "" {
212+ dataBuf.Reset()
213+ event = ""
214+ return true
215+ }
216+ var part fantasy.StreamPart
217+ if err := json.Unmarshal(dataBuf.Bytes(), &part); err != nil {
218+ return yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
219+ }
220+ if part.Type == fantasy.StreamPartTypeFinish {
221+ sawFinish = true
222+ }
223+ ok := yield(part)
224+ dataBuf.Reset()
225+ event = ""
226+ return ok
227+ }
228+ )
229+
230+ for scanner.Scan() {
231+ line := scanner.Text()
232+ if line == "" { // event boundary
233+ if !dispatch() {
234+ return
235+ }
236+ continue
237+ }
238+ if strings.HasPrefix(line, ":") { // comment / ping
239+ continue
240+ }
241+ if strings.HasPrefix(line, "event: ") {
242+ event = strings.TrimSpace(line[len("event: "):])
243+ continue
244+ }
245+ if strings.HasPrefix(line, "data: ") {
246+ if dataBuf.Len() > 0 {
247+ dataBuf.WriteByte('\n')
248+ }
249+ dataBuf.WriteString(line[len("data: "):])
250+ continue
251+ }
252+ }
253+ if err := scanner.Err(); err != nil &&
254+ !errors.Is(err, context.Canceled) &&
255+ !errors.Is(err, context.DeadlineExceeded) {
256+ yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
257+ return
258+ }
259+ // flush any pending data
260+ _ = dispatch()
261+ if !sawFinish {
262+ _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish})
263+ }
264+ }, nil
265+}
266+
267+func (m *languageModel) doRequest(ctx context.Context, stream bool, call fantasy.Call) (*http.Response, error) {
268+ addr, err := url.Parse(m.opts.baseURL)
269+ if err != nil {
270+ return nil, err
271+ }
272+ addr = addr.JoinPath(m.modelID)
273+ if stream {
274+ addr = addr.JoinPath("stream")
275+ } else {
276+ addr = addr.JoinPath("generate")
277+ }
278+
279+ body, err := json.Marshal(call)
280+ if err != nil {
281+ return nil, err
282+ }
283+
284+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, addr.String(), bytes.NewReader(body))
285+ if err != nil {
286+ return nil, err
287+ }
288+ req.Header.Set("Content-Type", "application/json")
289+ if stream {
290+ req.Header.Set("Accept", "text/event-stream")
291+ } else {
292+ req.Header.Set("Accept", "application/json")
293+ }
294+ for k, v := range m.opts.headers {
295+ req.Header.Set(k, v)
296+ }
297+
298+ if m.opts.apiKey != "" {
299+ req.Header.Set("Authorization", m.opts.apiKey)
300+ }
301+ return m.opts.client.Do(req)
302+}
303+
304+// ioReadAllLimit reads up to n bytes.
305+func ioReadAllLimit(r io.Reader, n int64) ([]byte, error) {
306+ var b bytes.Buffer
307+ if n <= 0 {
308+ n = 1 << 20
309+ }
310+ lr := &io.LimitedReader{R: r, N: n}
311+ _, err := b.ReadFrom(lr)
312+ return b.Bytes(), err
313+}
314+
315+func toProviderError(resp *http.Response, message string) error {
316+ return &fantasy.ProviderError{
317+ Title: fantasy.ErrorTitleForStatusCode(resp.StatusCode),
318+ Message: message,
319+ StatusCode: resp.StatusCode,
320+ }
321+}
322+
323+func retryAfter(resp *http.Response) string {
324+ after, err := strconv.Atoi(resp.Header.Get("Retry-After"))
325+ if err == nil && after > 0 {
326+ d := time.Duration(after) * time.Second
327+ return "Try again in " + d.String()
328+ }
329+ return "Try again in later"
330+}
internal/agent/hyper/provider.jsonnew file+1
@@ -0,0 +1 @@
1+{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]}
internal/cmd/login.go+86−15
@@ -9,8 +9,12 @@ import (
9 "strings"
9 "strings"
10
10
11 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
13+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
12 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/oauth/claude"
15 "github.com/charmbracelet/crush/internal/oauth/claude"
16+ "github.com/charmbracelet/crush/internal/oauth/hyper"
17+ "github.com/pkg/browser"
14 "github.com/spf13/cobra"
18 "github.com/spf13/cobra"
15)
19)
16
20
@@ -20,31 +24,34 @@ var loginCmd = &cobra.Command{
20 Short: "Login Crush to a platform",
24 Short: "Login Crush to a platform",
21 Long: `Login Crush to a specified platform.
25 Long: `Login Crush to a specified platform.
22The platform should be provided as an argument.
26The platform should be provided as an argument.
23−Available platforms are: claude.`,
27+Available platforms are: hyper, claude.`,
24 Example: `
28 Example: `
29+# Authenticate with Charm Hyper
30+crush login
31+
25# Authenticate with Claude Code Max
32# Authenticate with Claude Code Max
26crush login claude
33crush login claude
27 `,
34 `,
28 ValidArgs: []cobra.Completion{
35 ValidArgs: []cobra.Completion{
36+ "hyper",
29 "claude",
37 "claude",
30 "anthropic",
38 "anthropic",
31 },
39 },
32− Args: cobra.ExactArgs(1),
40+ Args: cobra.MaximumNArgs(1),
33 RunE: func(cmd *cobra.Command, args []string) error {
41 RunE: func(cmd *cobra.Command, args []string) error {
34− if len(args) > 1 {
35− return fmt.Errorf("wrong number of arguments")
36− }
37− if len(args) == 0 || args[0] == "" {
38− return cmd.Help()
39− }
40−
41 app, err := setupAppWithProgressBar(cmd)
42 app, err := setupAppWithProgressBar(cmd)
42 if err != nil {
43 if err != nil {
43 return err
44 return err
44 }
45 }
45 defer app.Shutdown()
46 defer app.Shutdown()
46
47
48+ provider := "hyper"
49+ if len(args) > 0 {
47− switch args[0] {
50+ provider = args[0]
51+ }
52+ switch provider {
53+ case "hyper":
54+ return loginHyper()
48 case "anthropic", "claude":
55 case "anthropic", "claude":
49 return loginClaude()
56 return loginClaude()
50 default:
57 default:
@@ -53,13 +60,73 @@ crush login claude
53 },
60 },
54}
61}
55
62
63+func loginHyper() error {
64+ cfg := config.Get()
65+ if !hyperp.Enabled() {
66+ return fmt.Errorf("hyper not enabled")
67+ }
68+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
69+ defer cancel()
70+
71+ resp, err := hyper.InitiateDeviceAuth(ctx)
72+ if err != nil {
73+ return err
74+ }
75+
76+ if clipboard.WriteAll(resp.UserCode) == nil {
77+ fmt.Println("The following code should be on clipboard already:")
78+ } else {
79+ fmt.Println("Copy the following code:")
80+ }
81+
82+ fmt.Println()
83+ fmt.Println(lipgloss.NewStyle().Bold(true).Render(resp.UserCode))
84+ fmt.Println()
85+ fmt.Println("Press enter to open this URL, and then paste it there:")
86+ fmt.Println()
87+ fmt.Println(lipgloss.NewStyle().Hyperlink(resp.VerificationURL, "id=hyper").Render(resp.VerificationURL))
88+ fmt.Println()
89+ waitEnter()
90+ if err := browser.OpenURL(resp.VerificationURL); err != nil {
91+ fmt.Println("Could not open the URL. You'll need to manually open the URL in your browser.")
92+ }
93+
94+ fmt.Println("Exchanging authorization code...")
95+ refreshToken, err := hyper.PollForToken(ctx, resp.DeviceCode, resp.ExpiresIn)
96+ if err != nil {
97+ return err
98+ }
99+
100+ fmt.Println("Exchanging refresh token for access token...")
101+ token, err := hyper.ExchangeToken(ctx, refreshToken)
102+ if err != nil {
103+ return err
104+ }
105+
106+ fmt.Println("Verifying access token...")
107+ introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
108+ if err != nil {
109+ return fmt.Errorf("token introspection failed: %w", err)
110+ }
111+ if !introspect.Active {
112+ return fmt.Errorf("access token is not active")
113+ }
114+
115+ if err := cmp.Or(
116+ cfg.SetConfigField("providers.hyper.api_key", token.AccessToken),
117+ cfg.SetConfigField("providers.hyper.oauth", token),
118+ ); err != nil {
119+ return err
120+ }
121+
122+ fmt.Println()
123+ fmt.Println("You're now authenticated with Hyper!")
124+ return nil
125+}
126+
56func loginClaude() error {
127func loginClaude() error {
57 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
128 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
58− go func() {
59− <-ctx.Done()
60− cancel()
129+ defer cancel()
61− os.Exit(1)
62− }()
63
130
64 verifier, challenge, err := claude.GetChallenge()
131 verifier, challenge, err := claude.GetChallenge()
65 if err != nil {
132 if err != nil {
@@ -106,3 +173,7 @@ func loginClaude() error {
106 fmt.Println("You're now authenticated with Claude Code Max!")
173 fmt.Println("You're now authenticated with Claude Code Max!")
107 return nil
174 return nil
108}
175}
176+
177+func waitEnter() {
178+ _, _ = fmt.Scanln()
179+}
internal/cmd/update_providers.go+30−8
@@ -10,22 +10,30 @@ import (
10 "github.com/spf13/cobra"
10 "github.com/spf13/cobra"
11)
11)
12
12
13+var updateProvidersSource string
14+
13var updateProvidersCmd = &cobra.Command{
15var updateProvidersCmd = &cobra.Command{
14 Use: "update-providers [path-or-url]",
16 Use: "update-providers [path-or-url]",
15 Short: "Update providers",
17 Short: "Update providers",
16− Long: `Update the list of providers from a specified local path or remote URL.`,
18+ Long: `Update provider information from a specified local path or remote URL.`,
17 Example: `
19 Example: `
18−# Update providers remotely from Catwalk
20+# Update Catwalk providers remotely (default)
19crush update-providers
21crush update-providers
20
22
21−# Update providers from a custom URL
23+# Update Catwalk providers from a custom URL
22−crush update-providers https://example.com/
24+crush update-providers https://example.com/providers.json
23
25
24−# Update providers from a local file
26+# Update Catwalk providers from a local file
25crush update-providers /path/to/local-providers.json
27crush update-providers /path/to/local-providers.json
26
28
27−# Update providers from embedded version
29+# Update Catwalk providers from embedded version
28crush update-providers embedded
30crush update-providers embedded
31+
32+# Update Hyper provider information
33+crush update-providers --source=hyper
34+
35+# Update Hyper from a custom URL
36+crush update-providers --source=hyper https://hyper.example.com
29`,
37`,
30 RunE: func(cmd *cobra.Command, args []string) error {
38 RunE: func(cmd *cobra.Command, args []string) error {
31 // NOTE(@andreynering): We want to skip logging output do stdout here.
39 // NOTE(@andreynering): We want to skip logging output do stdout here.
@@ -36,7 +44,17 @@ crush update-providers embedded
36 pathOrURL = args[0]
44 pathOrURL = args[0]
37 }
45 }
38
46
47+ var err error
48+ switch updateProvidersSource {
49+ case "catwalk":
39− if err := config.UpdateProviders(pathOrURL); err != nil {
50+ err = config.UpdateProviders(pathOrURL)
51+ case "hyper":
52+ err = config.UpdateHyper(pathOrURL)
53+ default:
54+ return fmt.Errorf("invalid source %q, must be 'catwalk' or 'hyper'", updateProvidersSource)
55+ }
56+
57+ if err != nil {
40 return err
58 return err
41 }
59 }
42
60
@@ -52,9 +70,13 @@ crush update-providers embedded
52 SetString("SUCCESS")
70 SetString("SUCCESS")
53 textStyle := lipgloss.NewStyle().
71 textStyle := lipgloss.NewStyle().
54 MarginLeft(2).
72 MarginLeft(2).
55− SetString("Providers updated successfully.")
73+ SetString(fmt.Sprintf("%s provider updated successfully.", updateProvidersSource))
56
74
57 fmt.Printf("%s\n%s\n\n", headerStyle.Render(), textStyle.Render())
75 fmt.Printf("%s\n%s\n\n", headerStyle.Render(), textStyle.Render())
58 return nil
76 return nil
59 },
77 },
60}
78}
79+
80+func init() {
81+ updateProvidersCmd.Flags().StringVar(&updateProvidersSource, "source", "catwalk", "Provider source to update (catwalk or hyper)")
82+}
internal/config/catwalk.gonew file+82
@@ -0,0 +1,82 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/charmbracelet/catwalk/pkg/embedded"
12+)
13+
14+type catwalkClient interface {
15+ GetProviders(context.Context, string) ([]catwalk.Provider, error)
16+}
17+
18+var _ syncer[[]catwalk.Provider] = (*catwalkSync)(nil)
19+
20+type catwalkSync struct {
21+ once sync.Once
22+ result []catwalk.Provider
23+ cache cache[[]catwalk.Provider]
24+ client catwalkClient
25+ autoupdate bool
26+ init atomic.Bool
27+}
28+
29+func (s *catwalkSync) Init(client catwalkClient, path string, autoupdate bool) {
30+ s.client = client
31+ s.cache = newCache[[]catwalk.Provider](path)
32+ s.autoupdate = autoupdate
33+ s.init.Store(true)
34+}
35+
36+func (s *catwalkSync) Get(ctx context.Context) ([]catwalk.Provider, error) {
37+ if !s.init.Load() {
38+ panic("called Get before Init")
39+ }
40+
41+ var throwErr error
42+ s.once.Do(func() {
43+ if !s.autoupdate {
44+ slog.Info("Using embedded Catwalk providers")
45+ s.result = embedded.GetAll()
46+ return
47+ }
48+
49+ cached, etag, cachedErr := s.cache.Get()
50+ if len(cached) == 0 || cachedErr != nil {
51+ // if cached file is empty, default to embedded providers
52+ cached = embedded.GetAll()
53+ }
54+
55+ slog.Info("Fetching providers from Catwalk")
56+ result, err := s.client.GetProviders(ctx, etag)
57+ if errors.Is(err, context.DeadlineExceeded) {
58+ slog.Warn("Catwalk providers not updated in time")
59+ s.result = cached
60+ return
61+ }
62+ if errors.Is(err, catwalk.ErrNotModified) {
63+ slog.Info("Catwalk providers not modified")
64+ s.result = cached
65+ return
66+ }
67+ if err != nil {
68+ // On error, fall back to cached (which defaults to embedded if empty).
69+ s.result = cached
70+ return
71+ }
72+ if len(result) == 0 {
73+ s.result = cached
74+ throwErr = errors.New("empty providers list from catwalk")
75+ return
76+ }
77+
78+ s.result = result
79+ throwErr = s.cache.Store(result)
80+ })
81+ return s.result, throwErr
82+}
internal/config/catwalk_test.gonew file+221
@@ -0,0 +1,221 @@
1+package config
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "testing"
9+
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type mockCatwalkClient struct {
15+ providers []catwalk.Provider
16+ err error
17+ callCount int
18+}
19+
20+func (m *mockCatwalkClient) GetProviders(ctx context.Context, etag string) ([]catwalk.Provider, error) {
21+ m.callCount++
22+ return m.providers, m.err
23+}
24+
25+func TestCatwalkSync_Init(t *testing.T) {
26+ t.Parallel()
27+
28+ syncer := &catwalkSync{}
29+ client := &mockCatwalkClient{}
30+ path := "/tmp/test.json"
31+
32+ syncer.Init(client, path, true)
33+
34+ require.True(t, syncer.init.Load())
35+ require.Equal(t, client, syncer.client)
36+ require.Equal(t, path, syncer.cache.path)
37+ require.True(t, syncer.autoupdate)
38+}
39+
40+func TestCatwalkSync_GetPanicIfNotInit(t *testing.T) {
41+ t.Parallel()
42+
43+ syncer := &catwalkSync{}
44+ require.Panics(t, func() {
45+ _, _ = syncer.Get(t.Context())
46+ })
47+}
48+
49+func TestCatwalkSync_GetWithAutoUpdateDisabled(t *testing.T) {
50+ t.Parallel()
51+
52+ syncer := &catwalkSync{}
53+ client := &mockCatwalkClient{
54+ providers: []catwalk.Provider{{Name: "should-not-be-used"}},
55+ }
56+ path := t.TempDir() + "/providers.json"
57+
58+ syncer.Init(client, path, false)
59+
60+ providers, err := syncer.Get(t.Context())
61+ require.NoError(t, err)
62+ require.NotEmpty(t, providers)
63+ require.Equal(t, 0, client.callCount, "Client should not be called when autoupdate is disabled")
64+
65+ // Should return embedded providers.
66+ for _, p := range providers {
67+ require.NotEqual(t, "should-not-be-used", p.Name)
68+ }
69+}
70+
71+func TestCatwalkSync_GetFreshProviders(t *testing.T) {
72+ t.Parallel()
73+
74+ syncer := &catwalkSync{}
75+ client := &mockCatwalkClient{
76+ providers: []catwalk.Provider{
77+ {Name: "Fresh Provider", ID: "fresh"},
78+ },
79+ }
80+ path := t.TempDir() + "/providers.json"
81+
82+ syncer.Init(client, path, true)
83+
84+ providers, err := syncer.Get(t.Context())
85+ require.NoError(t, err)
86+ require.Len(t, providers, 1)
87+ require.Equal(t, "Fresh Provider", providers[0].Name)
88+ require.Equal(t, 1, client.callCount)
89+
90+ // Verify cache was written.
91+ fileInfo, err := os.Stat(path)
92+ require.NoError(t, err)
93+ require.False(t, fileInfo.IsDir())
94+}
95+
96+func TestCatwalkSync_GetNotModifiedUsesCached(t *testing.T) {
97+ t.Parallel()
98+
99+ tmpDir := t.TempDir()
100+ path := tmpDir + "/providers.json"
101+
102+ // Create cache file.
103+ cachedProviders := []catwalk.Provider{
104+ {Name: "Cached Provider", ID: "cached"},
105+ }
106+ data, err := json.Marshal(cachedProviders)
107+ require.NoError(t, err)
108+ require.NoError(t, os.WriteFile(path, data, 0o644))
109+
110+ syncer := &catwalkSync{}
111+ client := &mockCatwalkClient{
112+ err: catwalk.ErrNotModified,
113+ }
114+
115+ syncer.Init(client, path, true)
116+
117+ providers, err := syncer.Get(t.Context())
118+ require.NoError(t, err)
119+ require.Len(t, providers, 1)
120+ require.Equal(t, "Cached Provider", providers[0].Name)
121+ require.Equal(t, 1, client.callCount)
122+}
123+
124+func TestCatwalkSync_GetEmptyResultFallbackToCached(t *testing.T) {
125+ t.Parallel()
126+
127+ tmpDir := t.TempDir()
128+ path := tmpDir + "/providers.json"
129+
130+ // Create cache file.
131+ cachedProviders := []catwalk.Provider{
132+ {Name: "Cached Provider", ID: "cached"},
133+ }
134+ data, err := json.Marshal(cachedProviders)
135+ require.NoError(t, err)
136+ require.NoError(t, os.WriteFile(path, data, 0o644))
137+
138+ syncer := &catwalkSync{}
139+ client := &mockCatwalkClient{
140+ providers: []catwalk.Provider{}, // Empty result.
141+ }
142+
143+ syncer.Init(client, path, true)
144+
145+ providers, err := syncer.Get(t.Context())
146+ require.Error(t, err)
147+ require.Contains(t, err.Error(), "empty providers list from catwalk")
148+ require.Len(t, providers, 1)
149+ require.Equal(t, "Cached Provider", providers[0].Name)
150+}
151+
152+func TestCatwalkSync_GetEmptyCacheDefaultsToEmbedded(t *testing.T) {
153+ t.Parallel()
154+
155+ tmpDir := t.TempDir()
156+ path := tmpDir + "/providers.json"
157+
158+ // Create empty cache file.
159+ emptyProviders := []catwalk.Provider{}
160+ data, err := json.Marshal(emptyProviders)
161+ require.NoError(t, err)
162+ require.NoError(t, os.WriteFile(path, data, 0o644))
163+
164+ syncer := &catwalkSync{}
165+ client := &mockCatwalkClient{
166+ err: errors.New("network error"),
167+ }
168+
169+ syncer.Init(client, path, true)
170+
171+ providers, err := syncer.Get(t.Context())
172+ require.NoError(t, err)
173+ require.NotEmpty(t, providers, "Should fall back to embedded providers")
174+
175+ // Verify it's embedded providers by checking we have multiple common ones.
176+ require.Greater(t, len(providers), 5)
177+}
178+
179+func TestCatwalkSync_GetClientError(t *testing.T) {
180+ t.Parallel()
181+
182+ tmpDir := t.TempDir()
183+ path := tmpDir + "/providers.json"
184+
185+ syncer := &catwalkSync{}
186+ client := &mockCatwalkClient{
187+ err: errors.New("network error"),
188+ }
189+
190+ syncer.Init(client, path, true)
191+
192+ providers, err := syncer.Get(t.Context())
193+ require.NoError(t, err) // Should fall back to embedded.
194+ require.NotEmpty(t, providers)
195+}
196+
197+func TestCatwalkSync_GetCalledMultipleTimesUsesOnce(t *testing.T) {
198+ t.Parallel()
199+
200+ syncer := &catwalkSync{}
201+ client := &mockCatwalkClient{
202+ providers: []catwalk.Provider{
203+ {Name: "Provider", ID: "test"},
204+ },
205+ }
206+ path := t.TempDir() + "/providers.json"
207+
208+ syncer.Init(client, path, true)
209+
210+ // Call Get multiple times.
211+ providers1, err1 := syncer.Get(t.Context())
212+ require.NoError(t, err1)
213+ require.Len(t, providers1, 1)
214+
215+ providers2, err2 := syncer.Get(t.Context())
216+ require.NoError(t, err2)
217+ require.Len(t, providers2, 1)
218+
219+ // Client should only be called once due to sync.Once.
220+ require.Equal(t, 1, client.callCount)
221+}
internal/config/config.go+14−7
@@ -13,10 +13,12 @@ import (
13 "time"
13 "time"
14
14
15 "github.com/charmbracelet/catwalk/pkg/catwalk"
15 "github.com/charmbracelet/catwalk/pkg/catwalk"
16+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
16 "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/env"
18 "github.com/charmbracelet/crush/internal/env"
18 "github.com/charmbracelet/crush/internal/oauth"
19 "github.com/charmbracelet/crush/internal/oauth"
19 "github.com/charmbracelet/crush/internal/oauth/claude"
20 "github.com/charmbracelet/crush/internal/oauth/claude"
21+ "github.com/charmbracelet/crush/internal/oauth/hyper"
20 "github.com/invopop/jsonschema"
22 "github.com/invopop/jsonschema"
21 "github.com/tidwall/sjson"
23 "github.com/tidwall/sjson"
22)
24)
@@ -120,7 +122,6 @@ type ProviderConfig struct {
120}
122}
121
123
122func (pc *ProviderConfig) SetupClaudeCode() {
124func (pc *ProviderConfig) SetupClaudeCode() {
123− pc.APIKey = fmt.Sprintf("Bearer %s", pc.OAuthToken.AccessToken)
124 pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
125 pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
125 pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
126 pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
126
127
@@ -483,12 +484,16 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
483 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
484 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
484 }
485 }
485
486
486− // Only Anthropic provider uses OAuth for now.
487+ var newToken *oauth.Token
488+ var err error
489+ switch providerID {
487− if providerID != string(catwalk.InferenceProviderAnthropic) {
490+ case string(catwalk.InferenceProviderAnthropic):
491+ newToken, err = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
492+ case hyperp.Name:
493+ newToken, err = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
494+ default:
488 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
495 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
489 }
496 }
490−
491− newToken, err := claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
492 if err != nil {
497 if err != nil {
493 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
498 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
494 }
499 }
@@ -529,9 +534,11 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
529 return err
534 return err
530 }
535 }
531 setKeyOrToken = func() {
536 setKeyOrToken = func() {
532− providerConfig.APIKey = v.AccessToken
537+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
533 providerConfig.OAuthToken = v
538 providerConfig.OAuthToken = v
539+ if providerID == string(catwalk.InferenceProviderAnthropic) {
534− providerConfig.SetupClaudeCode()
540+ providerConfig.SetupClaudeCode()
541+ }
535 }
542 }
536 }
543 }
537
544
internal/config/hyper.gonew file+124
@@ -0,0 +1,124 @@
1+package config
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "log/slog"
9+ "net/http"
10+ "sync"
11+ "sync/atomic"
12+ "time"
13+
14+ "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ "github.com/charmbracelet/crush/internal/agent/hyper"
16+ xetag "github.com/charmbracelet/x/etag"
17+)
18+
19+type hyperClient interface {
20+ Get(context.Context, string) (catwalk.Provider, error)
21+}
22+
23+var _ syncer[catwalk.Provider] = (*hyperSync)(nil)
24+
25+type hyperSync struct {
26+ once sync.Once
27+ result catwalk.Provider
28+ cache cache[catwalk.Provider]
29+ client hyperClient
30+ autoupdate bool
31+ init atomic.Bool
32+}
33+
34+func (s *hyperSync) Init(client hyperClient, path string, autoupdate bool) {
35+ s.client = client
36+ s.cache = newCache[catwalk.Provider](path)
37+ s.autoupdate = autoupdate
38+ s.init.Store(true)
39+}
40+
41+func (s *hyperSync) Get(ctx context.Context) (catwalk.Provider, error) {
42+ if !s.init.Load() {
43+ panic("called Get before Init")
44+ }
45+
46+ var throwErr error
47+ s.once.Do(func() {
48+ if !s.autoupdate {
49+ slog.Info("Using embedded Hyper provider")
50+ s.result = hyper.Embedded()
51+ return
52+ }
53+
54+ cached, etag, cachedErr := s.cache.Get()
55+ if cached.ID == "" || cachedErr != nil {
56+ // if cached file is empty, default to embedded provider
57+ cached = hyper.Embedded()
58+ }
59+
60+ slog.Info("Fetching Hyper provider")
61+ result, err := s.client.Get(ctx, etag)
62+ if errors.Is(err, context.DeadlineExceeded) {
63+ slog.Warn("Hyper provider not updated in time")
64+ s.result = cached
65+ return
66+ }
67+ if errors.Is(err, catwalk.ErrNotModified) {
68+ slog.Info("Hyper provider not modified")
69+ s.result = cached
70+ return
71+ }
72+ if len(result.Models) == 0 {
73+ slog.Warn("Hyper did not return any models")
74+ s.result = cached
75+ return
76+ }
77+
78+ s.result = result
79+ throwErr = s.cache.Store(result)
80+ })
81+ return s.result, throwErr
82+}
83+
84+var _ hyperClient = realHyperClient{}
85+
86+type realHyperClient struct {
87+ baseURL string
88+}
89+
90+// Get implements hyperClient.
91+func (r realHyperClient) Get(ctx context.Context, etag string) (catwalk.Provider, error) {
92+ var result catwalk.Provider
93+ req, err := http.NewRequestWithContext(
94+ ctx,
95+ http.MethodGet,
96+ r.baseURL+"/api/v1/provider",
97+ nil,
98+ )
99+ if err != nil {
100+ return result, fmt.Errorf("could not create request: %w", err)
101+ }
102+ xetag.Request(req, etag)
103+
104+ client := &http.Client{Timeout: 30 * time.Second}
105+ resp, err := client.Do(req)
106+ if err != nil {
107+ return result, fmt.Errorf("failed to make request: %w", err)
108+ }
109+ defer resp.Body.Close() //nolint:errcheck
110+
111+ if resp.StatusCode == http.StatusNotModified {
112+ return result, catwalk.ErrNotModified
113+ }
114+
115+ if resp.StatusCode != http.StatusOK {
116+ return result, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
117+ }
118+
119+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
120+ return result, fmt.Errorf("failed to decode response: %w", err)
121+ }
122+
123+ return result, nil
124+}
internal/config/hyper_test.gonew file+205
@@ -0,0 +1,205 @@
1+package config
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "testing"
9+
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type mockHyperClient struct {
15+ provider catwalk.Provider
16+ err error
17+ callCount int
18+}
19+
20+func (m *mockHyperClient) Get(ctx context.Context, etag string) (catwalk.Provider, error) {
21+ m.callCount++
22+ return m.provider, m.err
23+}
24+
25+func TestHyperSync_Init(t *testing.T) {
26+ t.Parallel()
27+
28+ syncer := &hyperSync{}
29+ client := &mockHyperClient{}
30+ path := "/tmp/hyper.json"
31+
32+ syncer.Init(client, path, true)
33+
34+ require.True(t, syncer.init.Load())
35+ require.Equal(t, client, syncer.client)
36+ require.Equal(t, path, syncer.cache.path)
37+}
38+
39+func TestHyperSync_GetPanicIfNotInit(t *testing.T) {
40+ t.Parallel()
41+
42+ syncer := &hyperSync{}
43+ require.Panics(t, func() {
44+ _, _ = syncer.Get(t.Context())
45+ })
46+}
47+
48+func TestHyperSync_GetFreshProvider(t *testing.T) {
49+ t.Parallel()
50+
51+ syncer := &hyperSync{}
52+ client := &mockHyperClient{
53+ provider: catwalk.Provider{
54+ Name: "Hyper",
55+ ID: "hyper",
56+ Models: []catwalk.Model{
57+ {ID: "model-1", Name: "Model 1"},
58+ },
59+ },
60+ }
61+ path := t.TempDir() + "/hyper.json"
62+
63+ syncer.Init(client, path, true)
64+
65+ provider, err := syncer.Get(t.Context())
66+ require.NoError(t, err)
67+ require.Equal(t, "Hyper", provider.Name)
68+ require.Equal(t, 1, client.callCount)
69+
70+ // Verify cache was written.
71+ fileInfo, err := os.Stat(path)
72+ require.NoError(t, err)
73+ require.False(t, fileInfo.IsDir())
74+}
75+
76+func TestHyperSync_GetNotModifiedUsesCached(t *testing.T) {
77+ t.Parallel()
78+
79+ tmpDir := t.TempDir()
80+ path := tmpDir + "/hyper.json"
81+
82+ // Create cache file.
83+ cachedProvider := catwalk.Provider{
84+ Name: "Cached Hyper",
85+ ID: "hyper",
86+ }
87+ data, err := json.Marshal(cachedProvider)
88+ require.NoError(t, err)
89+ require.NoError(t, os.WriteFile(path, data, 0o644))
90+
91+ syncer := &hyperSync{}
92+ client := &mockHyperClient{
93+ err: catwalk.ErrNotModified,
94+ }
95+
96+ syncer.Init(client, path, true)
97+
98+ provider, err := syncer.Get(t.Context())
99+ require.NoError(t, err)
100+ require.Equal(t, "Cached Hyper", provider.Name)
101+ require.Equal(t, 1, client.callCount)
102+}
103+
104+func TestHyperSync_GetClientError(t *testing.T) {
105+ t.Parallel()
106+
107+ tmpDir := t.TempDir()
108+ path := tmpDir + "/hyper.json"
109+
110+ syncer := &hyperSync{}
111+ client := &mockHyperClient{
112+ err: errors.New("network error"),
113+ }
114+
115+ syncer.Init(client, path, true)
116+
117+ provider, err := syncer.Get(t.Context())
118+ require.NoError(t, err) // Should fall back to embedded.
119+ require.Equal(t, "Charm Hyper", provider.Name)
120+ require.Equal(t, catwalk.InferenceProvider("hyper"), provider.ID)
121+}
122+
123+func TestHyperSync_GetEmptyCache(t *testing.T) {
124+ t.Parallel()
125+
126+ tmpDir := t.TempDir()
127+ path := tmpDir + "/hyper.json"
128+
129+ syncer := &hyperSync{}
130+ client := &mockHyperClient{
131+ provider: catwalk.Provider{
132+ Name: "Fresh Hyper",
133+ ID: "hyper",
134+ Models: []catwalk.Model{
135+ {ID: "model-1", Name: "Model 1"},
136+ },
137+ },
138+ }
139+
140+ syncer.Init(client, path, true)
141+
142+ provider, err := syncer.Get(t.Context())
143+ require.NoError(t, err)
144+ require.Equal(t, "Fresh Hyper", provider.Name)
145+}
146+
147+func TestHyperSync_GetCalledMultipleTimesUsesOnce(t *testing.T) {
148+ t.Parallel()
149+
150+ syncer := &hyperSync{}
151+ client := &mockHyperClient{
152+ provider: catwalk.Provider{
153+ Name: "Hyper",
154+ ID: "hyper",
155+ Models: []catwalk.Model{
156+ {ID: "model-1", Name: "Model 1"},
157+ },
158+ },
159+ }
160+ path := t.TempDir() + "/hyper.json"
161+
162+ syncer.Init(client, path, true)
163+
164+ // Call Get multiple times.
165+ provider1, err1 := syncer.Get(t.Context())
166+ require.NoError(t, err1)
167+ require.Equal(t, "Hyper", provider1.Name)
168+
169+ provider2, err2 := syncer.Get(t.Context())
170+ require.NoError(t, err2)
171+ require.Equal(t, "Hyper", provider2.Name)
172+
173+ // Client should only be called once due to sync.Once.
174+ require.Equal(t, 1, client.callCount)
175+}
176+
177+func TestHyperSync_GetCacheStoreError(t *testing.T) {
178+ t.Parallel()
179+
180+ // Create a file where we want a directory, causing mkdir to fail.
181+ tmpDir := t.TempDir()
182+ blockingFile := tmpDir + "/blocking"
183+ require.NoError(t, os.WriteFile(blockingFile, []byte("block"), 0o644))
184+
185+ // Try to create cache in a subdirectory under the blocking file.
186+ path := blockingFile + "/subdir/hyper.json"
187+
188+ syncer := &hyperSync{}
189+ client := &mockHyperClient{
190+ provider: catwalk.Provider{
191+ Name: "Hyper",
192+ ID: "hyper",
193+ Models: []catwalk.Model{
194+ {ID: "model-1", Name: "Model 1"},
195+ },
196+ },
197+ }
198+
199+ syncer.Init(client, path, true)
200+
201+ provider, err := syncer.Get(t.Context())
202+ require.Error(t, err)
203+ require.Contains(t, err.Error(), "failed to create directory for provider cache")
204+ require.Equal(t, "Hyper", provider.Name) // Provider is still returned.
205+}
internal/config/load.go+2−1
@@ -17,6 +17,7 @@ import (
17 "testing"
17 "testing"
18
18
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
20 "github.com/charmbracelet/crush/internal/csync"
21 "github.com/charmbracelet/crush/internal/csync"
21 "github.com/charmbracelet/crush/internal/env"
22 "github.com/charmbracelet/crush/internal/env"
22 "github.com/charmbracelet/crush/internal/fsext"
23 "github.com/charmbracelet/crush/internal/fsext"
@@ -271,7 +272,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
271 if providerConfig.Type == "" {
272 if providerConfig.Type == "" {
272 providerConfig.Type = catwalk.TypeOpenAICompat
273 providerConfig.Type = catwalk.TypeOpenAICompat
273 }
274 }
274− if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) {
275+ if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
275 slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
276 slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
276 c.Providers.Del(id)
277 c.Providers.Del(id)
277 continue
278 continue
internal/config/provider.go+130−74
@@ -10,16 +10,21 @@ import (
10 "os"
10 "os"
11 "path/filepath"
11 "path/filepath"
12 "runtime"
12 "runtime"
13+ "slices"
13 "strings"
14 "strings"
14 "sync"
15 "sync"
16+ "time"
15
17
16 "github.com/charmbracelet/catwalk/pkg/catwalk"
18 "github.com/charmbracelet/catwalk/pkg/catwalk"
17 "github.com/charmbracelet/catwalk/pkg/embedded"
19 "github.com/charmbracelet/catwalk/pkg/embedded"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
21+ "github.com/charmbracelet/crush/internal/csync"
18 "github.com/charmbracelet/crush/internal/home"
22 "github.com/charmbracelet/crush/internal/home"
23+ "github.com/charmbracelet/x/etag"
19)
24)
20
25
21−type ProviderClient interface {
26+type syncer[T any] interface {
22− GetProviders(context.Context, string) ([]catwalk.Provider, error)
27+ Get(context.Context) (T, error)
23}
28}
24
29
25var (
30var (
@@ -29,10 +34,10 @@ var (
29)
34)
30
35
31// file to cache provider data
36// file to cache provider data
32−func providerCacheFileData() string {
37+func cachePathFor(name string) string {
33 xdgDataHome := os.Getenv("XDG_DATA_HOME")
38 xdgDataHome := os.Getenv("XDG_DATA_HOME")
34 if xdgDataHome != "" {
39 if xdgDataHome != "" {
35− return filepath.Join(xdgDataHome, appName, "providers.json")
40+ return filepath.Join(xdgDataHome, appName, name+".json")
36 }
41 }
37
42
38 // return the path to the main data directory
43 // return the path to the main data directory
@@ -43,43 +48,13 @@ func providerCacheFileData() string {
43 if localAppData == "" {
48 if localAppData == "" {
44 localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
49 localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
45 }
50 }
46− return filepath.Join(localAppData, appName, "providers.json")
51+ return filepath.Join(localAppData, appName, name+".json")
47 }
52 }
48
53
49− return filepath.Join(home.Dir(), ".local", "share", appName, "providers.json")
54+ return filepath.Join(home.Dir(), ".local", "share", appName, name+".json")
50−}
51−
52−func saveProvidersInCache(path string, providers []catwalk.Provider) error {
53− slog.Info("Saving provider data to disk", "path", path)
54− if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
55− return fmt.Errorf("failed to create directory for provider cache: %w", err)
56− }
57−
58− data, err := json.Marshal(providers)
59− if err != nil {
60− return fmt.Errorf("failed to marshal provider data: %w", err)
61− }
62−
63− if err := os.WriteFile(path, data, 0o644); err != nil {
64− return fmt.Errorf("failed to write provider data to cache: %w", err)
65− }
66− return nil
67−}
68−
69−func loadProvidersFromCache(path string) ([]catwalk.Provider, string, error) {
70− data, err := os.ReadFile(path)
71− if err != nil {
72− return nil, "", fmt.Errorf("failed to read provider cache file: %w", err)
73− }
74−
75− var providers []catwalk.Provider
76− if err := json.Unmarshal(data, &providers); err != nil {
77− return nil, "", fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
78− }
79−
80− return providers, catwalk.Etag(data), nil
81}
55}
82
56
57+// UpdateProviders updates the Catwalk providers list from a specified source.
83func UpdateProviders(pathOrURL string) error {
58func UpdateProviders(pathOrURL string) error {
84 var providers []catwalk.Provider
59 var providers []catwalk.Provider
85 pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
60 pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
@@ -106,15 +81,55 @@ func UpdateProviders(pathOrURL string) error {
106 }
81 }
107 }
82 }
108
83
109− cachePath := providerCacheFileData()
84+ if err := newCache[[]catwalk.Provider](cachePathFor("providers")).Store(providers); err != nil {
110− if err := saveProvidersInCache(cachePath, providers); err != nil {
111 return fmt.Errorf("failed to save providers to cache: %w", err)
85 return fmt.Errorf("failed to save providers to cache: %w", err)
112 }
86 }
113
87
114− slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrURL, "to", cachePath)
88+ slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrURL, "to", cachePathFor)
89+ return nil
90+}
91+
92+// UpdateHyper updates the Hyper provider information from a specified URL.
93+func UpdateHyper(pathOrURL string) error {
94+ if !hyper.Enabled() {
95+ return fmt.Errorf("hyper not enabled")
96+ }
97+ var provider catwalk.Provider
98+ pathOrURL = cmp.Or(pathOrURL, hyper.BaseURL())
99+
100+ switch {
101+ case pathOrURL == "embedded":
102+ provider = hyper.Embedded()
103+ case strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://"):
104+ client := realHyperClient{baseURL: pathOrURL}
105+ var err error
106+ provider, err = client.Get(context.Background(), "")
107+ if err != nil {
108+ return fmt.Errorf("failed to fetch provider from Hyper: %w", err)
109+ }
110+ default:
111+ content, err := os.ReadFile(pathOrURL)
112+ if err != nil {
113+ return fmt.Errorf("failed to read file: %w", err)
114+ }
115+ if err := json.Unmarshal(content, &provider); err != nil {
116+ return fmt.Errorf("failed to unmarshal provider data: %w", err)
117+ }
118+ }
119+
120+ if err := newCache[catwalk.Provider](cachePathFor("hyper")).Store(provider); err != nil {
121+ return fmt.Errorf("failed to save Hyper provider to cache: %w", err)
122+ }
123+
124+ slog.Info("Hyper provider updated successfully", "from", pathOrURL, "to", cachePathFor("hyper"))
115 return nil
125 return nil
116}
126}
117
127
128+var (
129+ catwalkSyncer = &catwalkSync{}
130+ hyperSyncer = &hyperSync{}
131+)
132+
118// Providers returns the list of providers, taking into account cached results
133// Providers returns the list of providers, taking into account cached results
119// and whether or not auto update is enabled.
134// and whether or not auto update is enabled.
120//
135//
@@ -126,46 +141,87 @@ func UpdateProviders(pathOrURL string) error {
126// the cached list, or the embedded list if all others fail.
141// the cached list, or the embedded list if all others fail.
127func Providers(cfg *Config) ([]catwalk.Provider, error) {
142func Providers(cfg *Config) ([]catwalk.Provider, error) {
128 providerOnce.Do(func() {
143 providerOnce.Do(func() {
129− catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
144+ var wg sync.WaitGroup
130− client := catwalk.NewWithURL(catwalkURL)
145+ var errs []error
131− path := providerCacheFileData()
146+ providers := csync.NewSlice[catwalk.Provider]()
132−
133− if cfg.Options.DisableProviderAutoUpdate {
147+ autoupdate := !cfg.Options.DisableProviderAutoUpdate
134− slog.Info("Using embedded Catwalk providers")
148+
135− providerList, providerErr = embedded.GetAll(), nil
149+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
150+ defer cancel()
151+
152+ wg.Go(func() {
153+ catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
154+ client := catwalk.NewWithURL(catwalkURL)
155+ path := cachePathFor("providers")
156+ catwalkSyncer.Init(client, path, autoupdate)
157+
158+ items, err := catwalkSyncer.Get(ctx)
159+ if err != nil {
160+ catwalkURL := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
161+ errs = append(errs, 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
162+ return
163+ }
164+ providers.Append(items...)
165+ })
166+
167+ wg.Go(func() {
168+ if !hyper.Enabled() {
169+ return
170+ }
171+ path := cachePathFor("hyper")
172+ hyperSyncer.Init(realHyperClient{baseURL: hyper.BaseURL()}, path, autoupdate)
173+
174+ item, err := hyperSyncer.Get(ctx)
175+ if err != nil {
176+ errs = append(errs, fmt.Errorf("Crush was unable to fetch updated information from Hyper: %w", err)) //nolint:staticcheck
136− return
177+ return
178+ }
179+ providers.Append(item)
137− }
180+ })
181+
182+ wg.Wait()
183+
184+ providerList = slices.Collect(providers.Seq())
185+ providerErr = errors.Join(errs...)
186+ })
187+ return providerList, providerErr
188+}
138
189
139− cached, etag, cachedErr := loadProvidersFromCache(path)
190+type cache[T any] struct {
140− if len(cached) == 0 || cachedErr != nil {
191+ path string
141− // if cached file is empty, default to embedded providers
142− cached = embedded.GetAll()
143− }
192+}
144
193
145− providerList, providerErr = loadProviders(client, etag, path)
194+func newCache[T any](path string) cache[T] {
146− if errors.Is(providerErr, catwalk.ErrNotModified) {
195+ return cache[T]{path: path}
147− slog.Info("Catwalk providers not modified")
148− providerList, providerErr = cached, nil
149− }
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}
196}
157
197
158−func loadProviders(client ProviderClient, etag, path string) ([]catwalk.Provider, error) {
198+func (c cache[T]) Get() (T, string, error) {
159− slog.Info("Fetching providers from Catwalk.", "path", path)
199+ var v T
160− providers, err := client.GetProviders(context.Background(), etag)
200+ data, err := os.ReadFile(c.path)
161 if err != nil {
201 if err != nil {
162− return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
202+ return v, "", fmt.Errorf("failed to read provider cache file: %w", err)
203+ }
204+
205+ if err := json.Unmarshal(data, &v); err != nil {
206+ return v, "", fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
207+ }
208+
209+ return v, etag.Of(data), nil
210+}
211+
212+func (c cache[T]) Store(v T) error {
213+ slog.Info("Saving provider data to disk", "path", c.path)
214+ if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
215+ return fmt.Errorf("failed to create directory for provider cache: %w", err)
163 }
216 }
164− if len(providers) == 0 {
217+
165− return nil, errors.New("empty providers list from catwalk")
218+ data, err := json.Marshal(v)
219+ if err != nil {
220+ return fmt.Errorf("failed to marshal provider data: %w", err)
166 }
221 }
167− if err := saveProvidersInCache(path, providers); err != nil {
222+
168− return nil, err
223+ if err := os.WriteFile(c.path, data, 0o644); err != nil {
224+ return fmt.Errorf("failed to write provider data to cache: %w", err)
169 }
225 }
170− return providers, nil
226+ return nil
171}
227}
internal/config/provider_empty_test.go+18−10
@@ -2,6 +2,7 @@ package config
2
2
3import (
3import (
4 "context"
4 "context"
5+ "os"
5 "testing"
6 "testing"
6
7
7 "github.com/charmbracelet/catwalk/pkg/catwalk"
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -14,18 +15,25 @@ func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.P
14 return []catwalk.Provider{}, nil
15 return []catwalk.Provider{}, nil
15}
16}
16
17
17−// TestProvider_loadProvidersEmptyResult tests that loadProviders returns an
18+// TestCatwalkSync_GetEmptyResultFromClient tests that when the client returns
18−// error when the client returns an empty list. This ensures we don't cache
19+// an empty list, we fall back to cached providers and return an error.
19−// empty provider lists.
20+func TestCatwalkSync_GetEmptyResultFromClient(t *testing.T) {
20−func TestProvider_loadProvidersEmptyResult(t *testing.T) {
21+ t.Parallel()
22+
23+ tmpDir := t.TempDir()
24+ path := tmpDir + "/providers.json"
25+
26+ syncer := &catwalkSync{}
21 client := &emptyProviderClient{}
27 client := &emptyProviderClient{}
22− tmpPath := t.TempDir() + "/providers.json"
23
28
24− providers, err := loadProviders(client, "", tmpPath)
29+ syncer.Init(client, path, true)
30+
31+ providers, err := syncer.Get(t.Context())
32+ require.Error(t, err)
25 require.Contains(t, err.Error(), "empty providers list from catwalk")
33 require.Contains(t, err.Error(), "empty providers list from catwalk")
26− require.Empty(t, providers)
34+ require.NotEmpty(t, providers) // Should have embedded providers as fallback.
27− require.Len(t, providers, 0)
28
35
29− // Check that no cache file was created for empty results
36+ // Check that no cache file was created for empty results.
37+ _, statErr := os.Stat(path)
30− require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
38+ require.True(t, os.IsNotExist(statErr), "Cache file should not exist for empty results")
31}
39}
internal/config/provider_test.go+240−76
@@ -1,10 +1,9 @@
1package config
1package config
2
2
3import (
3import (
4− "context"
5 "encoding/json"
4 "encoding/json"
6− "errors"
7 "os"
5 "os"
6+ "path/filepath"
8 "sync"
7 "sync"
9 "testing"
8 "testing"
10
9
@@ -12,47 +11,31 @@ import (
12 "github.com/stretchr/testify/require"
11 "github.com/stretchr/testify/require"
13)
12)
14
13
15−type mockProviderClient struct {
16− shouldFail bool
17− shouldReturnErr error
18−}
19−
20−func (m *mockProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
21− if m.shouldReturnErr != nil {
22− return nil, m.shouldReturnErr
23− }
24− if m.shouldFail {
25− return nil, errors.New("failed to load providers")
26− }
27− return []catwalk.Provider{
28− {
29− Name: "Mock",
30− },
31− }, nil
32−}
33−
34func resetProviderState() {
14func resetProviderState() {
35 providerOnce = sync.Once{}
15 providerOnce = sync.Once{}
36 providerList = nil
16 providerList = nil
37 providerErr = nil
17 providerErr = nil
18+ catwalkSyncer = &catwalkSync{}
19+ hyperSyncer = &hyperSync{}
38}
20}
39
21
40−func TestProvider_loadProvidersNoIssues(t *testing.T) {
22+func TestProviders_Integration_AutoUpdateDisabled(t *testing.T) {
41− client := &mockProviderClient{shouldFail: false}
23+ tmpDir := t.TempDir()
42− tmpPath := t.TempDir() + "/providers.json"
24+ t.Setenv("XDG_DATA_HOME", tmpDir)
43− providers, err := loadProviders(client, "", tmpPath)
44− require.NoError(t, err)
45− require.NotNil(t, providers)
46− require.Len(t, providers, 1)
47
25
48− // check if file got saved
26+ // Use a test-specific instance to avoid global state interference.
49− fileInfo, err := os.Stat(tmpPath)
27+ testCatwalkSyncer := &catwalkSync{}
50− require.NoError(t, err)
28+ testHyperSyncer := &hyperSync{}
51− require.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
29+
30+ originalCatwalSyncer := catwalkSyncer
31+ originalHyperSyncer := hyperSyncer
32+ defer func() {
33+ catwalkSyncer = originalCatwalSyncer
34+ hyperSyncer = originalHyperSyncer
52−}
35+ }()
53
36
54−func TestProvider_DisableAutoUpdate(t *testing.T) {
37+ catwalkSyncer = testCatwalkSyncer
55− t.Setenv("XDG_DATA_HOME", t.TempDir())
38+ hyperSyncer = testHyperSyncer
56
39
57 resetProviderState()
40 resetProviderState()
58 defer resetProviderState()
41 defer resetProviderState()
@@ -69,76 +52,257 @@ func TestProvider_DisableAutoUpdate(t *testing.T) {
69 require.Greater(t, len(providers), 5, "Expected embedded providers")
52 require.Greater(t, len(providers), 5, "Expected embedded providers")
70}
53}
71
54
72−func TestProvider_WithValidCache(t *testing.T) {
55+func TestProviders_Integration_WithMockClients(t *testing.T) {
73 tmpDir := t.TempDir()
56 tmpDir := t.TempDir()
74 t.Setenv("XDG_DATA_HOME", tmpDir)
57 t.Setenv("XDG_DATA_HOME", tmpDir)
75
58
76− resetProviderState()
59+ // Create fresh syncers for this test.
77− defer resetProviderState()
60+ testCatwalkSyncer := &catwalkSync{}
61+ testHyperSyncer := &hyperSync{}
62+
63+ // Initialize with mock clients.
64+ mockCatwalkClient := &mockCatwalkClient{
65+ providers: []catwalk.Provider{
66+ {Name: "Provider1", ID: "p1"},
67+ {Name: "Provider2", ID: "p2"},
68+ },
69+ }
70+ mockHyperClient := &mockHyperClient{
71+ provider: catwalk.Provider{
72+ Name: "Hyper",
73+ ID: "hyper",
74+ Models: []catwalk.Model{
75+ {ID: "hyper-1", Name: "Hyper Model"},
76+ },
77+ },
78+ }
79+
80+ catwalkPath := tmpDir + "/crush/providers.json"
81+ hyperPath := tmpDir + "/crush/hyper.json"
82+
83+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
84+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
85+
86+ // Get providers from each syncer.
87+ catwalkProviders, err := testCatwalkSyncer.Get(t.Context())
88+ require.NoError(t, err)
89+ require.Len(t, catwalkProviders, 2)
90+
91+ hyperProvider, err := testHyperSyncer.Get(t.Context())
92+ require.NoError(t, err)
93+ require.Equal(t, "Hyper", hyperProvider.Name)
94+
95+ // Verify total.
96+ allProviders := append(catwalkProviders, hyperProvider)
97+ require.Len(t, allProviders, 3)
98+}
99+
100+func TestProviders_Integration_WithCachedData(t *testing.T) {
101+ tmpDir := t.TempDir()
102+ t.Setenv("XDG_DATA_HOME", tmpDir)
103+
104+ // Create cache files.
105+ catwalkPath := tmpDir + "/crush/providers.json"
106+ hyperPath := tmpDir + "/crush/hyper.json"
78
107
79− cachePath := tmpDir + "/crush/providers.json"
80 require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
108 require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
109+
110+ // Write Catwalk cache.
81− cachedProviders := []catwalk.Provider{
111+ catwalkProviders := []catwalk.Provider{
112+ {Name: "Cached1", ID: "c1"},
113+ {Name: "Cached2", ID: "c2"},
114+ }
115+ data, err := json.Marshal(catwalkProviders)
116+ require.NoError(t, err)
117+ require.NoError(t, os.WriteFile(catwalkPath, data, 0o644))
118+
119+ // Write Hyper cache.
120+ hyperProvider := catwalk.Provider{
82− {Name: "Cached"},
121+ Name: "Cached Hyper",
122+ ID: "hyper",
83 }
123 }
84− data, err := json.Marshal(cachedProviders)
124+ data, err = json.Marshal(hyperProvider)
85 require.NoError(t, err)
125 require.NoError(t, err)
86− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
126+ require.NoError(t, os.WriteFile(hyperPath, data, 0o644))
127+
128+ // Create fresh syncers.
129+ testCatwalkSyncer := &catwalkSync{}
130+ testHyperSyncer := &hyperSync{}
131+
132+ // Mock clients that return ErrNotModified.
133+ mockCatwalkClient := &mockCatwalkClient{
134+ err: catwalk.ErrNotModified,
135+ }
136+ mockHyperClient := &mockHyperClient{
137+ err: catwalk.ErrNotModified,
138+ }
87
139
88− mockClient := &mockProviderClient{shouldFail: false}
140+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
141+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
89
142
90− providers, err := loadProviders(mockClient, "", cachePath)
143+ // Get providers - should use cached.
144+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
91 require.NoError(t, err)
145 require.NoError(t, err)
92− require.NotNil(t, providers)
146+ require.Len(t, catwalkResult, 2)
93− require.Len(t, providers, 1)
147+ require.Equal(t, "Cached1", catwalkResult[0].Name)
94− require.Equal(t, "Mock", providers[0].Name, "Expected fresh provider from fetch")
148+
149+ hyperResult, err := testHyperSyncer.Get(t.Context())
150+ require.NoError(t, err)
151+ require.Equal(t, "Cached Hyper", hyperResult.Name)
95}
152}
96
153
97−func TestProvider_NotModifiedUsesCached(t *testing.T) {
154+func TestProviders_Integration_CatwalkFailsHyperSucceeds(t *testing.T) {
98 tmpDir := t.TempDir()
155 tmpDir := t.TempDir()
99 t.Setenv("XDG_DATA_HOME", tmpDir)
156 t.Setenv("XDG_DATA_HOME", tmpDir)
100
157
101− resetProviderState()
158+ testCatwalkSyncer := &catwalkSync{}
102− defer resetProviderState()
159+ testHyperSyncer := &hyperSync{}
103
160
104− cachePath := tmpDir + "/crush/providers.json"
161+ // Catwalk fails, Hyper succeeds.
105− require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
162+ mockCatwalkClient := &mockCatwalkClient{
106− cachedProviders := []catwalk.Provider{
163+ err: catwalk.ErrNotModified, // Will use embedded.
107− {Name: "Cached"},
108 }
164 }
109− data, err := json.Marshal(cachedProviders)
165+ mockHyperClient := &mockHyperClient{
166+ provider: catwalk.Provider{
167+ Name: "Hyper",
168+ ID: "hyper",
169+ Models: []catwalk.Model{
170+ {ID: "hyper-1", Name: "Hyper Model"},
171+ },
172+ },
173+ }
174+
175+ catwalkPath := tmpDir + "/crush/providers.json"
176+ hyperPath := tmpDir + "/crush/hyper.json"
177+
178+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
179+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
180+
181+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
110 require.NoError(t, err)
182 require.NoError(t, err)
111− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
183+ require.NotEmpty(t, catwalkResult) // Should have embedded.
112
184
113− mockClient := &mockProviderClient{shouldReturnErr: catwalk.ErrNotModified}
185+ hyperResult, err := testHyperSyncer.Get(t.Context())
114− providers, err := loadProviders(mockClient, "", cachePath)
186+ require.NoError(t, err)
115− require.ErrorIs(t, err, catwalk.ErrNotModified)
187+ require.Equal(t, "Hyper", hyperResult.Name)
116− require.Nil(t, providers)
117}
188}
118
189
119−func TestProvider_EmptyCacheDefaultsToEmbedded(t *testing.T) {
190+func TestProviders_Integration_BothFail(t *testing.T) {
120 tmpDir := t.TempDir()
191 tmpDir := t.TempDir()
121 t.Setenv("XDG_DATA_HOME", tmpDir)
192 t.Setenv("XDG_DATA_HOME", tmpDir)
122
193
123− resetProviderState()
194+ testCatwalkSyncer := &catwalkSync{}
124− defer resetProviderState()
195+ testHyperSyncer := &hyperSync{}
125
196
197+ // Both fail.
198+ mockCatwalkClient := &mockCatwalkClient{
199+ err: catwalk.ErrNotModified,
200+ }
201+ mockHyperClient := &mockHyperClient{
202+ provider: catwalk.Provider{}, // Empty provider.
203+ }
204+
126− cachePath := tmpDir + "/crush/providers.json"
205+ catwalkPath := tmpDir + "/crush/providers.json"
127− require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
206+ hyperPath := tmpDir + "/crush/hyper.json"
128− emptyProviders := []catwalk.Provider{}
207+
129− data, err := json.Marshal(emptyProviders)
208+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
209+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
210+
211+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
130 require.NoError(t, err)
212 require.NoError(t, err)
131− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
213+ require.NotEmpty(t, catwalkResult) // Should fall back to embedded.
132
214
133− cached, _, err := loadProvidersFromCache(cachePath)
215+ hyperResult, err := testHyperSyncer.Get(t.Context())
134 require.NoError(t, err)
216 require.NoError(t, err)
135− require.Empty(t, cached, "Expected empty cache")
217+ require.Equal(t, "Charm Hyper", hyperResult.Name) // Falls back to embedded when no models.
136}
218}
137
219
138−func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
220+func TestCache_StoreAndGet(t *testing.T) {
139− client := &mockProviderClient{shouldFail: true}
221+ t.Parallel()
140− tmpPath := t.TempDir() + "/providers.json"
222+
141− providers, err := loadProviders(client, "", tmpPath)
223+ tmpDir := t.TempDir()
224+ cachePath := tmpDir + "/test.json"
225+
226+ cache := newCache[[]catwalk.Provider](cachePath)
227+
228+ providers := []catwalk.Provider{
229+ {Name: "Provider1", ID: "p1"},
230+ {Name: "Provider2", ID: "p2"},
231+ }
232+
233+ // Store.
234+ err := cache.Store(providers)
235+ require.NoError(t, err)
236+
237+ // Get.
238+ result, etag, err := cache.Get()
239+ require.NoError(t, err)
240+ require.Len(t, result, 2)
241+ require.Equal(t, "Provider1", result[0].Name)
242+ require.NotEmpty(t, etag)
243+}
244+
245+func TestCache_GetNonExistent(t *testing.T) {
246+ t.Parallel()
247+
248+ tmpDir := t.TempDir()
249+ cachePath := tmpDir + "/nonexistent.json"
250+
251+ cache := newCache[[]catwalk.Provider](cachePath)
252+
253+ _, _, err := cache.Get()
142 require.Error(t, err)
254 require.Error(t, err)
143− require.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
255+ require.Contains(t, err.Error(), "failed to read provider cache file")
256+}
257+
258+func TestCache_GetInvalidJSON(t *testing.T) {
259+ t.Parallel()
260+
261+ tmpDir := t.TempDir()
262+ cachePath := tmpDir + "/invalid.json"
263+
264+ require.NoError(t, os.WriteFile(cachePath, []byte("invalid json"), 0o644))
265+
266+ cache := newCache[[]catwalk.Provider](cachePath)
267+
268+ _, _, err := cache.Get()
269+ require.Error(t, err)
270+ require.Contains(t, err.Error(), "failed to unmarshal provider data from cache")
271+}
272+
273+func TestCachePathFor(t *testing.T) {
274+ tests := []struct {
275+ name string
276+ xdgDataHome string
277+ expected string
278+ }{
279+ {
280+ name: "with XDG_DATA_HOME",
281+ xdgDataHome: "/custom/data",
282+ expected: "/custom/data/crush/providers.json",
283+ },
284+ {
285+ name: "without XDG_DATA_HOME",
286+ xdgDataHome: "",
287+ expected: "", // Will use platform-specific default.
288+ },
289+ }
290+
291+ for _, tt := range tests {
292+ t.Run(tt.name, func(t *testing.T) {
293+ if tt.xdgDataHome != "" {
294+ t.Setenv("XDG_DATA_HOME", tt.xdgDataHome)
295+ } else {
296+ t.Setenv("XDG_DATA_HOME", "")
297+ }
298+
299+ result := cachePathFor("providers")
300+ if tt.expected != "" {
301+ require.Equal(t, tt.expected, filepath.ToSlash(result))
302+ } else {
303+ require.Contains(t, result, "crush")
304+ require.Contains(t, result, "providers.json")
305+ }
306+ })
307+ }
144}
308}
internal/event/event.go+16
@@ -46,6 +46,22 @@ func Init() {
46 distinctId = getDistinctId()
46 distinctId = getDistinctId()
47}
47}
48
48
49+func GetID() string { return distinctId }
50+
51+func Alias(userID string) {
52+ if client == nil || distinctId == fallbackId || distinctId == "" || userID == "" {
53+ return
54+ }
55+ if err := client.Enqueue(posthog.Alias{
56+ DistinctId: distinctId,
57+ Alias: userID,
58+ }); err != nil {
59+ slog.Error("Failed to enqueue PostHog alias event", "error", err)
60+ return
61+ }
62+ slog.Info("Aliased in PostHog", "machine_id", distinctId, "user_id", userID)
63+}
64+
49// send logs an event to PostHog with the given event name and properties.
65// send logs an event to PostHog with the given event name and properties.
50func send(event string, props ...any) {
66func send(event string, props ...any) {
51 if client == nil {
67 if client == nil {
internal/oauth/hyper/device.gonew file+251
@@ -0,0 +1,251 @@
1+// Package hyper provides functions to handle Hyper device flow authentication.
2+package hyper
3+
4+import (
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "os"
13+ "strings"
14+ "time"
15+
16+ "github.com/charmbracelet/crush/internal/agent/hyper"
17+ "github.com/charmbracelet/crush/internal/event"
18+ "github.com/charmbracelet/crush/internal/oauth"
19+)
20+
21+// DeviceAuthResponse contains the response from the device authorization endpoint.
22+type DeviceAuthResponse struct {
23+ DeviceCode string `json:"device_code"`
24+ UserCode string `json:"user_code"`
25+ VerificationURL string `json:"verification_url"`
26+ ExpiresIn int `json:"expires_in"`
27+}
28+
29+// TokenResponse contains the response from the polling endpoint.
30+type TokenResponse struct {
31+ RefreshToken string `json:"refresh_token,omitempty"`
32+ UserID string `json:"user_id"`
33+ OrganizationID string `json:"organization_id"`
34+ OrganizationName string `json:"organization_name"`
35+ Error string `json:"error,omitempty"`
36+ ErrorDescription string `json:"error_description,omitempty"`
37+}
38+
39+// InitiateDeviceAuth calls the /device/auth endpoint to start the device flow.
40+func InitiateDeviceAuth(ctx context.Context) (*DeviceAuthResponse, error) {
41+ url := hyper.BaseURL() + "/device/auth"
42+
43+ req, err := http.NewRequestWithContext(
44+ ctx, http.MethodPost, url,
45+ strings.NewReader(fmt.Sprintf(`{"device_name":%q}`, deviceName())),
46+ )
47+ if err != nil {
48+ return nil, fmt.Errorf("create request: %w", err)
49+ }
50+
51+ req.Header.Set("Content-Type", "application/json")
52+ req.Header.Set("User-Agent", "crush")
53+
54+ client := &http.Client{Timeout: 30 * time.Second}
55+ resp, err := client.Do(req)
56+ if err != nil {
57+ return nil, fmt.Errorf("execute request: %w", err)
58+ }
59+ defer resp.Body.Close()
60+
61+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
62+ if err != nil {
63+ return nil, fmt.Errorf("read response: %w", err)
64+ }
65+
66+ if resp.StatusCode != http.StatusOK {
67+ return nil, fmt.Errorf("device auth failed: status %d, body %q", resp.StatusCode, string(body))
68+ }
69+
70+ var authResp DeviceAuthResponse
71+ if err := json.Unmarshal(body, &authResp); err != nil {
72+ return nil, fmt.Errorf("unmarshal response: %w", err)
73+ }
74+
75+ return &authResp, nil
76+}
77+
78+func deviceName() string {
79+ if hostname, err := os.Hostname(); err == nil && hostname != "" {
80+ return "Crush (" + hostname + ")"
81+ }
82+ return "Crush"
83+}
84+
85+// PollForToken polls the /device/token endpoint until authorization is complete.
86+// It respects the polling interval and handles various error states.
87+func PollForToken(ctx context.Context, deviceCode string, expiresIn int) (string, error) {
88+ ctx, cancel := context.WithTimeout(ctx, time.Duration(expiresIn)*time.Second)
89+ defer cancel()
90+
91+ d := 5 * time.Second
92+ ticker := time.NewTicker(d)
93+ defer ticker.Stop()
94+
95+ for {
96+ select {
97+ case <-ctx.Done():
98+ return "", ctx.Err()
99+ case <-ticker.C:
100+ result, err := pollOnce(ctx, deviceCode)
101+ if err != nil {
102+ return "", err
103+ }
104+ if result.RefreshToken != "" {
105+ event.Alias(result.UserID)
106+ return result.RefreshToken, nil
107+ }
108+ switch result.Error {
109+ case "authorization_pending":
110+ continue
111+ default:
112+ return "", errors.New(result.ErrorDescription)
113+ }
114+ }
115+ }
116+}
117+
118+func pollOnce(ctx context.Context, deviceCode string) (TokenResponse, error) {
119+ var result TokenResponse
120+ url := fmt.Sprintf("%s/device/auth/%s", hyper.BaseURL(), deviceCode)
121+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
122+ if err != nil {
123+ return result, fmt.Errorf("create request: %w", err)
124+ }
125+
126+ req.Header.Set("Content-Type", "application/json")
127+ req.Header.Set("User-Agent", "crush")
128+
129+ client := &http.Client{Timeout: 30 * time.Second}
130+ resp, err := client.Do(req)
131+ if err != nil {
132+ return result, fmt.Errorf("execute request: %w", err)
133+ }
134+ defer resp.Body.Close()
135+
136+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
137+ if err != nil {
138+ return result, fmt.Errorf("read response: %w", err)
139+ }
140+
141+ if err := json.Unmarshal(body, &result); err != nil {
142+ return result, fmt.Errorf("unmarshal response: %w: %s", err, string(body))
143+ }
144+
145+ if resp.StatusCode != http.StatusOK {
146+ return result, fmt.Errorf("token request failed: status %d body %q", resp.StatusCode, string(body))
147+ }
148+
149+ return result, nil
150+}
151+
152+// ExchangeToken exchanges a refresh token for an access token.
153+func ExchangeToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
154+ reqBody := map[string]string{
155+ "refresh_token": refreshToken,
156+ }
157+
158+ data, err := json.Marshal(reqBody)
159+ if err != nil {
160+ return nil, fmt.Errorf("marshal request: %w", err)
161+ }
162+
163+ url := hyper.BaseURL() + "/token/exchange"
164+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
165+ if err != nil {
166+ return nil, fmt.Errorf("create request: %w", err)
167+ }
168+
169+ req.Header.Set("Content-Type", "application/json")
170+ req.Header.Set("User-Agent", "crush")
171+
172+ client := &http.Client{Timeout: 30 * time.Second}
173+ resp, err := client.Do(req)
174+ if err != nil {
175+ return nil, fmt.Errorf("execute request: %w", err)
176+ }
177+ defer resp.Body.Close()
178+
179+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
180+ if err != nil {
181+ return nil, fmt.Errorf("read response: %w", err)
182+ }
183+
184+ if resp.StatusCode != http.StatusOK {
185+ return nil, fmt.Errorf("token exchange failed: status %d body %q", resp.StatusCode, string(body))
186+ }
187+
188+ var token oauth.Token
189+ if err := json.Unmarshal(body, &token); err != nil {
190+ return nil, fmt.Errorf("unmarshal response: %w", err)
191+ }
192+
193+ token.SetExpiresAt()
194+ return &token, nil
195+}
196+
197+// IntrospectTokenResponse contains the response from the token introspection endpoint.
198+type IntrospectTokenResponse struct {
199+ Active bool `json:"active"`
200+ Sub string `json:"sub,omitempty"`
201+ OrgID string `json:"org_id,omitempty"`
202+ Exp int64 `json:"exp,omitempty"`
203+ Iat int64 `json:"iat,omitempty"`
204+ Iss string `json:"iss,omitempty"`
205+ Jti string `json:"jti,omitempty"`
206+}
207+
208+// IntrospectToken validates an access token using the introspection endpoint.
209+// Implements OAuth2 Token Introspection (RFC 7662).
210+func IntrospectToken(ctx context.Context, accessToken string) (*IntrospectTokenResponse, error) {
211+ reqBody := map[string]string{
212+ "token": accessToken,
213+ }
214+
215+ data, err := json.Marshal(reqBody)
216+ if err != nil {
217+ return nil, fmt.Errorf("marshal request: %w", err)
218+ }
219+
220+ url := hyper.BaseURL() + "/token/introspect"
221+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
222+ if err != nil {
223+ return nil, fmt.Errorf("create request: %w", err)
224+ }
225+
226+ req.Header.Set("Content-Type", "application/json")
227+ req.Header.Set("User-Agent", "crush")
228+
229+ client := &http.Client{Timeout: 30 * time.Second}
230+ resp, err := client.Do(req)
231+ if err != nil {
232+ return nil, fmt.Errorf("execute request: %w", err)
233+ }
234+ defer resp.Body.Close()
235+
236+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
237+ if err != nil {
238+ return nil, fmt.Errorf("read response: %w", err)
239+ }
240+
241+ if resp.StatusCode != http.StatusOK {
242+ return nil, fmt.Errorf("token introspection failed: status %d body %q", resp.StatusCode, string(body))
243+ }
244+
245+ var result IntrospectTokenResponse
246+ if err := json.Unmarshal(body, &result); err != nil {
247+ return nil, fmt.Errorf("unmarshal response: %w", err)
248+ }
249+
250+ return &result, nil
251+}
internal/oauth/token.go+1−1
@@ -4,7 +4,7 @@ import (
4 "time"
4 "time"
5)
5)
6
6
7−// Token represents an OAuth2 token from Claude Code Max.
7+// Token represents an OAuth2 token.
8type Token struct {
8type Token struct {
9 AccessToken string `json:"access_token"`
9 AccessToken string `json:"access_token"`
10 RefreshToken string `json:"refresh_token"`
10 RefreshToken string `json:"refresh_token"`
internal/tui/components/dialogs/commands/commands.go+2−1
@@ -13,6 +13,7 @@ import (
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
14
14
15 "github.com/charmbracelet/crush/internal/agent"
15 "github.com/charmbracelet/crush/internal/agent"
16+ "github.com/charmbracelet/crush/internal/agent/hyper"
16 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
17 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
17 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/csync"
19 "github.com/charmbracelet/crush/internal/csync"
@@ -363,7 +364,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
363 selectedModel := cfg.Models[agentCfg.Model]
364 selectedModel := cfg.Models[agentCfg.Model]
364
365
365 // Anthropic models: thinking toggle
366 // Anthropic models: thinking toggle
366− if providerCfg.Type == catwalk.TypeAnthropic {
367+ if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
367 status := "Enable"
368 status := "Enable"
368 if selectedModel.Think {
369 if selectedModel.Think {
369 status = "Disable"
370 status = "Disable"
internal/tui/components/dialogs/hyper/device_flow.gonew file+264
@@ -0,0 +1,264 @@
1+// Package hyper provides the dialog for Hyper device flow authentication.
2+package hyper
3+
4+import (
5+ "context"
6+ "fmt"
7+ "time"
8+
9+ "charm.land/bubbles/v2/spinner"
10+ tea "charm.land/bubbletea/v2"
11+ "charm.land/lipgloss/v2"
12+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
13+ "github.com/charmbracelet/crush/internal/oauth"
14+ "github.com/charmbracelet/crush/internal/oauth/hyper"
15+ "github.com/charmbracelet/crush/internal/tui/styles"
16+ "github.com/charmbracelet/crush/internal/tui/util"
17+ "github.com/pkg/browser"
18+)
19+
20+// DeviceFlowState represents the current state of the device flow.
21+type DeviceFlowState int
22+
23+const (
24+ DeviceFlowStateDisplay DeviceFlowState = iota
25+ DeviceFlowStateSuccess
26+ DeviceFlowStateError
27+)
28+
29+// DeviceAuthInitiatedMsg is sent when the device auth is initiated
30+// successfully.
31+type DeviceAuthInitiatedMsg struct {
32+ deviceCode string
33+ expiresIn int
34+}
35+
36+// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
37+type DeviceFlowCompletedMsg struct {
38+ Token *oauth.Token
39+}
40+
41+// DeviceFlowErrorMsg is sent when the device flow encounters an error.
42+type DeviceFlowErrorMsg struct {
43+ Error error
44+}
45+
46+// DeviceFlow handles the Hyper device flow authentication.
47+type DeviceFlow struct {
48+ State DeviceFlowState
49+ width int
50+ baseURL string
51+ deviceCode string
52+ userCode string
53+ verificationURL string
54+ expiresIn int
55+ token *oauth.Token
56+ cancelFunc context.CancelFunc
57+ spinner spinner.Model
58+}
59+
60+// NewDeviceFlow creates a new device flow component.
61+func NewDeviceFlow() *DeviceFlow {
62+ s := spinner.New()
63+ s.Spinner = spinner.Dot
64+ s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
65+ return &DeviceFlow{
66+ State: DeviceFlowStateDisplay,
67+ baseURL: hyperp.BaseURL(),
68+ spinner: s,
69+ }
70+}
71+
72+// Init initializes the device flow by calling the device auth API and starting polling.
73+func (d *DeviceFlow) Init() tea.Cmd {
74+ return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
75+}
76+
77+// Update handles messages and state transitions.
78+func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
79+ var cmd tea.Cmd
80+ d.spinner, cmd = d.spinner.Update(msg)
81+
82+ switch msg := msg.(type) {
83+ case DeviceAuthInitiatedMsg:
84+ // Start polling now that we have the device code.
85+ d.expiresIn = msg.expiresIn
86+ return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
87+ case DeviceFlowCompletedMsg:
88+ d.State = DeviceFlowStateSuccess
89+ d.token = msg.Token
90+ return d, nil
91+ case DeviceFlowErrorMsg:
92+ d.State = DeviceFlowStateError
93+ return d, util.ReportError(msg.Error)
94+ }
95+
96+ return d, cmd
97+}
98+
99+// View renders the device flow dialog.
100+func (d *DeviceFlow) View() string {
101+ t := styles.CurrentTheme()
102+
103+ whiteStyle := lipgloss.NewStyle().Foreground(t.White)
104+ primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
105+ greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
106+ errorStyle := lipgloss.NewStyle().Foreground(t.Error)
107+ mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
108+
109+ switch d.State {
110+ case DeviceFlowStateDisplay:
111+ if d.userCode == "" {
112+ return lipgloss.NewStyle().
113+ Margin(0, 1).
114+ Render(
115+ greenStyle.Render(d.spinner.View()) +
116+ mutedStyle.Render("Initializing..."),
117+ )
118+ }
119+
120+ instructions := lipgloss.NewStyle().
121+ Margin(1, 1, 0, 1).
122+ Width(d.width - 2).
123+ Render(
124+
125+ whiteStyle.Render("Press ") +
126+ primaryStyle.Render("enter") +
127+ whiteStyle.Render(" to copy the code below and open the browser."),
128+ )
129+
130+ codeBox := lipgloss.NewStyle().
131+ Width(d.width-2).
132+ Height(7).
133+ Align(lipgloss.Center, lipgloss.Center).
134+ Background(t.BgBaseLighter).
135+ Margin(1).
136+ Render(
137+ lipgloss.NewStyle().
138+ Bold(true).
139+ Foreground(t.White).
140+ Render(d.userCode),
141+ )
142+
143+ link := lipgloss.NewStyle().Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL)
144+ url := mutedStyle.
145+ Margin(0, 1).
146+ Width(d.width - 2).
147+ Render("Browser not opening? Refer to\n" + link)
148+
149+ waiting := greenStyle.
150+ Width(d.width-2).
151+ Margin(1, 1, 0, 1).
152+ Render(d.spinner.View() + "Verifying...")
153+
154+ return lipgloss.JoinVertical(
155+ lipgloss.Left,
156+ instructions,
157+ codeBox,
158+ url,
159+ waiting,
160+ )
161+
162+ case DeviceFlowStateSuccess:
163+ return greenStyle.Margin(0, 1).Render("Authentication successful!")
164+
165+ case DeviceFlowStateError:
166+ return lipgloss.NewStyle().
167+ Margin(0, 1).
168+ Width(d.width).
169+ Render(errorStyle.Render("Authentication failed."))
170+
171+ default:
172+ return ""
173+ }
174+}
175+
176+// SetWidth sets the width of the dialog.
177+func (d *DeviceFlow) SetWidth(w int) {
178+ d.width = w
179+}
180+
181+// Cursor hides the cursor.
182+func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
183+
184+// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
185+func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
186+ return tea.Sequence(
187+ tea.SetClipboard(d.userCode),
188+ func() tea.Msg {
189+ if err := browser.OpenURL(d.verificationURL); err != nil {
190+ return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
191+ }
192+ return nil
193+ },
194+ util.ReportInfo("Code copied and URL opened"),
195+ )
196+}
197+
198+// CopyCode copies just the user code to the clipboard.
199+func (d *DeviceFlow) CopyCode() tea.Cmd {
200+ return tea.Sequence(
201+ tea.SetClipboard(d.userCode),
202+ util.ReportInfo("Code copied to clipboard"),
203+ )
204+}
205+
206+// Cancel cancels the device flow polling.
207+func (d *DeviceFlow) Cancel() {
208+ if d.cancelFunc != nil {
209+ d.cancelFunc()
210+ }
211+}
212+
213+func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
214+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
215+ defer cancel()
216+ authResp, err := hyper.InitiateDeviceAuth(ctx)
217+ if err != nil {
218+ return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
219+ }
220+
221+ d.deviceCode = authResp.DeviceCode
222+ d.userCode = authResp.UserCode
223+ d.verificationURL = authResp.VerificationURL
224+
225+ return DeviceAuthInitiatedMsg{
226+ deviceCode: authResp.DeviceCode,
227+ expiresIn: authResp.ExpiresIn,
228+ }
229+}
230+
231+// startPolling starts polling for the device token.
232+func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd {
233+ return func() tea.Msg {
234+ ctx, cancel := context.WithCancel(context.Background())
235+ d.cancelFunc = cancel
236+
237+ // Poll for refresh token.
238+ refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn)
239+ if err != nil {
240+ if ctx.Err() != nil {
241+ // Cancelled, don't report error.
242+ return nil
243+ }
244+ return DeviceFlowErrorMsg{Error: err}
245+ }
246+
247+ // Exchange refresh token for access token.
248+ token, err := hyper.ExchangeToken(ctx, refreshToken)
249+ if err != nil {
250+ return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)}
251+ }
252+
253+ // Verify the access token works.
254+ introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
255+ if err != nil {
256+ return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)}
257+ }
258+ if !introspect.Active {
259+ return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")}
260+ }
261+
262+ return DeviceFlowCompletedMsg{Token: token}
263+ }
264+}
internal/tui/components/dialogs/models/keys.go+15
@@ -15,6 +15,8 @@ type KeyMap struct {
15 isAPIKeyHelp bool
15 isAPIKeyHelp bool
16 isAPIKeyValid bool
16 isAPIKeyValid bool
17
17
18+ isHyperDeviceFlow bool
19+
18 isClaudeAuthChoiceHelp bool
20 isClaudeAuthChoiceHelp bool
19 isClaudeOAuthHelp bool
21 isClaudeOAuthHelp bool
20 isClaudeOAuthURLState bool
22 isClaudeOAuthURLState bool
@@ -74,6 +76,19 @@ func (k KeyMap) FullHelp() [][]key.Binding {
74
76
75// ShortHelp implements help.KeyMap.
77// ShortHelp implements help.KeyMap.
76func (k KeyMap) ShortHelp() []key.Binding {
78func (k KeyMap) ShortHelp() []key.Binding {
79+ if k.isHyperDeviceFlow {
80+ return []key.Binding{
81+ key.NewBinding(
82+ key.WithKeys("c"),
83+ key.WithHelp("c", "copy code"),
84+ ),
85+ key.NewBinding(
86+ key.WithKeys("enter"),
87+ key.WithHelp("enter", "copy & open"),
88+ ),
89+ k.Close,
90+ }
91+ }
77 if k.isClaudeAuthChoiceHelp {
92 if k.isClaudeAuthChoiceHelp {
78 return []key.Binding{
93 return []key.Binding{
79 key.NewBinding(
94 key.NewBinding(
internal/tui/components/dialogs/models/list.go+18−2
@@ -62,7 +62,8 @@ func (m *ModelListComponent) Init() tea.Cmd {
62 filteredProviders := []catwalk.Provider{}
62 filteredProviders := []catwalk.Provider{}
63 for _, p := range providers {
63 for _, p := range providers {
64 hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
64 hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
65+ isHyper := p.ID == "hyper"
65− if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
66+ if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper {
66 filteredProviders = append(filteredProviders, p)
67 filteredProviders = append(filteredProviders, p)
67 }
68 }
68 }
69 }
@@ -204,8 +205,23 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
204 }
205 }
205 }
206 }
206
207
208+ // Move "Charm Hyper" to first position
209+ // (but still after recent models and custom providers).
210+ sortedProviders := make([]catwalk.Provider, len(m.providers))
211+ copy(sortedProviders, m.providers)
212+ slices.SortStableFunc(sortedProviders, func(a, b catwalk.Provider) int {
213+ switch {
214+ case a.ID == "hyper":
215+ return -1
216+ case b.ID == "hyper":
217+ return 1
218+ default:
219+ return 0
220+ }
221+ })
222+
207 // Then add the known providers from the predefined list
223 // Then add the known providers from the predefined list
208− for _, provider := range m.providers {
224+ for _, provider := range sortedProviders {
209 // Skip if we already added this provider as an unknown provider
225 // Skip if we already added this provider as an unknown provider
210 if addedProviders[string(provider.ID)] {
226 if addedProviders[string(provider.ID)] {
211 continue
227 continue
internal/tui/components/dialogs/models/models.go+88−15
@@ -1,3 +1,4 @@
1+// Package models provides the model selection dialog for the TUI.
1package models
2package models
2
3
3import (
4import (
@@ -11,10 +12,12 @@ import (
11 "charm.land/lipgloss/v2"
12 "charm.land/lipgloss/v2"
12 "github.com/atotto/clipboard"
13 "github.com/atotto/clipboard"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
18 "github.com/charmbracelet/crush/internal/tui/exp/list"
21 "github.com/charmbracelet/crush/internal/tui/exp/list"
19 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/styles"
20 "github.com/charmbracelet/crush/internal/tui/util"
23 "github.com/charmbracelet/crush/internal/tui/util"
@@ -70,6 +73,10 @@ type modelDialogCmp struct {
70 isAPIKeyValid bool
73 isAPIKeyValid bool
71 apiKeyValue string
74 apiKeyValue string
72
75
76+ // Hyper device flow state
77+ hyperDeviceFlow *hyper.DeviceFlow
78+ showHyperDeviceFlow bool
79+
73 // Claude state
80 // Claude state
74 claudeAuthMethodChooser *claude.AuthMethodChooser
81 claudeAuthMethodChooser *claude.AuthMethodChooser
75 claudeOAuth2 *claude.OAuth2
82 claudeOAuth2 *claude.OAuth2
@@ -127,6 +134,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
127 u, cmd := m.apiKeyInput.Update(msg)
134 u, cmd := m.apiKeyInput.Update(msg)
128 m.apiKeyInput = u.(*APIKeyInput)
135 m.apiKeyInput = u.(*APIKeyInput)
129 return m, cmd
136 return m, cmd
137+ case hyper.DeviceFlowCompletedMsg:
138+ return m, m.saveOauthTokenAndContinue(msg.Token, true)
139+ case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
140+ if m.hyperDeviceFlow != nil {
141+ u, cmd := m.hyperDeviceFlow.Update(msg)
142+ m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
143+ return m, cmd
144+ }
145+ return m, nil
130 case claude.ValidationCompletedMsg:
146 case claude.ValidationCompletedMsg:
131 var cmds []tea.Cmd
147 var cmds []tea.Cmd
132 u, cmd := m.claudeOAuth2.Update(msg)
148 u, cmd := m.claudeOAuth2.Update(msg)
@@ -134,7 +150,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
134 cmds = append(cmds, cmd)
150 cmds = append(cmds, cmd)
135
151
136 if msg.State == claude.OAuthValidationStateValid {
152 if msg.State == claude.OAuthValidationStateValid {
137− cmds = append(cmds, m.saveAPIKeyAndContinue(msg.Token, false))
153+ cmds = append(cmds, m.saveOauthTokenAndContinue(msg.Token, false))
138 m.keyMap.isClaudeOAuthHelpComplete = true
154 m.keyMap.isClaudeOAuthHelpComplete = true
139 }
155 }
140
156
@@ -143,6 +159,11 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
143 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
159 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
144 case tea.KeyPressMsg:
160 case tea.KeyPressMsg:
145 switch {
161 switch {
162+ // Handle Hyper device flow keys
163+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow:
164+ if m.hyperDeviceFlow != nil {
165+ return m, m.hyperDeviceFlow.CopyCode()
166+ }
146 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
167 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
147 return m, tea.Sequence(
168 return m, tea.Sequence(
148 tea.SetClipboard(m.claudeOAuth2.URL),
169 tea.SetClipboard(m.claudeOAuth2.URL),
@@ -156,6 +177,10 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
156 m.claudeAuthMethodChooser.ToggleChoice()
177 m.claudeAuthMethodChooser.ToggleChoice()
157 return m, nil
178 return m, nil
158 case key.Matches(msg, m.keyMap.Select):
179 case key.Matches(msg, m.keyMap.Select):
180+ // If showing device flow, enter copies code and opens URL
181+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
182+ return m, m.hyperDeviceFlow.CopyCodeAndOpenURL()
183+ }
159 selectedItem := m.modelList.SelectedModel()
184 selectedItem := m.modelList.SelectedModel()
160
185
161 modelType := config.SelectedModelTypeLarge
186 modelType := config.SelectedModelTypeLarge
@@ -167,6 +192,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
167 m.keyMap.isClaudeAuthChoiceHelp = false
192 m.keyMap.isClaudeAuthChoiceHelp = false
168 m.keyMap.isClaudeOAuthHelp = false
193 m.keyMap.isClaudeOAuthHelp = false
169 m.keyMap.isAPIKeyHelp = true
194 m.keyMap.isAPIKeyHelp = true
195+ m.showHyperDeviceFlow = false
170 m.showClaudeAuthMethodChooser = false
196 m.showClaudeAuthMethodChooser = false
171 m.needsAPIKey = true
197 m.needsAPIKey = true
172 m.selectedModel = selectedItem
198 m.selectedModel = selectedItem
@@ -194,7 +220,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
194 return m, cmd2
220 return m, cmd2
195 }
221 }
196 if m.isAPIKeyValid {
222 if m.isAPIKeyValid {
197− return m, m.saveAPIKeyAndContinue(m.apiKeyValue, true)
223+ return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
198 }
224 }
199 if m.needsAPIKey {
225 if m.needsAPIKey {
200 // Handle API key submission
226 // Handle API key submission
@@ -249,15 +275,23 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
249 ModelType: modelType,
275 ModelType: modelType,
250 }),
276 }),
251 )
277 )
252− } else {
278+ }
279+ switch selectedItem.Provider.ID {
253− if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
280+ case catwalk.InferenceProviderAnthropic:
254− m.showClaudeAuthMethodChooser = true
281+ m.showClaudeAuthMethodChooser = true
255− m.keyMap.isClaudeAuthChoiceHelp = true
282+ m.keyMap.isClaudeAuthChoiceHelp = true
256− return m, nil
257− }
258− askForApiKey()
259 return m, nil
283 return m, nil
284+ case hyperp.Name:
285+ m.showHyperDeviceFlow = true
286+ m.selectedModel = selectedItem
287+ m.selectedModelType = modelType
288+ m.hyperDeviceFlow = hyper.NewDeviceFlow()
289+ m.hyperDeviceFlow.SetWidth(m.width - 2)
290+ return m, m.hyperDeviceFlow.Init()
260 }
291 }
292+ // For other providers, show API key input
293+ askForApiKey()
294+ return m, nil
261 case key.Matches(msg, m.keyMap.Tab):
295 case key.Matches(msg, m.keyMap.Tab):
262 switch {
296 switch {
263 case m.showClaudeAuthMethodChooser:
297 case m.showClaudeAuthMethodChooser:
@@ -275,6 +309,14 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
275 return m, m.modelList.SetModelType(LargeModelType)
309 return m, m.modelList.SetModelType(LargeModelType)
276 }
310 }
277 case key.Matches(msg, m.keyMap.Close):
311 case key.Matches(msg, m.keyMap.Close):
312+ if m.showHyperDeviceFlow {
313+ // Cancel device flow and go back to model selection
314+ if m.hyperDeviceFlow != nil {
315+ m.hyperDeviceFlow.Cancel()
316+ }
317+ m.showHyperDeviceFlow = false
318+ m.selectedModel = nil
319+ }
278 if m.showClaudeAuthMethodChooser {
320 if m.showClaudeAuthMethodChooser {
279 m.claudeAuthMethodChooser.SetDefaults()
321 m.claudeAuthMethodChooser.SetDefaults()
280 m.showClaudeAuthMethodChooser = false
322 m.showClaudeAuthMethodChooser = false
@@ -329,7 +371,20 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
329 return m, cmd
371 return m, cmd
330 }
372 }
331 case spinner.TickMsg:
373 case spinner.TickMsg:
374+ u, cmd := m.apiKeyInput.Update(msg)
375+ m.apiKeyInput = u.(*APIKeyInput)
376+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
377+ u, cmd = m.hyperDeviceFlow.Update(msg)
378+ m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
379+ }
380+ return m, cmd
381+ default:
382+ // Pass all other messages to the device flow for spinner animation
383+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
384+ u, cmd := m.hyperDeviceFlow.Update(msg)
385+ m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
386+ return m, cmd
332− if m.showClaudeOAuth2 {
387+ } else if m.showClaudeOAuth2 {
333 u, cmd := m.claudeOAuth2.Update(msg)
388 u, cmd := m.claudeOAuth2.Update(msg)
334 m.claudeOAuth2 = u.(*claude.OAuth2)
389 m.claudeOAuth2 = u.(*claude.OAuth2)
335 return m, cmd
390 return m, cmd
@@ -345,6 +400,23 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
345func (m *modelDialogCmp) View() string {
400func (m *modelDialogCmp) View() string {
346 t := styles.CurrentTheme()
401 t := styles.CurrentTheme()
347
402
403+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
404+ // Show Hyper device flow
405+ m.keyMap.isHyperDeviceFlow = true
406+ deviceFlowView := m.hyperDeviceFlow.View()
407+ content := lipgloss.JoinVertical(
408+ lipgloss.Left,
409+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with Hyper", m.width-4)),
410+ deviceFlowView,
411+ "",
412+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
413+ )
414+ return m.style().Render(content)
415+ }
416+
417+ // Reset the flags when not showing device flow
418+ m.keyMap.isHyperDeviceFlow = false
419+
348 switch {
420 switch {
349 case m.showClaudeAuthMethodChooser:
421 case m.showClaudeAuthMethodChooser:
350 chooserView := m.claudeAuthMethodChooser.View()
422 chooserView := m.claudeAuthMethodChooser.View()
@@ -397,6 +469,9 @@ func (m *modelDialogCmp) View() string {
397}
469}
398
470
399func (m *modelDialogCmp) Cursor() *tea.Cursor {
471func (m *modelDialogCmp) Cursor() *tea.Cursor {
472+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
473+ return m.hyperDeviceFlow.Cursor()
474+ }
400 if m.showClaudeAuthMethodChooser {
475 if m.showClaudeAuthMethodChooser {
401 return nil
476 return nil
402 }
477 }
@@ -477,10 +552,8 @@ func (m *modelDialogCmp) modelTypeRadio() string {
477
552
478func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
553func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
479 cfg := config.Get()
554 cfg := config.Get()
480− if _, ok := cfg.Providers.Get(providerID); ok {
555+ _, ok := cfg.Providers.Get(providerID)
481− return true
482− }
483− return false
556+ return ok
484}
557}
485
558
486func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
559func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
@@ -497,7 +570,7 @@ func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*cat
497 return nil, nil
570 return nil, nil
498}
571}
499
572
500−func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
573+func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd {
501 if m.selectedModel == nil {
574 if m.selectedModel == nil {
502 return util.ReportError(fmt.Errorf("no model selected"))
575 return util.ReportError(fmt.Errorf("no model selected"))
503 }
576 }
internal/tui/exp/list/items.go+6
@@ -323,6 +323,7 @@ type ItemSection interface {
323 layout.Sizeable
323 layout.Sizeable
324 Indexable
324 Indexable
325 SetInfo(info string)
325 SetInfo(info string)
326+ Title() string
326}
327}
327type itemSectionModel struct {
328type itemSectionModel struct {
328 width int
329 width int
@@ -337,6 +338,11 @@ func (m *itemSectionModel) ID() string {
337 return m.id
338 return m.id
338}
339}
339
340
341+// Title implements ItemSection.
342+func (m *itemSectionModel) Title() string {
343+ return m.title
344+}
345+
340func NewItemSection(title string) ItemSection {
346func NewItemSection(title string) ItemSection {
341 return &itemSectionModel{
347 return &itemSectionModel{
342 title: title,
348 title: title,