on main help sign in
feat: hyper integration (#1642)
commit5291b1dbebb48e84e72571a11b561b49f42a15b2 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateTue 16 Dec 2025, 16:21 (1 year ago) parent7fd14ff
28 files changed+2178−213
.gitattributes+1 .github/workflows/schema-update.yml+4−2 Taskfile.yaml+7 go.mod+1−1 internal/agent/agent.go+6 internal/agent/coordinator.go+15 internal/agent/hyper/provider.go+330 internal/agent/hyper/provider.json+1 internal/cmd/login.go+86−15 internal/cmd/update_providers.go+30−8 internal/config/catwalk.go+82 internal/config/catwalk_test.go+221 internal/config/config.go+14−7 internal/config/hyper.go+124 internal/config/hyper_test.go+205 internal/config/load.go+2−1 internal/config/provider.go+130−74 internal/config/provider_empty_test.go+18−10 internal/config/provider_test.go+240−76 internal/event/event.go+16 internal/oauth/hyper/device.go+251 internal/oauth/token.go+1−1 internal/tui/components/dialogs/commands/commands.go+2−1 internal/tui/components/dialogs/hyper/device_flow.go+264 internal/tui/components/dialogs/models/keys.go+15 internal/tui/components/dialogs/models/list.go+18−2 internal/tui/components/dialogs/models/models.go+88−15 internal/tui/exp/list/items.go+6
.gitattributes+1
@@ -1,3 +1,4 @@
11*.golden linguist-generated=true -text
22.github/crush-schema.json linguist-generated=true
3+internal/agent/hyper/provider.json linguist-generated=true
34internal/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
22
33on:
44 push:
55 branches: [main]
66 paths:
77 - "internal/config/**"
8+ - "internal/agent/hyper/**"
89
910jobs:
1011 update-schema:
@@ -17,9 +18,10 @@ jobs:
1718 with:
1819 go-version-file: go.mod
1920 - run: go run . schema > ./schema.json
21+ - run: go generate ./internal/agent/hyper/...
2022 - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
2123 with:
22− commit_message: "chore: auto-update generated files"
24+ commit_message: "chore: auto-update files"
2325 branch: main
2426 commit_user_name: Charm
2527 commit_user_email: 124303983+charmcli@users.noreply.github.com
Taskfile.yaml+7
@@ -101,6 +101,13 @@ tasks:
101101 generates:
102102 - schema.json
103103
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+
104111 release:
105112 desc: Create and push a new tag following semver
106113 vars:
go.mod+1−1
@@ -23,6 +23,7 @@ require (
2323 github.com/charmbracelet/fang v0.4.4
2424 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
2525 github.com/charmbracelet/x/ansi v0.11.3
26+ github.com/charmbracelet/x/etag v0.2.0
2627 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
2728 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
2829 github.com/charmbracelet/x/exp/ordered v0.1.0
@@ -91,7 +92,6 @@ require (
9192 github.com/bahlo/generic-list-go v0.2.0 // indirect
9293 github.com/buger/jsonparser v1.1.1 // indirect
9394 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
94− github.com/charmbracelet/x/etag v0.2.0 // indirect
9595 github.com/charmbracelet/x/json v0.2.0 // indirect
9696 github.com/charmbracelet/x/termios v0.1.1 // indirect
9797 github.com/charmbracelet/x/windows v0.2.2 // indirect
internal/agent/agent.go+6
@@ -27,7 +27,9 @@ import (
2727 "charm.land/fantasy/providers/google"
2828 "charm.land/fantasy/providers/openai"
2929 "charm.land/fantasy/providers/openrouter"
30+ "charm.land/lipgloss/v2"
3031 "github.com/charmbracelet/catwalk/pkg/catwalk"
32+ "github.com/charmbracelet/crush/internal/agent/hyper"
3133 "github.com/charmbracelet/crush/internal/agent/tools"
3234 "github.com/charmbracelet/crush/internal/config"
3335 "github.com/charmbracelet/crush/internal/csync"
@@ -454,6 +456,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
454456 currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
455457 } else if isPermissionErr {
456458 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)
457463 } else if errors.As(err, &providerErr) {
458464 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
459465 } else if errors.As(err, &fantasyErr) {
internal/agent/coordinator.go+15
@@ -17,6 +17,7 @@ import (
1717
1818 "charm.land/fantasy"
1919 "github.com/charmbracelet/catwalk/pkg/catwalk"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
2021 "github.com/charmbracelet/crush/internal/agent/prompt"
2122 "github.com/charmbracelet/crush/internal/agent/tools"
2223 "github.com/charmbracelet/crush/internal/config"
@@ -668,6 +669,18 @@ func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, optio
668669 return google.New(opts...)
669670}
670671
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+
671684func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
672685 if model.Think {
673686 return true
@@ -728,6 +741,8 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
728741 providerCfg.ExtraBody["tool_stream"] = true
729742 }
730743 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
744+ case hyper.Name:
745+ return c.buildHyperProvider(baseURL, apiKey)
731746 default:
732747 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
733748 }
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 (
99 "strings"
1010
1111 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
13+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1214 "github.com/charmbracelet/crush/internal/config"
1315 "github.com/charmbracelet/crush/internal/oauth/claude"
16+ "github.com/charmbracelet/crush/internal/oauth/hyper"
17+ "github.com/pkg/browser"
1418 "github.com/spf13/cobra"
1519)
1620
@@ -20,31 +24,34 @@ var loginCmd = &cobra.Command{
2024 Short: "Login Crush to a platform",
2125 Long: `Login Crush to a specified platform.
2226The platform should be provided as an argument.
23−Available platforms are: claude.`,
27+Available platforms are: hyper, claude.`,
2428 Example: `
29+# Authenticate with Charm Hyper
30+crush login
31+
2532# Authenticate with Claude Code Max
2633crush login claude
2734 `,
2835 ValidArgs: []cobra.Completion{
36+ "hyper",
2937 "claude",
3038 "anthropic",
3139 },
32− Args: cobra.ExactArgs(1),
40+ Args: cobra.MaximumNArgs(1),
3341 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−
4142 app, err := setupAppWithProgressBar(cmd)
4243 if err != nil {
4344 return err
4445 }
4546 defer app.Shutdown()
4647
47− switch args[0] {
48+ provider := "hyper"
49+ if len(args) > 0 {
50+ provider = args[0]
51+ }
52+ switch provider {
53+ case "hyper":
54+ return loginHyper()
4855 case "anthropic", "claude":
4956 return loginClaude()
5057 default:
@@ -53,13 +60,73 @@ crush login claude
5360 },
5461}
5562
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+
56127func loginClaude() error {
57128 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
58− go func() {
59− <-ctx.Done()
60− cancel()
61− os.Exit(1)
62− }()
129+ defer cancel()
63130
64131 verifier, challenge, err := claude.GetChallenge()
65132 if err != nil {
@@ -106,3 +173,7 @@ func loginClaude() error {
106173 fmt.Println("You're now authenticated with Claude Code Max!")
107174 return nil
108175}
176+
177+func waitEnter() {
178+ _, _ = fmt.Scanln()
179+}
internal/cmd/update_providers.go+30−8
@@ -10,22 +10,30 @@ import (
1010 "github.com/spf13/cobra"
1111)
1212
13+var updateProvidersSource string
14+
1315var updateProvidersCmd = &cobra.Command{
1416 Use: "update-providers [path-or-url]",
1517 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.`,
1719 Example: `
18−# Update providers remotely from Catwalk
20+# Update Catwalk providers remotely (default)
1921crush update-providers
2022
21−# Update providers from a custom URL
22−crush update-providers https://example.com/
23+# Update Catwalk providers from a custom URL
24+crush update-providers https://example.com/providers.json
2325
24−# Update providers from a local file
26+# Update Catwalk providers from a local file
2527crush update-providers /path/to/local-providers.json
2628
27−# Update providers from embedded version
29+# Update Catwalk providers from embedded version
2830crush 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
2937`,
3038 RunE: func(cmd *cobra.Command, args []string) error {
3139 // NOTE(@andreynering): We want to skip logging output do stdout here.
@@ -36,7 +44,17 @@ crush update-providers embedded
3644 pathOrURL = args[0]
3745 }
3846
39− if err := config.UpdateProviders(pathOrURL); err != nil {
47+ var err error
48+ switch updateProvidersSource {
49+ case "catwalk":
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 {
4058 return err
4159 }
4260
@@ -52,9 +70,13 @@ crush update-providers embedded
5270 SetString("SUCCESS")
5371 textStyle := lipgloss.NewStyle().
5472 MarginLeft(2).
55− SetString("Providers updated successfully.")
73+ SetString(fmt.Sprintf("%s provider updated successfully.", updateProvidersSource))
5674
5775 fmt.Printf("%s\n%s\n\n", headerStyle.Render(), textStyle.Render())
5876 return nil
5977 },
6078}
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 (
1313 "time"
1414
1515 "github.com/charmbracelet/catwalk/pkg/catwalk"
16+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1617 "github.com/charmbracelet/crush/internal/csync"
1718 "github.com/charmbracelet/crush/internal/env"
1819 "github.com/charmbracelet/crush/internal/oauth"
1920 "github.com/charmbracelet/crush/internal/oauth/claude"
21+ "github.com/charmbracelet/crush/internal/oauth/hyper"
2022 "github.com/invopop/jsonschema"
2123 "github.com/tidwall/sjson"
2224)
@@ -120,7 +122,6 @@ type ProviderConfig struct {
120122}
121123
122124func (pc *ProviderConfig) SetupClaudeCode() {
123− pc.APIKey = fmt.Sprintf("Bearer %s", pc.OAuthToken.AccessToken)
124125 pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
125126 pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
126127
@@ -483,12 +484,16 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
483484 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
484485 }
485486
486− // Only Anthropic provider uses OAuth for now.
487− if providerID != string(catwalk.InferenceProviderAnthropic) {
487+ var newToken *oauth.Token
488+ var err error
489+ switch providerID {
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:
488495 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
489496 }
490−
491− newToken, err := claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
492497 if err != nil {
493498 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
494499 }
@@ -529,9 +534,11 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
529534 return err
530535 }
531536 setKeyOrToken = func() {
532− providerConfig.APIKey = v.AccessToken
537+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
533538 providerConfig.OAuthToken = v
534− providerConfig.SetupClaudeCode()
539+ if providerID == string(catwalk.InferenceProviderAnthropic) {
540+ providerConfig.SetupClaudeCode()
541+ }
535542 }
536543 }
537544
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 (
1717 "testing"
1818
1919 "github.com/charmbracelet/catwalk/pkg/catwalk"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
2021 "github.com/charmbracelet/crush/internal/csync"
2122 "github.com/charmbracelet/crush/internal/env"
2223 "github.com/charmbracelet/crush/internal/fsext"
@@ -271,7 +272,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
271272 if providerConfig.Type == "" {
272273 providerConfig.Type = catwalk.TypeOpenAICompat
273274 }
274− if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) {
275+ if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
275276 slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
276277 c.Providers.Del(id)
277278 continue
internal/config/provider.go+130−74
@@ -10,16 +10,21 @@ import (
1010 "os"
1111 "path/filepath"
1212 "runtime"
13+ "slices"
1314 "strings"
1415 "sync"
16+ "time"
1517
1618 "github.com/charmbracelet/catwalk/pkg/catwalk"
1719 "github.com/charmbracelet/catwalk/pkg/embedded"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
21+ "github.com/charmbracelet/crush/internal/csync"
1822 "github.com/charmbracelet/crush/internal/home"
23+ "github.com/charmbracelet/x/etag"
1924)
2025
21−type ProviderClient interface {
22− GetProviders(context.Context, string) ([]catwalk.Provider, error)
26+type syncer[T any] interface {
27+ Get(context.Context) (T, error)
2328}
2429
2530var (
@@ -29,10 +34,10 @@ var (
2934)
3035
3136// file to cache provider data
32−func providerCacheFileData() string {
37+func cachePathFor(name string) string {
3338 xdgDataHome := os.Getenv("XDG_DATA_HOME")
3439 if xdgDataHome != "" {
35− return filepath.Join(xdgDataHome, appName, "providers.json")
40+ return filepath.Join(xdgDataHome, appName, name+".json")
3641 }
3742
3843 // return the path to the main data directory
@@ -43,43 +48,13 @@ func providerCacheFileData() string {
4348 if localAppData == "" {
4449 localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
4550 }
46− return filepath.Join(localAppData, appName, "providers.json")
51+ return filepath.Join(localAppData, appName, name+".json")
4752 }
4853
49− return filepath.Join(home.Dir(), ".local", "share", appName, "providers.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
54+ return filepath.Join(home.Dir(), ".local", "share", appName, name+".json")
8155}
8256
57+// UpdateProviders updates the Catwalk providers list from a specified source.
8358func UpdateProviders(pathOrURL string) error {
8459 var providers []catwalk.Provider
8560 pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
@@ -106,15 +81,55 @@ func UpdateProviders(pathOrURL string) error {
10681 }
10782 }
10883
109− cachePath := providerCacheFileData()
110− if err := saveProvidersInCache(cachePath, providers); err != nil {
84+ if err := newCache[[]catwalk.Provider](cachePathFor("providers")).Store(providers); err != nil {
11185 return fmt.Errorf("failed to save providers to cache: %w", err)
11286 }
11387
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"))
115125 return nil
116126}
117127
128+var (
129+ catwalkSyncer = &catwalkSync{}
130+ hyperSyncer = &hyperSync{}
131+)
132+
118133// Providers returns the list of providers, taking into account cached results
119134// and whether or not auto update is enabled.
120135//
@@ -126,46 +141,87 @@ func UpdateProviders(pathOrURL string) error {
126141// the cached list, or the embedded list if all others fail.
127142func Providers(cfg *Config) ([]catwalk.Provider, error) {
128143 providerOnce.Do(func() {
129− catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
130− client := catwalk.NewWithURL(catwalkURL)
131− path := providerCacheFileData()
132−
133− if cfg.Options.DisableProviderAutoUpdate {
134− slog.Info("Using embedded Catwalk providers")
135− providerList, providerErr = embedded.GetAll(), nil
136− return
137− }
144+ var wg sync.WaitGroup
145+ var errs []error
146+ providers := csync.NewSlice[catwalk.Provider]()
147+ autoupdate := !cfg.Options.DisableProviderAutoUpdate
148+
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
177+ return
178+ }
179+ providers.Append(item)
180+ })
181+
182+ wg.Wait()
183+
184+ providerList = slices.Collect(providers.Seq())
185+ providerErr = errors.Join(errs...)
186+ })
187+ return providerList, providerErr
188+}
138189
139− cached, etag, cachedErr := loadProvidersFromCache(path)
140− if len(cached) == 0 || cachedErr != nil {
141− // if cached file is empty, default to embedded providers
142− cached = embedded.GetAll()
143− }
190+type cache[T any] struct {
191+ path string
192+}
144193
145− providerList, providerErr = loadProviders(client, etag, path)
146− if errors.Is(providerErr, catwalk.ErrNotModified) {
147− slog.Info("Catwalk providers not modified")
148− providerList, providerErr = cached, nil
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
194+func newCache[T any](path string) cache[T] {
195+ return cache[T]{path: path}
156196}
157197
158−func loadProviders(client ProviderClient, etag, path string) ([]catwalk.Provider, error) {
159− slog.Info("Fetching providers from Catwalk.", "path", path)
160− providers, err := client.GetProviders(context.Background(), etag)
198+func (c cache[T]) Get() (T, string, error) {
199+ var v T
200+ data, err := os.ReadFile(c.path)
161201 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)
163216 }
164− if len(providers) == 0 {
165− return nil, errors.New("empty providers list from catwalk")
217+
218+ data, err := json.Marshal(v)
219+ if err != nil {
220+ return fmt.Errorf("failed to marshal provider data: %w", err)
166221 }
167− if err := saveProvidersInCache(path, providers); err != nil {
168− return nil, err
222+
223+ if err := os.WriteFile(c.path, data, 0o644); err != nil {
224+ return fmt.Errorf("failed to write provider data to cache: %w", err)
169225 }
170− return providers, nil
226+ return nil
171227}
internal/config/provider_empty_test.go+18−10
@@ -2,6 +2,7 @@ package config
22
33import (
44 "context"
5+ "os"
56 "testing"
67
78 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -14,18 +15,25 @@ func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.P
1415 return []catwalk.Provider{}, nil
1516}
1617
17−// TestProvider_loadProvidersEmptyResult tests that loadProviders returns an
18−// error when the client returns an empty list. This ensures we don't cache
19−// empty provider lists.
20−func TestProvider_loadProvidersEmptyResult(t *testing.T) {
18+// TestCatwalkSync_GetEmptyResultFromClient tests that when the client returns
19+// an empty list, we fall back to cached providers and return an error.
20+func TestCatwalkSync_GetEmptyResultFromClient(t *testing.T) {
21+ t.Parallel()
22+
23+ tmpDir := t.TempDir()
24+ path := tmpDir + "/providers.json"
25+
26+ syncer := &catwalkSync{}
2127 client := &emptyProviderClient{}
22− tmpPath := t.TempDir() + "/providers.json"
2328
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)
2533 require.Contains(t, err.Error(), "empty providers list from catwalk")
26− require.Empty(t, providers)
27− require.Len(t, providers, 0)
34+ require.NotEmpty(t, providers) // Should have embedded providers as fallback.
2835
29− // Check that no cache file was created for empty results
30− require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
36+ // Check that no cache file was created for empty results.
37+ _, statErr := os.Stat(path)
38+ require.True(t, os.IsNotExist(statErr), "Cache file should not exist for empty results")
3139}
internal/config/provider_test.go+240−76
@@ -1,10 +1,9 @@
11package config
22
33import (
4− "context"
54 "encoding/json"
6− "errors"
75 "os"
6+ "path/filepath"
87 "sync"
98 "testing"
109
@@ -12,47 +11,31 @@ import (
1211 "github.com/stretchr/testify/require"
1312)
1413
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−
3414func resetProviderState() {
3515 providerOnce = sync.Once{}
3616 providerList = nil
3717 providerErr = nil
18+ catwalkSyncer = &catwalkSync{}
19+ hyperSyncer = &hyperSync{}
3820}
3921
40−func TestProvider_loadProvidersNoIssues(t *testing.T) {
41− client := &mockProviderClient{shouldFail: false}
42− tmpPath := t.TempDir() + "/providers.json"
43− providers, err := loadProviders(client, "", tmpPath)
44− require.NoError(t, err)
45− require.NotNil(t, providers)
46− require.Len(t, providers, 1)
22+func TestProviders_Integration_AutoUpdateDisabled(t *testing.T) {
23+ tmpDir := t.TempDir()
24+ t.Setenv("XDG_DATA_HOME", tmpDir)
4725
48− // check if file got saved
49− fileInfo, err := os.Stat(tmpPath)
50− require.NoError(t, err)
51− require.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
52−}
26+ // Use a test-specific instance to avoid global state interference.
27+ testCatwalkSyncer := &catwalkSync{}
28+ testHyperSyncer := &hyperSync{}
29+
30+ originalCatwalSyncer := catwalkSyncer
31+ originalHyperSyncer := hyperSyncer
32+ defer func() {
33+ catwalkSyncer = originalCatwalSyncer
34+ hyperSyncer = originalHyperSyncer
35+ }()
5336
54−func TestProvider_DisableAutoUpdate(t *testing.T) {
55− t.Setenv("XDG_DATA_HOME", t.TempDir())
37+ catwalkSyncer = testCatwalkSyncer
38+ hyperSyncer = testHyperSyncer
5639
5740 resetProviderState()
5841 defer resetProviderState()
@@ -69,76 +52,257 @@ func TestProvider_DisableAutoUpdate(t *testing.T) {
6952 require.Greater(t, len(providers), 5, "Expected embedded providers")
7053}
7154
72−func TestProvider_WithValidCache(t *testing.T) {
55+func TestProviders_Integration_WithMockClients(t *testing.T) {
7356 tmpDir := t.TempDir()
7457 t.Setenv("XDG_DATA_HOME", tmpDir)
7558
76− resetProviderState()
77− defer resetProviderState()
59+ // Create fresh syncers for this test.
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"
78107
79− cachePath := tmpDir + "/crush/providers.json"
80108 require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
81− cachedProviders := []catwalk.Provider{
82− {Name: "Cached"},
109+
110+ // Write Catwalk cache.
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{
121+ Name: "Cached Hyper",
122+ ID: "hyper",
83123 }
84− data, err := json.Marshal(cachedProviders)
124+ data, err = json.Marshal(hyperProvider)
85125 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+ }
87139
88− mockClient := &mockProviderClient{shouldFail: false}
140+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
141+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
89142
90− providers, err := loadProviders(mockClient, "", cachePath)
143+ // Get providers - should use cached.
144+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
91145 require.NoError(t, err)
92− require.NotNil(t, providers)
93− require.Len(t, providers, 1)
94− require.Equal(t, "Mock", providers[0].Name, "Expected fresh provider from fetch")
146+ require.Len(t, catwalkResult, 2)
147+ require.Equal(t, "Cached1", catwalkResult[0].Name)
148+
149+ hyperResult, err := testHyperSyncer.Get(t.Context())
150+ require.NoError(t, err)
151+ require.Equal(t, "Cached Hyper", hyperResult.Name)
95152}
96153
97−func TestProvider_NotModifiedUsesCached(t *testing.T) {
154+func TestProviders_Integration_CatwalkFailsHyperSucceeds(t *testing.T) {
98155 tmpDir := t.TempDir()
99156 t.Setenv("XDG_DATA_HOME", tmpDir)
100157
101− resetProviderState()
102− defer resetProviderState()
158+ testCatwalkSyncer := &catwalkSync{}
159+ testHyperSyncer := &hyperSync{}
103160
104− cachePath := tmpDir + "/crush/providers.json"
105− require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
106− cachedProviders := []catwalk.Provider{
107− {Name: "Cached"},
161+ // Catwalk fails, Hyper succeeds.
162+ mockCatwalkClient := &mockCatwalkClient{
163+ err: catwalk.ErrNotModified, // Will use embedded.
108164 }
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())
110182 require.NoError(t, err)
111− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
183+ require.NotEmpty(t, catwalkResult) // Should have embedded.
112184
113− mockClient := &mockProviderClient{shouldReturnErr: catwalk.ErrNotModified}
114− providers, err := loadProviders(mockClient, "", cachePath)
115− require.ErrorIs(t, err, catwalk.ErrNotModified)
116− require.Nil(t, providers)
185+ hyperResult, err := testHyperSyncer.Get(t.Context())
186+ require.NoError(t, err)
187+ require.Equal(t, "Hyper", hyperResult.Name)
117188}
118189
119−func TestProvider_EmptyCacheDefaultsToEmbedded(t *testing.T) {
190+func TestProviders_Integration_BothFail(t *testing.T) {
120191 tmpDir := t.TempDir()
121192 t.Setenv("XDG_DATA_HOME", tmpDir)
122193
123− resetProviderState()
124− defer resetProviderState()
194+ testCatwalkSyncer := &catwalkSync{}
195+ testHyperSyncer := &hyperSync{}
125196
126− cachePath := tmpDir + "/crush/providers.json"
127− require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
128− emptyProviders := []catwalk.Provider{}
129− data, err := json.Marshal(emptyProviders)
197+ // Both fail.
198+ mockCatwalkClient := &mockCatwalkClient{
199+ err: catwalk.ErrNotModified,
200+ }
201+ mockHyperClient := &mockHyperClient{
202+ provider: catwalk.Provider{}, // Empty provider.
203+ }
204+
205+ catwalkPath := tmpDir + "/crush/providers.json"
206+ hyperPath := tmpDir + "/crush/hyper.json"
207+
208+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
209+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
210+
211+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
130212 require.NoError(t, err)
131− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
213+ require.NotEmpty(t, catwalkResult) // Should fall back to embedded.
132214
133− cached, _, err := loadProvidersFromCache(cachePath)
215+ hyperResult, err := testHyperSyncer.Get(t.Context())
134216 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.
136218}
137219
138−func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
139− client := &mockProviderClient{shouldFail: true}
140− tmpPath := t.TempDir() + "/providers.json"
141− providers, err := loadProviders(client, "", tmpPath)
220+func TestCache_StoreAndGet(t *testing.T) {
221+ t.Parallel()
222+
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()
142254 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+ }
144308}
internal/event/event.go+16
@@ -46,6 +46,22 @@ func Init() {
4646 distinctId = getDistinctId()
4747}
4848
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+
4965// send logs an event to PostHog with the given event name and properties.
5066func send(event string, props ...any) {
5167 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 (
44 "time"
55)
66
7−// Token represents an OAuth2 token from Claude Code Max.
7+// Token represents an OAuth2 token.
88type Token struct {
99 AccessToken string `json:"access_token"`
1010 RefreshToken string `json:"refresh_token"`
internal/tui/components/dialogs/commands/commands.go+2−1
@@ -13,6 +13,7 @@ import (
1313 "github.com/charmbracelet/catwalk/pkg/catwalk"
1414
1515 "github.com/charmbracelet/crush/internal/agent"
16+ "github.com/charmbracelet/crush/internal/agent/hyper"
1617 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1718 "github.com/charmbracelet/crush/internal/config"
1819 "github.com/charmbracelet/crush/internal/csync"
@@ -363,7 +364,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
363364 selectedModel := cfg.Models[agentCfg.Model]
364365
365366 // Anthropic models: thinking toggle
366− if providerCfg.Type == catwalk.TypeAnthropic {
367+ if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
367368 status := "Enable"
368369 if selectedModel.Think {
369370 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 {
1515 isAPIKeyHelp bool
1616 isAPIKeyValid bool
1717
18+ isHyperDeviceFlow bool
19+
1820 isClaudeAuthChoiceHelp bool
1921 isClaudeOAuthHelp bool
2022 isClaudeOAuthURLState bool
@@ -74,6 +76,19 @@ func (k KeyMap) FullHelp() [][]key.Binding {
7476
7577// ShortHelp implements help.KeyMap.
7678func (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+ }
7792 if k.isClaudeAuthChoiceHelp {
7893 return []key.Binding{
7994 key.NewBinding(
internal/tui/components/dialogs/models/list.go+18−2
@@ -62,7 +62,8 @@ func (m *ModelListComponent) Init() tea.Cmd {
6262 filteredProviders := []catwalk.Provider{}
6363 for _, p := range providers {
6464 hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
65− if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
65+ isHyper := p.ID == "hyper"
66+ if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper {
6667 filteredProviders = append(filteredProviders, p)
6768 }
6869 }
@@ -204,8 +205,23 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
204205 }
205206 }
206207
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+
207223 // Then add the known providers from the predefined list
208− for _, provider := range m.providers {
224+ for _, provider := range sortedProviders {
209225 // Skip if we already added this provider as an unknown provider
210226 if addedProviders[string(provider.ID)] {
211227 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.
12package models
23
34import (
@@ -11,10 +12,12 @@ import (
1112 "charm.land/lipgloss/v2"
1213 "github.com/atotto/clipboard"
1314 "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1416 "github.com/charmbracelet/crush/internal/config"
1517 "github.com/charmbracelet/crush/internal/tui/components/core"
1618 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
1719 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
1821 "github.com/charmbracelet/crush/internal/tui/exp/list"
1922 "github.com/charmbracelet/crush/internal/tui/styles"
2023 "github.com/charmbracelet/crush/internal/tui/util"
@@ -70,6 +73,10 @@ type modelDialogCmp struct {
7073 isAPIKeyValid bool
7174 apiKeyValue string
7275
76+ // Hyper device flow state
77+ hyperDeviceFlow *hyper.DeviceFlow
78+ showHyperDeviceFlow bool
79+
7380 // Claude state
7481 claudeAuthMethodChooser *claude.AuthMethodChooser
7582 claudeOAuth2 *claude.OAuth2
@@ -127,6 +134,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
127134 u, cmd := m.apiKeyInput.Update(msg)
128135 m.apiKeyInput = u.(*APIKeyInput)
129136 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
130146 case claude.ValidationCompletedMsg:
131147 var cmds []tea.Cmd
132148 u, cmd := m.claudeOAuth2.Update(msg)
@@ -134,7 +150,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
134150 cmds = append(cmds, cmd)
135151
136152 if msg.State == claude.OAuthValidationStateValid {
137− cmds = append(cmds, m.saveAPIKeyAndContinue(msg.Token, false))
153+ cmds = append(cmds, m.saveOauthTokenAndContinue(msg.Token, false))
138154 m.keyMap.isClaudeOAuthHelpComplete = true
139155 }
140156
@@ -143,6 +159,11 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
143159 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
144160 case tea.KeyPressMsg:
145161 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+ }
146167 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
147168 return m, tea.Sequence(
148169 tea.SetClipboard(m.claudeOAuth2.URL),
@@ -156,6 +177,10 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
156177 m.claudeAuthMethodChooser.ToggleChoice()
157178 return m, nil
158179 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+ }
159184 selectedItem := m.modelList.SelectedModel()
160185
161186 modelType := config.SelectedModelTypeLarge
@@ -167,6 +192,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
167192 m.keyMap.isClaudeAuthChoiceHelp = false
168193 m.keyMap.isClaudeOAuthHelp = false
169194 m.keyMap.isAPIKeyHelp = true
195+ m.showHyperDeviceFlow = false
170196 m.showClaudeAuthMethodChooser = false
171197 m.needsAPIKey = true
172198 m.selectedModel = selectedItem
@@ -194,7 +220,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
194220 return m, cmd2
195221 }
196222 if m.isAPIKeyValid {
197− return m, m.saveAPIKeyAndContinue(m.apiKeyValue, true)
223+ return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
198224 }
199225 if m.needsAPIKey {
200226 // Handle API key submission
@@ -249,15 +275,23 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
249275 ModelType: modelType,
250276 }),
251277 )
252− } else {
253− if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
254− m.showClaudeAuthMethodChooser = true
255− m.keyMap.isClaudeAuthChoiceHelp = true
256− return m, nil
257− }
258− askForApiKey()
278+ }
279+ switch selectedItem.Provider.ID {
280+ case catwalk.InferenceProviderAnthropic:
281+ m.showClaudeAuthMethodChooser = true
282+ m.keyMap.isClaudeAuthChoiceHelp = true
259283 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()
260291 }
292+ // For other providers, show API key input
293+ askForApiKey()
294+ return m, nil
261295 case key.Matches(msg, m.keyMap.Tab):
262296 switch {
263297 case m.showClaudeAuthMethodChooser:
@@ -275,6 +309,14 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
275309 return m, m.modelList.SetModelType(LargeModelType)
276310 }
277311 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+ }
278320 if m.showClaudeAuthMethodChooser {
279321 m.claudeAuthMethodChooser.SetDefaults()
280322 m.showClaudeAuthMethodChooser = false
@@ -329,7 +371,20 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
329371 return m, cmd
330372 }
331373 case spinner.TickMsg:
332− if m.showClaudeOAuth2 {
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
387+ } else if m.showClaudeOAuth2 {
333388 u, cmd := m.claudeOAuth2.Update(msg)
334389 m.claudeOAuth2 = u.(*claude.OAuth2)
335390 return m, cmd
@@ -345,6 +400,23 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
345400func (m *modelDialogCmp) View() string {
346401 t := styles.CurrentTheme()
347402
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+
348420 switch {
349421 case m.showClaudeAuthMethodChooser:
350422 chooserView := m.claudeAuthMethodChooser.View()
@@ -397,6 +469,9 @@ func (m *modelDialogCmp) View() string {
397469}
398470
399471func (m *modelDialogCmp) Cursor() *tea.Cursor {
472+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
473+ return m.hyperDeviceFlow.Cursor()
474+ }
400475 if m.showClaudeAuthMethodChooser {
401476 return nil
402477 }
@@ -477,10 +552,8 @@ func (m *modelDialogCmp) modelTypeRadio() string {
477552
478553func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
479554 cfg := config.Get()
480− if _, ok := cfg.Providers.Get(providerID); ok {
481− return true
482− }
483− return false
555+ _, ok := cfg.Providers.Get(providerID)
556+ return ok
484557}
485558
486559func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
@@ -497,7 +570,7 @@ func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*cat
497570 return nil, nil
498571}
499572
500−func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
573+func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd {
501574 if m.selectedModel == nil {
502575 return util.ReportError(fmt.Errorf("no model selected"))
503576 }
internal/tui/exp/list/items.go+6
@@ -323,6 +323,7 @@ type ItemSection interface {
323323 layout.Sizeable
324324 Indexable
325325 SetInfo(info string)
326+ Title() string
326327}
327328type itemSectionModel struct {
328329 width int
@@ -337,6 +338,11 @@ func (m *itemSectionModel) ID() string {
337338 return m.id
338339}
339340
341+// Title implements ItemSection.
342+func (m *itemSectionModel) Title() string {
343+ return m.title
344+}
345+
340346func NewItemSection(title string) ItemSection {
341347 return &itemSectionModel{
342348 title: title,