on main help sign in
Merge branch 'main' into ui
f4e8023Ayman Bagabas
.gitattributes+1
@@ -1,3 +1,4 @@
1*.golden linguist-generated=true -text
1*.golden linguist-generated=true -text
2.github/crush-schema.json linguist-generated=true
2.github/crush-schema.json linguist-generated=true
3+internal/agent/hyper/provider.json linguist-generated=true
3internal/agent/testdata/**/*.yaml -diff linguist-generated=true
4internal/agent/testdata/**/*.yaml -diff linguist-generated=true
.github/workflows/schema-update.yml+4−2
@@ -1,10 +1,11 @@
1−name: Update Schema
1+name: Update files
2
2
3on:
3on:
4 push:
4 push:
5 branches: [main]
5 branches: [main]
6 paths:
6 paths:
7 - "internal/config/**"
7 - "internal/config/**"
8+ - "internal/agent/hyper/**"
8
9
9jobs:
10jobs:
10 update-schema:
11 update-schema:
@@ -17,9 +18,10 @@ jobs:
17 with:
18 with:
18 go-version-file: go.mod
19 go-version-file: go.mod
19 - run: go run . schema > ./schema.json
20 - run: go run . schema > ./schema.json
21+ - run: go generate ./internal/agent/hyper/...
20 - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
22 - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
21 with:
23 with:
22− commit_message: "chore: auto-update generated files"
24+ commit_message: "chore: auto-update files"
23 branch: main
25 branch: main
24 commit_user_name: Charm
26 commit_user_name: Charm
25 commit_user_email: 124303983+charmcli@users.noreply.github.com
27 commit_user_email: 124303983+charmcli@users.noreply.github.com
Taskfile.yaml+7
@@ -101,6 +101,13 @@ tasks:
101 generates:
101 generates:
102 - schema.json
102 - schema.json
103
103
104+ hyper:
105+ desc: Update Hyper embedded provider.json
106+ cmds:
107+ - go generate ./internal/agent/hyper/...
108+ generates:
109+ - ./internal/agent/hyper/provider.json
110+
104 release:
111 release:
105 desc: Create and push a new tag following semver
112 desc: Create and push a new tag following semver
106 vars:
113 vars:
go.mod+3−3
@@ -18,11 +18,12 @@ require (
18 github.com/aymanbagabas/go-udiff v0.3.1
18 github.com/aymanbagabas/go-udiff v0.3.1
19 github.com/bmatcuk/doublestar/v4 v4.9.1
19 github.com/bmatcuk/doublestar/v4 v4.9.1
20 github.com/charlievieth/fastwalk v1.0.14
20 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.10.2
21+ github.com/charmbracelet/catwalk v0.11.0
22 github.com/charmbracelet/colorprofile v0.4.1
22 github.com/charmbracelet/colorprofile v0.4.1
23 github.com/charmbracelet/fang v0.4.4
23 github.com/charmbracelet/fang v0.4.4
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
25 github.com/charmbracelet/x/ansi v0.11.3
25 github.com/charmbracelet/x/ansi v0.11.3
26+ github.com/charmbracelet/x/etag v0.2.0
26 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
27 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
27 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
28 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
28 github.com/charmbracelet/x/exp/ordered v0.1.0
29 github.com/charmbracelet/x/exp/ordered v0.1.0
@@ -53,6 +54,7 @@ require (
53 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
54 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
54 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
55 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
55 github.com/stretchr/testify v1.11.1
56 github.com/stretchr/testify v1.11.1
57+ github.com/tidwall/gjson v1.18.0
56 github.com/tidwall/sjson v1.2.5
58 github.com/tidwall/sjson v1.2.5
57 github.com/zeebo/xxh3 v1.0.2
59 github.com/zeebo/xxh3 v1.0.2
58 golang.org/x/mod v0.31.0
60 golang.org/x/mod v0.31.0
@@ -92,7 +94,6 @@ require (
92 github.com/bahlo/generic-list-go v0.2.0 // indirect
94 github.com/bahlo/generic-list-go v0.2.0 // indirect
93 github.com/buger/jsonparser v1.1.1 // indirect
95 github.com/buger/jsonparser v1.1.1 // indirect
94 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
96 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
95− github.com/charmbracelet/x/etag v0.2.0 // indirect
96 github.com/charmbracelet/x/json v0.2.0 // indirect
97 github.com/charmbracelet/x/json v0.2.0 // indirect
97 github.com/charmbracelet/x/termios v0.1.1 // indirect
98 github.com/charmbracelet/x/termios v0.1.1 // indirect
98 github.com/charmbracelet/x/windows v0.2.2 // indirect
99 github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -145,7 +146,6 @@ require (
145 github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
146 github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
146 github.com/spf13/pflag v1.0.9 // indirect
147 github.com/spf13/pflag v1.0.9 // indirect
147 github.com/tetratelabs/wazero v1.10.1 // indirect
148 github.com/tetratelabs/wazero v1.10.1 // indirect
148− github.com/tidwall/gjson v1.18.0 // indirect
149 github.com/tidwall/match v1.1.1 // indirect
149 github.com/tidwall/match v1.1.1 // indirect
150 github.com/tidwall/pretty v1.2.1 // indirect
150 github.com/tidwall/pretty v1.2.1 // indirect
151 github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect
151 github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect
go.sum+2−2
@@ -92,8 +92,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
92github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
92github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
93github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
93github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
94github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
94github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
95−github.com/charmbracelet/catwalk v0.10.2 h1:Ps6IeGu0ArKE3l3OYv+HwIwbnzZrAl1C3AuwXiOf1G0=
95+github.com/charmbracelet/catwalk v0.11.0 h1:PU3rkc4h4YVJEn9Iyb/1rQAaF4hEd04fuG4tj3vv4dg=
96−github.com/charmbracelet/catwalk v0.10.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
96+github.com/charmbracelet/catwalk v0.11.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
97github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
97github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
98github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
98github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
99github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
99github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
internal/agent/agent.go+17−1
@@ -27,7 +27,9 @@ import (
27 "charm.land/fantasy/providers/google"
27 "charm.land/fantasy/providers/google"
28 "charm.land/fantasy/providers/openai"
28 "charm.land/fantasy/providers/openai"
29 "charm.land/fantasy/providers/openrouter"
29 "charm.land/fantasy/providers/openrouter"
30+ "charm.land/lipgloss/v2"
30 "github.com/charmbracelet/catwalk/pkg/catwalk"
31 "github.com/charmbracelet/catwalk/pkg/catwalk"
32+ "github.com/charmbracelet/crush/internal/agent/hyper"
31 "github.com/charmbracelet/crush/internal/agent/tools"
33 "github.com/charmbracelet/crush/internal/agent/tools"
32 "github.com/charmbracelet/crush/internal/config"
34 "github.com/charmbracelet/crush/internal/config"
33 "github.com/charmbracelet/crush/internal/csync"
35 "github.com/charmbracelet/crush/internal/csync"
@@ -454,8 +456,22 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
454 currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
456 currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
455 } else if isPermissionErr {
457 } else if isPermissionErr {
456 currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
458 currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
459+ } else if errors.Is(err, hyper.ErrNoCredits) {
460+ url := hyper.BaseURL()
461+ link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
462+ currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
457 } else if errors.As(err, &providerErr) {
463 } else if errors.As(err, &providerErr) {
464+ if providerErr.Message == "The requested model is not supported." {
465+ url := "https://github.com/settings/copilot/features"
466+ link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
467+ currentAssistant.AddFinish(
468+ message.FinishReasonError,
469+ "Copilot model not enabled",
470+ fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait a minute before trying again. %s", a.largeModel.CatwalkCfg.Name, link),
471+ )
472+ } else {
458− currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
473+ currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
474+ }
459 } else if errors.As(err, &fantasyErr) {
475 } else if errors.As(err, &fantasyErr) {
460 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
476 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
461 } else {
477 } else {
internal/agent/coordinator.go+15
@@ -17,6 +17,7 @@ import (
17
17
18 "charm.land/fantasy"
18 "charm.land/fantasy"
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
20 "github.com/charmbracelet/crush/internal/agent/prompt"
21 "github.com/charmbracelet/crush/internal/agent/prompt"
21 "github.com/charmbracelet/crush/internal/agent/tools"
22 "github.com/charmbracelet/crush/internal/agent/tools"
22 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
@@ -668,6 +669,18 @@ func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, optio
668 return google.New(opts...)
669 return google.New(opts...)
669}
670}
670
671
672+func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
673+ opts := []hyper.Option{
674+ hyper.WithBaseURL(baseURL),
675+ hyper.WithAPIKey(apiKey),
676+ }
677+ if c.cfg.Options.Debug {
678+ httpClient := log.NewHTTPClient()
679+ opts = append(opts, hyper.WithHTTPClient(httpClient))
680+ }
681+ return hyper.New(opts...)
682+}
683+
671func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
684func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
672 if model.Think {
685 if model.Think {
673 return true
686 return true
@@ -728,6 +741,8 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
728 providerCfg.ExtraBody["tool_stream"] = true
741 providerCfg.ExtraBody["tool_stream"] = true
729 }
742 }
730 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
743 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
744+ case hyper.Name:
745+ return c.buildHyperProvider(baseURL, apiKey)
731 default:
746 default:
732 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
747 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
733 }
748 }
internal/agent/hyper/provider.gonew file+330
@@ -0,0 +1,330 @@
1+// Package hyper provides a fantasy.Provider that proxies requests to Hyper.
2+package hyper
3+
4+import (
5+ "bufio"
6+ "bytes"
7+ "cmp"
8+ "context"
9+ _ "embed"
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "io"
14+ "log/slog"
15+ "maps"
16+ "net/http"
17+ "net/url"
18+ "os"
19+ "strconv"
20+ "strings"
21+ "sync"
22+ "time"
23+
24+ "charm.land/fantasy"
25+ "charm.land/fantasy/object"
26+ "github.com/charmbracelet/catwalk/pkg/catwalk"
27+ "github.com/charmbracelet/crush/internal/event"
28+)
29+
30+//go:generate wget -O provider.json https://console.charm.land/api/v1/provider
31+
32+//go:embed provider.json
33+var embedded []byte
34+
35+// Enabled returns true if hyper is enabled.
36+var Enabled = sync.OnceValue(func() bool {
37+ b, _ := strconv.ParseBool(
38+ cmp.Or(
39+ os.Getenv("HYPER"),
40+ os.Getenv("HYPERCRUSH"),
41+ os.Getenv("HYPER_ENABLE"),
42+ os.Getenv("HYPER_ENABLED"),
43+ ),
44+ )
45+ return b
46+})
47+
48+// Embedded returns the embedded Hyper provider.
49+var Embedded = sync.OnceValue(func() catwalk.Provider {
50+ var provider catwalk.Provider
51+ if err := json.Unmarshal(embedded, &provider); err != nil {
52+ slog.Error("could not use embedded provider data", "err", err)
53+ }
54+ return provider
55+})
56+
57+const (
58+ // Name is the default name of this meta provider.
59+ Name = "hyper"
60+ // defaultBaseURL is the default proxy URL.
61+ // TODO: change this to production URL when ready.
62+ defaultBaseURL = "https://console.charm.land"
63+)
64+
65+// BaseURL returns the base URL, which is either $HYPER_URL or the default.
66+var BaseURL = sync.OnceValue(func() string {
67+ return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
68+})
69+
70+var ErrNoCredits = errors.New("you're out of credits")
71+
72+type options struct {
73+ baseURL string
74+ apiKey string
75+ name string
76+ headers map[string]string
77+ client *http.Client
78+}
79+
80+// Option configures the proxy provider.
81+type Option = func(*options)
82+
83+// New creates a new proxy provider.
84+func New(opts ...Option) (fantasy.Provider, error) {
85+ o := options{
86+ baseURL: BaseURL() + "/api/v1/fantasy",
87+ name: Name,
88+ headers: map[string]string{
89+ "x-crush-id": event.GetID(),
90+ },
91+ client: &http.Client{Timeout: 0}, // stream-safe
92+ }
93+ for _, opt := range opts {
94+ opt(&o)
95+ }
96+ return &provider{options: o}, nil
97+}
98+
99+// WithBaseURL sets the proxy base URL (e.g. http://localhost:8080).
100+func WithBaseURL(url string) Option { return func(o *options) { o.baseURL = url } }
101+
102+// WithName sets the provider name.
103+func WithName(name string) Option { return func(o *options) { o.name = name } }
104+
105+// WithHeaders sets extra headers sent to the proxy.
106+func WithHeaders(headers map[string]string) Option {
107+ return func(o *options) {
108+ maps.Copy(o.headers, headers)
109+ }
110+}
111+
112+// WithHTTPClient sets custom HTTP client.
113+func WithHTTPClient(c *http.Client) Option { return func(o *options) { o.client = c } }
114+
115+// WithAPIKey sets the API key.
116+func WithAPIKey(key string) Option {
117+ return func(o *options) {
118+ o.apiKey = key
119+ }
120+}
121+
122+type provider struct{ options options }
123+
124+func (p *provider) Name() string { return p.options.name }
125+
126+// LanguageModel implements fantasy.Provider.
127+func (p *provider) LanguageModel(_ context.Context, modelID string) (fantasy.LanguageModel, error) {
128+ if modelID == "" {
129+ return nil, errors.New("missing model id")
130+ }
131+ return &languageModel{modelID: modelID, provider: p.options.name, opts: p.options}, nil
132+}
133+
134+type languageModel struct {
135+ provider string
136+ modelID string
137+ opts options
138+}
139+
140+// GenerateObject implements fantasy.LanguageModel.
141+func (m *languageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
142+ return object.GenerateWithTool(ctx, m, call)
143+}
144+
145+// StreamObject implements fantasy.LanguageModel.
146+func (m *languageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
147+ return object.StreamWithTool(ctx, m, call)
148+}
149+
150+func (m *languageModel) Provider() string { return m.provider }
151+func (m *languageModel) Model() string { return m.modelID }
152+
153+// Generate implements fantasy.LanguageModel by calling the proxy JSON endpoint.
154+func (m *languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
155+ resp, err := m.doRequest(ctx, false, call)
156+ if err != nil {
157+ return nil, err
158+ }
159+ defer func() { _ = resp.Body.Close() }()
160+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
161+ b, _ := ioReadAllLimit(resp.Body, 64*1024)
162+ return nil, fmt.Errorf("proxy generate error: %s", strings.TrimSpace(string(b)))
163+ }
164+ var out fantasy.Response
165+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
166+ return nil, err
167+ }
168+ return &out, nil
169+}
170+
171+// Stream implements fantasy.LanguageModel using SSE from the proxy.
172+func (m *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
173+ // Prefer explicit /stream endpoint
174+ resp, err := m.doRequest(ctx, true, call)
175+ if err != nil {
176+ return nil, err
177+ }
178+ switch resp.StatusCode {
179+ case http.StatusTooManyRequests:
180+ _ = resp.Body.Close()
181+ return nil, toProviderError(resp, retryAfter(resp))
182+ case http.StatusUnauthorized:
183+ _ = resp.Body.Close()
184+ return nil, toProviderError(resp, "")
185+ case http.StatusPaymentRequired:
186+ _ = resp.Body.Close()
187+ return nil, ErrNoCredits
188+ }
189+
190+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
191+ defer func() { _ = resp.Body.Close() }()
192+ b, _ := ioReadAllLimit(resp.Body, 64*1024)
193+ return nil, &fantasy.ProviderError{
194+ Title: "Stream Error",
195+ Message: strings.TrimSpace(string(b)),
196+ StatusCode: resp.StatusCode,
197+ }
198+ }
199+
200+ return func(yield func(fantasy.StreamPart) bool) {
201+ defer func() { _ = resp.Body.Close() }()
202+ scanner := bufio.NewScanner(resp.Body)
203+ buf := make([]byte, 0, 64*1024)
204+ scanner.Buffer(buf, 4*1024*1024)
205+
206+ var (
207+ event string
208+ dataBuf bytes.Buffer
209+ sawFinish bool
210+ dispatch = func() bool {
211+ if dataBuf.Len() == 0 || event == "" {
212+ dataBuf.Reset()
213+ event = ""
214+ return true
215+ }
216+ var part fantasy.StreamPart
217+ if err := json.Unmarshal(dataBuf.Bytes(), &part); err != nil {
218+ return yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
219+ }
220+ if part.Type == fantasy.StreamPartTypeFinish {
221+ sawFinish = true
222+ }
223+ ok := yield(part)
224+ dataBuf.Reset()
225+ event = ""
226+ return ok
227+ }
228+ )
229+
230+ for scanner.Scan() {
231+ line := scanner.Text()
232+ if line == "" { // event boundary
233+ if !dispatch() {
234+ return
235+ }
236+ continue
237+ }
238+ if strings.HasPrefix(line, ":") { // comment / ping
239+ continue
240+ }
241+ if strings.HasPrefix(line, "event: ") {
242+ event = strings.TrimSpace(line[len("event: "):])
243+ continue
244+ }
245+ if strings.HasPrefix(line, "data: ") {
246+ if dataBuf.Len() > 0 {
247+ dataBuf.WriteByte('\n')
248+ }
249+ dataBuf.WriteString(line[len("data: "):])
250+ continue
251+ }
252+ }
253+ if err := scanner.Err(); err != nil &&
254+ !errors.Is(err, context.Canceled) &&
255+ !errors.Is(err, context.DeadlineExceeded) {
256+ yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
257+ return
258+ }
259+ // flush any pending data
260+ _ = dispatch()
261+ if !sawFinish {
262+ _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish})
263+ }
264+ }, nil
265+}
266+
267+func (m *languageModel) doRequest(ctx context.Context, stream bool, call fantasy.Call) (*http.Response, error) {
268+ addr, err := url.Parse(m.opts.baseURL)
269+ if err != nil {
270+ return nil, err
271+ }
272+ addr = addr.JoinPath(m.modelID)
273+ if stream {
274+ addr = addr.JoinPath("stream")
275+ } else {
276+ addr = addr.JoinPath("generate")
277+ }
278+
279+ body, err := json.Marshal(call)
280+ if err != nil {
281+ return nil, err
282+ }
283+
284+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, addr.String(), bytes.NewReader(body))
285+ if err != nil {
286+ return nil, err
287+ }
288+ req.Header.Set("Content-Type", "application/json")
289+ if stream {
290+ req.Header.Set("Accept", "text/event-stream")
291+ } else {
292+ req.Header.Set("Accept", "application/json")
293+ }
294+ for k, v := range m.opts.headers {
295+ req.Header.Set(k, v)
296+ }
297+
298+ if m.opts.apiKey != "" {
299+ req.Header.Set("Authorization", m.opts.apiKey)
300+ }
301+ return m.opts.client.Do(req)
302+}
303+
304+// ioReadAllLimit reads up to n bytes.
305+func ioReadAllLimit(r io.Reader, n int64) ([]byte, error) {
306+ var b bytes.Buffer
307+ if n <= 0 {
308+ n = 1 << 20
309+ }
310+ lr := &io.LimitedReader{R: r, N: n}
311+ _, err := b.ReadFrom(lr)
312+ return b.Bytes(), err
313+}
314+
315+func toProviderError(resp *http.Response, message string) error {
316+ return &fantasy.ProviderError{
317+ Title: fantasy.ErrorTitleForStatusCode(resp.StatusCode),
318+ Message: message,
319+ StatusCode: resp.StatusCode,
320+ }
321+}
322+
323+func retryAfter(resp *http.Response) string {
324+ after, err := strconv.Atoi(resp.Header.Get("Retry-After"))
325+ if err == nil && after > 0 {
326+ d := time.Duration(after) * time.Second
327+ return "Try again in " + d.String()
328+ }
329+ return "Try again in later"
330+}
internal/agent/hyper/provider.jsonnew file+1
@@ -0,0 +1 @@
1+{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]}
internal/cmd/login.go+178−17
@@ -9,8 +9,14 @@ import (
9 "strings"
9 "strings"
10
10
11 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
13+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
12 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/oauth"
13 "github.com/charmbracelet/crush/internal/oauth/claude"
16 "github.com/charmbracelet/crush/internal/oauth/claude"
17+ "github.com/charmbracelet/crush/internal/oauth/copilot"
18+ "github.com/charmbracelet/crush/internal/oauth/hyper"
19+ "github.com/pkg/browser"
14 "github.com/spf13/cobra"
20 "github.com/spf13/cobra"
15)
21)
16
22
@@ -20,46 +26,122 @@ var loginCmd = &cobra.Command{
20 Short: "Login Crush to a platform",
26 Short: "Login Crush to a platform",
21 Long: `Login Crush to a specified platform.
27 Long: `Login Crush to a specified platform.
22The platform should be provided as an argument.
28The platform should be provided as an argument.
23−Available platforms are: claude.`,
29+Available platforms are: hyper, claude, copilot.`,
24 Example: `
30 Example: `
31+# Authenticate with Charm Hyper
32+crush login
33+
25# Authenticate with Claude Code Max
34# Authenticate with Claude Code Max
26crush login claude
35crush login claude
36+
37+# Authenticate with GitHub Copilot
38+crush login copilot
27 `,
39 `,
28 ValidArgs: []cobra.Completion{
40 ValidArgs: []cobra.Completion{
41+ "hyper",
29 "claude",
42 "claude",
30 "anthropic",
43 "anthropic",
44+ "copilot",
45+ "github",
46+ "github-copilot",
31 },
47 },
32− Args: cobra.ExactArgs(1),
48+ Args: cobra.MaximumNArgs(1),
33 RunE: func(cmd *cobra.Command, args []string) error {
49 RunE: func(cmd *cobra.Command, args []string) error {
34− if len(args) > 1 {
35− return fmt.Errorf("wrong number of arguments")
36− }
37− if len(args) == 0 || args[0] == "" {
38− return cmd.Help()
39− }
40−
41 app, err := setupAppWithProgressBar(cmd)
50 app, err := setupAppWithProgressBar(cmd)
42 if err != nil {
51 if err != nil {
43 return err
52 return err
44 }
53 }
45 defer app.Shutdown()
54 defer app.Shutdown()
46
55
56+ provider := "hyper"
57+ if len(args) > 0 {
47− switch args[0] {
58+ provider = args[0]
59+ }
60+ switch provider {
61+ case "hyper":
62+ return loginHyper()
48 case "anthropic", "claude":
63 case "anthropic", "claude":
49 return loginClaude()
64 return loginClaude()
65+ case "copilot", "github", "github-copilot":
66+ return loginCopilot()
50 default:
67 default:
51 return fmt.Errorf("unknown platform: %s", args[0])
68 return fmt.Errorf("unknown platform: %s", args[0])
52 }
69 }
53 },
70 },
54}
71}
55
72
56−func loginClaude() error {
73+func loginHyper() error {
74+ cfg := config.Get()
75+ if !hyperp.Enabled() {
76+ return fmt.Errorf("hyper not enabled")
77+ }
57 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
78 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
58− go func() {
59− <-ctx.Done()
60− cancel()
79+ defer cancel()
61− os.Exit(1)
80+
62− }()
81+ resp, err := hyper.InitiateDeviceAuth(ctx)
82+ if err != nil {
83+ return err
84+ }
85+
86+ if clipboard.WriteAll(resp.UserCode) == nil {
87+ fmt.Println("The following code should be on clipboard already:")
88+ } else {
89+ fmt.Println("Copy the following code:")
90+ }
91+
92+ fmt.Println()
93+ fmt.Println(lipgloss.NewStyle().Bold(true).Render(resp.UserCode))
94+ fmt.Println()
95+ fmt.Println("Press enter to open this URL, and then paste it there:")
96+ fmt.Println()
97+ fmt.Println(lipgloss.NewStyle().Hyperlink(resp.VerificationURL, "id=hyper").Render(resp.VerificationURL))
98+ fmt.Println()
99+ waitEnter()
100+ if err := browser.OpenURL(resp.VerificationURL); err != nil {
101+ fmt.Println("Could not open the URL. You'll need to manually open the URL in your browser.")
102+ }
103+
104+ fmt.Println("Exchanging authorization code...")
105+ refreshToken, err := hyper.PollForToken(ctx, resp.DeviceCode, resp.ExpiresIn)
106+ if err != nil {
107+ return err
108+ }
109+
110+ fmt.Println("Exchanging refresh token for access token...")
111+ token, err := hyper.ExchangeToken(ctx, refreshToken)
112+ if err != nil {
113+ return err
114+ }
115+
116+ fmt.Println("Verifying access token...")
117+ introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
118+ if err != nil {
119+ return fmt.Errorf("token introspection failed: %w", err)
120+ }
121+ if !introspect.Active {
122+ return fmt.Errorf("access token is not active")
123+ }
124+
125+ if err := cmp.Or(
126+ cfg.SetConfigField("providers.hyper.api_key", token.AccessToken),
127+ cfg.SetConfigField("providers.hyper.oauth", token),
128+ ); err != nil {
129+ return err
130+ }
131+
132+ fmt.Println()
133+ fmt.Println("You're now authenticated with Hyper!")
134+ return nil
135+}
136+
137+func loginClaude() error {
138+ ctx := getLoginContext()
139+
140+ cfg := config.Get()
141+ if cfg.HasConfigField("providers.anthropic.oauth") {
142+ fmt.Println("You are already logged in to Claude.")
143+ return nil
144+ }
63
145
64 verifier, challenge, err := claude.GetChallenge()
146 verifier, challenge, err := claude.GetChallenge()
65 if err != nil {
147 if err != nil {
@@ -94,7 +176,6 @@ func loginClaude() error {
94 return err
176 return err
95 }
177 }
96
178
97− cfg := config.Get()
98 if err := cmp.Or(
179 if err := cmp.Or(
99 cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
180 cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
100 cfg.SetConfigField("providers.anthropic.oauth", token),
181 cfg.SetConfigField("providers.anthropic.oauth", token),
@@ -106,3 +187,83 @@ func loginClaude() error {
106 fmt.Println("You're now authenticated with Claude Code Max!")
187 fmt.Println("You're now authenticated with Claude Code Max!")
107 return nil
188 return nil
108}
189}
190+
191+func loginCopilot() error {
192+ ctx := getLoginContext()
193+
194+ cfg := config.Get()
195+ if cfg.HasConfigField("providers.copilot.oauth") {
196+ fmt.Println("You are already logged in to GitHub Copilot.")
197+ return nil
198+ }
199+
200+ diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
201+ var token *oauth.Token
202+
203+ switch {
204+ case hasDiskToken:
205+ fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
206+
207+ t, err := copilot.RefreshToken(ctx, diskToken)
208+ if err != nil {
209+ return fmt.Errorf("unable to refresh token from disk: %w", err)
210+ }
211+ token = t
212+ default:
213+ fmt.Println("Requesting device code from GitHub...")
214+ dc, err := copilot.RequestDeviceCode(ctx)
215+ if err != nil {
216+ return err
217+ }
218+
219+ fmt.Println()
220+ fmt.Println("Open the following URL and follow the instructions to authenticate with GitHub Copilot:")
221+ fmt.Println()
222+ fmt.Println(lipgloss.NewStyle().Hyperlink(dc.VerificationURI, "id=copilot").Render(dc.VerificationURI))
223+ fmt.Println()
224+ fmt.Println("Code:", lipgloss.NewStyle().Bold(true).Render(dc.UserCode))
225+ fmt.Println()
226+ fmt.Println("Waiting for authorization...")
227+
228+ t, err := copilot.PollForToken(ctx, dc)
229+ if err == copilot.ErrNotAvailable {
230+ fmt.Println()
231+ fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
232+ fmt.Println()
233+ fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL))
234+ fmt.Println()
235+ fmt.Println("You may be able to request free access if elegible. For more information, see:")
236+ fmt.Println()
237+ fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL))
238+ }
239+ if err != nil {
240+ return err
241+ }
242+ token = t
243+ }
244+
245+ if err := cmp.Or(
246+ cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
247+ cfg.SetConfigField("providers.copilot.oauth", token),
248+ ); err != nil {
249+ return err
250+ }
251+
252+ fmt.Println()
253+ fmt.Println("You're now authenticated with GitHub Copilot!")
254+ return nil
255+}
256+
257+func getLoginContext() context.Context {
258+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
259+ go func() {
260+ <-ctx.Done()
261+ cancel()
262+ os.Exit(1)
263+ }()
264+ return ctx
265+}
266+
267+func waitEnter() {
268+ _, _ = fmt.Scanln()
269+}
internal/cmd/projects.go+3−4
@@ -13,14 +13,13 @@ import (
13
13
14var projectsCmd = &cobra.Command{
14var projectsCmd = &cobra.Command{
15 Use: "projects",
15 Use: "projects",
16− Short: "List all tracked project directories",
16+ Short: "List project directories",
17− Long: `List all directories where Crush has been used.
17+ Long: "List directories where Crush project data is known to exist",
18−This includes the working directory, data directory path, and last accessed time.`,
19 Example: `
18 Example: `
20# List all projects in a table
19# List all projects in a table
21crush projects
20crush projects
22
21
23−# Output as JSON
22+# Output projects data as JSON
24crush projects --json
23crush projects --json
25 `,
24 `,
26 RunE: func(cmd *cobra.Command, args []string) error {
25 RunE: func(cmd *cobra.Command, args []string) error {
internal/cmd/update_providers.go+30−8
@@ -10,22 +10,30 @@ import (
10 "github.com/spf13/cobra"
10 "github.com/spf13/cobra"
11)
11)
12
12
13+var updateProvidersSource string
14+
13var updateProvidersCmd = &cobra.Command{
15var updateProvidersCmd = &cobra.Command{
14 Use: "update-providers [path-or-url]",
16 Use: "update-providers [path-or-url]",
15 Short: "Update providers",
17 Short: "Update providers",
16− Long: `Update the list of providers from a specified local path or remote URL.`,
18+ Long: `Update provider information from a specified local path or remote URL.`,
17 Example: `
19 Example: `
18−# Update providers remotely from Catwalk
20+# Update Catwalk providers remotely (default)
19crush update-providers
21crush update-providers
20
22
21−# Update providers from a custom URL
23+# Update Catwalk providers from a custom URL
22−crush update-providers https://example.com/
24+crush update-providers https://example.com/providers.json
23
25
24−# Update providers from a local file
26+# Update Catwalk providers from a local file
25crush update-providers /path/to/local-providers.json
27crush update-providers /path/to/local-providers.json
26
28
27−# Update providers from embedded version
29+# Update Catwalk providers from embedded version
28crush update-providers embedded
30crush update-providers embedded
31+
32+# Update Hyper provider information
33+crush update-providers --source=hyper
34+
35+# Update Hyper from a custom URL
36+crush update-providers --source=hyper https://hyper.example.com
29`,
37`,
30 RunE: func(cmd *cobra.Command, args []string) error {
38 RunE: func(cmd *cobra.Command, args []string) error {
31 // NOTE(@andreynering): We want to skip logging output do stdout here.
39 // NOTE(@andreynering): We want to skip logging output do stdout here.
@@ -36,7 +44,17 @@ crush update-providers embedded
36 pathOrURL = args[0]
44 pathOrURL = args[0]
37 }
45 }
38
46
47+ var err error
48+ switch updateProvidersSource {
49+ case "catwalk":
39− if err := config.UpdateProviders(pathOrURL); err != nil {
50+ err = config.UpdateProviders(pathOrURL)
51+ case "hyper":
52+ err = config.UpdateHyper(pathOrURL)
53+ default:
54+ return fmt.Errorf("invalid source %q, must be 'catwalk' or 'hyper'", updateProvidersSource)
55+ }
56+
57+ if err != nil {
40 return err
58 return err
41 }
59 }
42
60
@@ -52,9 +70,13 @@ crush update-providers embedded
52 SetString("SUCCESS")
70 SetString("SUCCESS")
53 textStyle := lipgloss.NewStyle().
71 textStyle := lipgloss.NewStyle().
54 MarginLeft(2).
72 MarginLeft(2).
55− SetString("Providers updated successfully.")
73+ SetString(fmt.Sprintf("%s provider updated successfully.", updateProvidersSource))
56
74
57 fmt.Printf("%s\n%s\n\n", headerStyle.Render(), textStyle.Render())
75 fmt.Printf("%s\n%s\n\n", headerStyle.Render(), textStyle.Render())
58 return nil
76 return nil
59 },
77 },
60}
78}
79+
80+func init() {
81+ updateProvidersCmd.Flags().StringVar(&updateProvidersSource, "source", "catwalk", "Provider source to update (catwalk or hyper)")
82+}
internal/config/catwalk.gonew file+82
@@ -0,0 +1,82 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/charmbracelet/catwalk/pkg/embedded"
12+)
13+
14+type catwalkClient interface {
15+ GetProviders(context.Context, string) ([]catwalk.Provider, error)
16+}
17+
18+var _ syncer[[]catwalk.Provider] = (*catwalkSync)(nil)
19+
20+type catwalkSync struct {
21+ once sync.Once
22+ result []catwalk.Provider
23+ cache cache[[]catwalk.Provider]
24+ client catwalkClient
25+ autoupdate bool
26+ init atomic.Bool
27+}
28+
29+func (s *catwalkSync) Init(client catwalkClient, path string, autoupdate bool) {
30+ s.client = client
31+ s.cache = newCache[[]catwalk.Provider](path)
32+ s.autoupdate = autoupdate
33+ s.init.Store(true)
34+}
35+
36+func (s *catwalkSync) Get(ctx context.Context) ([]catwalk.Provider, error) {
37+ if !s.init.Load() {
38+ panic("called Get before Init")
39+ }
40+
41+ var throwErr error
42+ s.once.Do(func() {
43+ if !s.autoupdate {
44+ slog.Info("Using embedded Catwalk providers")
45+ s.result = embedded.GetAll()
46+ return
47+ }
48+
49+ cached, etag, cachedErr := s.cache.Get()
50+ if len(cached) == 0 || cachedErr != nil {
51+ // if cached file is empty, default to embedded providers
52+ cached = embedded.GetAll()
53+ }
54+
55+ slog.Info("Fetching providers from Catwalk")
56+ result, err := s.client.GetProviders(ctx, etag)
57+ if errors.Is(err, context.DeadlineExceeded) {
58+ slog.Warn("Catwalk providers not updated in time")
59+ s.result = cached
60+ return
61+ }
62+ if errors.Is(err, catwalk.ErrNotModified) {
63+ slog.Info("Catwalk providers not modified")
64+ s.result = cached
65+ return
66+ }
67+ if err != nil {
68+ // On error, fall back to cached (which defaults to embedded if empty).
69+ s.result = cached
70+ return
71+ }
72+ if len(result) == 0 {
73+ s.result = cached
74+ throwErr = errors.New("empty providers list from catwalk")
75+ return
76+ }
77+
78+ s.result = result
79+ throwErr = s.cache.Store(result)
80+ })
81+ return s.result, throwErr
82+}
internal/config/catwalk_test.gonew file+221
@@ -0,0 +1,221 @@
1+package config
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "testing"
9+
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type mockCatwalkClient struct {
15+ providers []catwalk.Provider
16+ err error
17+ callCount int
18+}
19+
20+func (m *mockCatwalkClient) GetProviders(ctx context.Context, etag string) ([]catwalk.Provider, error) {
21+ m.callCount++
22+ return m.providers, m.err
23+}
24+
25+func TestCatwalkSync_Init(t *testing.T) {
26+ t.Parallel()
27+
28+ syncer := &catwalkSync{}
29+ client := &mockCatwalkClient{}
30+ path := "/tmp/test.json"
31+
32+ syncer.Init(client, path, true)
33+
34+ require.True(t, syncer.init.Load())
35+ require.Equal(t, client, syncer.client)
36+ require.Equal(t, path, syncer.cache.path)
37+ require.True(t, syncer.autoupdate)
38+}
39+
40+func TestCatwalkSync_GetPanicIfNotInit(t *testing.T) {
41+ t.Parallel()
42+
43+ syncer := &catwalkSync{}
44+ require.Panics(t, func() {
45+ _, _ = syncer.Get(t.Context())
46+ })
47+}
48+
49+func TestCatwalkSync_GetWithAutoUpdateDisabled(t *testing.T) {
50+ t.Parallel()
51+
52+ syncer := &catwalkSync{}
53+ client := &mockCatwalkClient{
54+ providers: []catwalk.Provider{{Name: "should-not-be-used"}},
55+ }
56+ path := t.TempDir() + "/providers.json"
57+
58+ syncer.Init(client, path, false)
59+
60+ providers, err := syncer.Get(t.Context())
61+ require.NoError(t, err)
62+ require.NotEmpty(t, providers)
63+ require.Equal(t, 0, client.callCount, "Client should not be called when autoupdate is disabled")
64+
65+ // Should return embedded providers.
66+ for _, p := range providers {
67+ require.NotEqual(t, "should-not-be-used", p.Name)
68+ }
69+}
70+
71+func TestCatwalkSync_GetFreshProviders(t *testing.T) {
72+ t.Parallel()
73+
74+ syncer := &catwalkSync{}
75+ client := &mockCatwalkClient{
76+ providers: []catwalk.Provider{
77+ {Name: "Fresh Provider", ID: "fresh"},
78+ },
79+ }
80+ path := t.TempDir() + "/providers.json"
81+
82+ syncer.Init(client, path, true)
83+
84+ providers, err := syncer.Get(t.Context())
85+ require.NoError(t, err)
86+ require.Len(t, providers, 1)
87+ require.Equal(t, "Fresh Provider", providers[0].Name)
88+ require.Equal(t, 1, client.callCount)
89+
90+ // Verify cache was written.
91+ fileInfo, err := os.Stat(path)
92+ require.NoError(t, err)
93+ require.False(t, fileInfo.IsDir())
94+}
95+
96+func TestCatwalkSync_GetNotModifiedUsesCached(t *testing.T) {
97+ t.Parallel()
98+
99+ tmpDir := t.TempDir()
100+ path := tmpDir + "/providers.json"
101+
102+ // Create cache file.
103+ cachedProviders := []catwalk.Provider{
104+ {Name: "Cached Provider", ID: "cached"},
105+ }
106+ data, err := json.Marshal(cachedProviders)
107+ require.NoError(t, err)
108+ require.NoError(t, os.WriteFile(path, data, 0o644))
109+
110+ syncer := &catwalkSync{}
111+ client := &mockCatwalkClient{
112+ err: catwalk.ErrNotModified,
113+ }
114+
115+ syncer.Init(client, path, true)
116+
117+ providers, err := syncer.Get(t.Context())
118+ require.NoError(t, err)
119+ require.Len(t, providers, 1)
120+ require.Equal(t, "Cached Provider", providers[0].Name)
121+ require.Equal(t, 1, client.callCount)
122+}
123+
124+func TestCatwalkSync_GetEmptyResultFallbackToCached(t *testing.T) {
125+ t.Parallel()
126+
127+ tmpDir := t.TempDir()
128+ path := tmpDir + "/providers.json"
129+
130+ // Create cache file.
131+ cachedProviders := []catwalk.Provider{
132+ {Name: "Cached Provider", ID: "cached"},
133+ }
134+ data, err := json.Marshal(cachedProviders)
135+ require.NoError(t, err)
136+ require.NoError(t, os.WriteFile(path, data, 0o644))
137+
138+ syncer := &catwalkSync{}
139+ client := &mockCatwalkClient{
140+ providers: []catwalk.Provider{}, // Empty result.
141+ }
142+
143+ syncer.Init(client, path, true)
144+
145+ providers, err := syncer.Get(t.Context())
146+ require.Error(t, err)
147+ require.Contains(t, err.Error(), "empty providers list from catwalk")
148+ require.Len(t, providers, 1)
149+ require.Equal(t, "Cached Provider", providers[0].Name)
150+}
151+
152+func TestCatwalkSync_GetEmptyCacheDefaultsToEmbedded(t *testing.T) {
153+ t.Parallel()
154+
155+ tmpDir := t.TempDir()
156+ path := tmpDir + "/providers.json"
157+
158+ // Create empty cache file.
159+ emptyProviders := []catwalk.Provider{}
160+ data, err := json.Marshal(emptyProviders)
161+ require.NoError(t, err)
162+ require.NoError(t, os.WriteFile(path, data, 0o644))
163+
164+ syncer := &catwalkSync{}
165+ client := &mockCatwalkClient{
166+ err: errors.New("network error"),
167+ }
168+
169+ syncer.Init(client, path, true)
170+
171+ providers, err := syncer.Get(t.Context())
172+ require.NoError(t, err)
173+ require.NotEmpty(t, providers, "Should fall back to embedded providers")
174+
175+ // Verify it's embedded providers by checking we have multiple common ones.
176+ require.Greater(t, len(providers), 5)
177+}
178+
179+func TestCatwalkSync_GetClientError(t *testing.T) {
180+ t.Parallel()
181+
182+ tmpDir := t.TempDir()
183+ path := tmpDir + "/providers.json"
184+
185+ syncer := &catwalkSync{}
186+ client := &mockCatwalkClient{
187+ err: errors.New("network error"),
188+ }
189+
190+ syncer.Init(client, path, true)
191+
192+ providers, err := syncer.Get(t.Context())
193+ require.NoError(t, err) // Should fall back to embedded.
194+ require.NotEmpty(t, providers)
195+}
196+
197+func TestCatwalkSync_GetCalledMultipleTimesUsesOnce(t *testing.T) {
198+ t.Parallel()
199+
200+ syncer := &catwalkSync{}
201+ client := &mockCatwalkClient{
202+ providers: []catwalk.Provider{
203+ {Name: "Provider", ID: "test"},
204+ },
205+ }
206+ path := t.TempDir() + "/providers.json"
207+
208+ syncer.Init(client, path, true)
209+
210+ // Call Get multiple times.
211+ providers1, err1 := syncer.Get(t.Context())
212+ require.NoError(t, err1)
213+ require.Len(t, providers1, 1)
214+
215+ providers2, err2 := syncer.Get(t.Context())
216+ require.NoError(t, err2)
217+ require.Len(t, providers2, 1)
218+
219+ // Client should only be called once due to sync.Once.
220+ require.Equal(t, 1, client.callCount)
221+}
internal/config/config.go+75−10
@@ -5,6 +5,7 @@ import (
5 "context"
5 "context"
6 "fmt"
6 "fmt"
7 "log/slog"
7 "log/slog"
8+ "maps"
8 "net/http"
9 "net/http"
9 "net/url"
10 "net/url"
10 "os"
11 "os"
@@ -13,11 +14,15 @@ import (
13 "time"
14 "time"
14
15
15 "github.com/charmbracelet/catwalk/pkg/catwalk"
16 "github.com/charmbracelet/catwalk/pkg/catwalk"
17+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
16 "github.com/charmbracelet/crush/internal/csync"
18 "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/env"
19 "github.com/charmbracelet/crush/internal/env"
18 "github.com/charmbracelet/crush/internal/oauth"
20 "github.com/charmbracelet/crush/internal/oauth"
19 "github.com/charmbracelet/crush/internal/oauth/claude"
21 "github.com/charmbracelet/crush/internal/oauth/claude"
22+ "github.com/charmbracelet/crush/internal/oauth/copilot"
23+ "github.com/charmbracelet/crush/internal/oauth/hyper"
20 "github.com/invopop/jsonschema"
24 "github.com/invopop/jsonschema"
25+ "github.com/tidwall/gjson"
21 "github.com/tidwall/sjson"
26 "github.com/tidwall/sjson"
22)
27)
23
28
@@ -119,8 +124,37 @@ type ProviderConfig struct {
119 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
124 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
120}
125}
121
126
127+// ToProvider converts the [ProviderConfig] to a [catwalk.Provider].
128+func (pc *ProviderConfig) ToProvider() catwalk.Provider {
129+ // Convert config provider to provider.Provider format
130+ provider := catwalk.Provider{
131+ Name: pc.Name,
132+ ID: catwalk.InferenceProvider(pc.ID),
133+ Models: make([]catwalk.Model, len(pc.Models)),
134+ }
135+
136+ // Convert models
137+ for i, model := range pc.Models {
138+ provider.Models[i] = catwalk.Model{
139+ ID: model.ID,
140+ Name: model.Name,
141+ CostPer1MIn: model.CostPer1MIn,
142+ CostPer1MOut: model.CostPer1MOut,
143+ CostPer1MInCached: model.CostPer1MInCached,
144+ CostPer1MOutCached: model.CostPer1MOutCached,
145+ ContextWindow: model.ContextWindow,
146+ DefaultMaxTokens: model.DefaultMaxTokens,
147+ CanReason: model.CanReason,
148+ ReasoningLevels: model.ReasoningLevels,
149+ DefaultReasoningEffort: model.DefaultReasoningEffort,
150+ SupportsImages: model.SupportsImages,
151+ }
152+ }
153+
154+ return provider
155+}
156+
122func (pc *ProviderConfig) SetupClaudeCode() {
157func (pc *ProviderConfig) SetupClaudeCode() {
123− pc.APIKey = fmt.Sprintf("Bearer %s", pc.OAuthToken.AccessToken)
124 pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
158 pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
125 pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
159 pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
126
160
@@ -135,6 +169,10 @@ func (pc *ProviderConfig) SetupClaudeCode() {
135 pc.ExtraHeaders["anthropic-beta"] = value
169 pc.ExtraHeaders["anthropic-beta"] = value
136}
170}
137
171
172+func (pc *ProviderConfig) SetupGitHubCopilot() {
173+ maps.Copy(pc.ExtraHeaders, copilot.Headers())
174+}
175+
138type MCPType string
176type MCPType string
139
177
140const (
178const (
@@ -451,6 +489,14 @@ func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model Selecte
451 return nil
489 return nil
452}
490}
453
491
492+func (c *Config) HasConfigField(key string) bool {
493+ data, err := os.ReadFile(c.dataConfigDir)
494+ if err != nil {
495+ return false
496+ }
497+ return gjson.Get(string(data), key).Exists()
498+}
499+
454func (c *Config) SetConfigField(key string, value any) error {
500func (c *Config) SetConfigField(key string, value any) error {
455 // read the data
501 // read the data
456 data, err := os.ReadFile(c.dataConfigDir)
502 data, err := os.ReadFile(c.dataConfigDir)
@@ -483,20 +529,33 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
483 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
529 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
484 }
530 }
485
531
486− // Only Anthropic provider uses OAuth for now.
532+ var newToken *oauth.Token
533+ var refreshErr error
534+ switch providerID {
487− if providerID != string(catwalk.InferenceProviderAnthropic) {
535+ case string(catwalk.InferenceProviderAnthropic):
536+ newToken, refreshErr = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
537+ case string(catwalk.InferenceProviderCopilot):
538+ newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
539+ case hyperp.Name:
540+ newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
541+ default:
488 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
542 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
489 }
543 }
490−
491− newToken, err := claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
492− if err != nil {
544+ if refreshErr != nil {
493− return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
545+ return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
494 }
546 }
495
547
496 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
548 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
497 providerConfig.OAuthToken = newToken
549 providerConfig.OAuthToken = newToken
550+
551+ switch providerID {
552+ case string(catwalk.InferenceProviderAnthropic):
498− providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
553+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
499− providerConfig.SetupClaudeCode()
554+ providerConfig.SetupClaudeCode()
555+ case string(catwalk.InferenceProviderCopilot):
556+ providerConfig.APIKey = newToken.AccessToken
557+ providerConfig.SetupGitHubCopilot()
558+ }
500
559
501 c.Providers.Set(providerID, providerConfig)
560 c.Providers.Set(providerID, providerConfig)
502
561
@@ -531,7 +590,13 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
531 setKeyOrToken = func() {
590 setKeyOrToken = func() {
532 providerConfig.APIKey = v.AccessToken
591 providerConfig.APIKey = v.AccessToken
533 providerConfig.OAuthToken = v
592 providerConfig.OAuthToken = v
593+ switch providerID {
594+ case string(catwalk.InferenceProviderAnthropic):
595+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
534− providerConfig.SetupClaudeCode()
596+ providerConfig.SetupClaudeCode()
597+ case string(catwalk.InferenceProviderCopilot):
598+ providerConfig.SetupGitHubCopilot()
599+ }
535 }
600 }
536 }
601 }
537
602
internal/config/copilot.gonew file+43
@@ -0,0 +1,43 @@
1+package config
2+
3+import (
4+ "cmp"
5+ "context"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/charmbracelet/crush/internal/oauth"
10+ "github.com/charmbracelet/crush/internal/oauth/copilot"
11+)
12+
13+func (c *Config) importCopilot() (*oauth.Token, bool) {
14+ if testing.Testing() {
15+ return nil, false
16+ }
17+
18+ if c.HasConfigField("providers.copilot.api_key") || c.HasConfigField("providers.copilot.oauth") {
19+ return nil, false
20+ }
21+
22+ diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
23+ if !hasDiskToken {
24+ return nil, false
25+ }
26+
27+ slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
28+ token, err := copilot.RefreshToken(context.TODO(), diskToken)
29+ if err != nil {
30+ slog.Error("Unable to import GitHub Copilot token", "error", err)
31+ return nil, false
32+ }
33+
34+ if err := cmp.Or(
35+ c.SetConfigField("providers.copilot.api_key", token.AccessToken),
36+ c.SetConfigField("providers.copilot.oauth", token),
37+ ); err != nil {
38+ slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
39+ }
40+
41+ slog.Info("GitHub Copilot successfully imported")
42+ return token, true
43+}
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+15−2
@@ -17,6 +17,7 @@ import (
17 "testing"
17 "testing"
18
18
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
20 "github.com/charmbracelet/crush/internal/csync"
21 "github.com/charmbracelet/crush/internal/csync"
21 "github.com/charmbracelet/crush/internal/env"
22 "github.com/charmbracelet/crush/internal/env"
22 "github.com/charmbracelet/crush/internal/fsext"
23 "github.com/charmbracelet/crush/internal/fsext"
@@ -131,6 +132,8 @@ func PushPopCrushEnv() func() {
131}
132}
132
133
133func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
134func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
135+ c.importCopilot()
136+
134 knownProviderNames := make(map[string]bool)
137 knownProviderNames := make(map[string]bool)
135 restore := PushPopCrushEnv()
138 restore := PushPopCrushEnv()
136 defer restore()
139 defer restore()
@@ -198,8 +201,18 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
198 Models: p.Models,
201 Models: p.Models,
199 }
202 }
200
203
204+ switch {
201− if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
205+ case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
202 prepared.SetupClaudeCode()
206 prepared.SetupClaudeCode()
207+ case p.ID == catwalk.InferenceProviderCopilot:
208+ if config.OAuthToken != nil {
209+ if token, ok := c.importCopilot(); ok {
210+ prepared.OAuthToken = token
211+ }
212+ }
213+ if config.OAuthToken != nil {
214+ prepared.SetupGitHubCopilot()
215+ }
203 }
216 }
204
217
205 switch p.ID {
218 switch p.ID {
@@ -271,7 +284,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
271 if providerConfig.Type == "" {
284 if providerConfig.Type == "" {
272 providerConfig.Type = catwalk.TypeOpenAICompat
285 providerConfig.Type = catwalk.TypeOpenAICompat
273 }
286 }
274− if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) {
287+ if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
275 slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
288 slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
276 c.Providers.Del(id)
289 c.Providers.Del(id)
277 continue
290 continue
internal/config/provider.go+130−74
@@ -10,16 +10,21 @@ import (
10 "os"
10 "os"
11 "path/filepath"
11 "path/filepath"
12 "runtime"
12 "runtime"
13+ "slices"
13 "strings"
14 "strings"
14 "sync"
15 "sync"
16+ "time"
15
17
16 "github.com/charmbracelet/catwalk/pkg/catwalk"
18 "github.com/charmbracelet/catwalk/pkg/catwalk"
17 "github.com/charmbracelet/catwalk/pkg/embedded"
19 "github.com/charmbracelet/catwalk/pkg/embedded"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
21+ "github.com/charmbracelet/crush/internal/csync"
18 "github.com/charmbracelet/crush/internal/home"
22 "github.com/charmbracelet/crush/internal/home"
23+ "github.com/charmbracelet/x/etag"
19)
24)
20
25
21−type ProviderClient interface {
26+type syncer[T any] interface {
22− GetProviders(context.Context, string) ([]catwalk.Provider, error)
27+ Get(context.Context) (T, error)
23}
28}
24
29
25var (
30var (
@@ -29,10 +34,10 @@ var (
29)
34)
30
35
31// file to cache provider data
36// file to cache provider data
32−func providerCacheFileData() string {
37+func cachePathFor(name string) string {
33 xdgDataHome := os.Getenv("XDG_DATA_HOME")
38 xdgDataHome := os.Getenv("XDG_DATA_HOME")
34 if xdgDataHome != "" {
39 if xdgDataHome != "" {
35− return filepath.Join(xdgDataHome, appName, "providers.json")
40+ return filepath.Join(xdgDataHome, appName, name+".json")
36 }
41 }
37
42
38 // return the path to the main data directory
43 // return the path to the main data directory
@@ -43,43 +48,13 @@ func providerCacheFileData() string {
43 if localAppData == "" {
48 if localAppData == "" {
44 localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
49 localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
45 }
50 }
46− return filepath.Join(localAppData, appName, "providers.json")
51+ return filepath.Join(localAppData, appName, name+".json")
47 }
52 }
48
53
49− return filepath.Join(home.Dir(), ".local", "share", appName, "providers.json")
54+ return filepath.Join(home.Dir(), ".local", "share", appName, name+".json")
50−}
51−
52−func saveProvidersInCache(path string, providers []catwalk.Provider) error {
53− slog.Info("Saving provider data to disk", "path", path)
54− if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
55− return fmt.Errorf("failed to create directory for provider cache: %w", err)
56− }
57−
58− data, err := json.Marshal(providers)
59− if err != nil {
60− return fmt.Errorf("failed to marshal provider data: %w", err)
61− }
62−
63− if err := os.WriteFile(path, data, 0o644); err != nil {
64− return fmt.Errorf("failed to write provider data to cache: %w", err)
65− }
66− return nil
67−}
68−
69−func loadProvidersFromCache(path string) ([]catwalk.Provider, string, error) {
70− data, err := os.ReadFile(path)
71− if err != nil {
72− return nil, "", fmt.Errorf("failed to read provider cache file: %w", err)
73− }
74−
75− var providers []catwalk.Provider
76− if err := json.Unmarshal(data, &providers); err != nil {
77− return nil, "", fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
78− }
79−
80− return providers, catwalk.Etag(data), nil
81}
55}
82
56
57+// UpdateProviders updates the Catwalk providers list from a specified source.
83func UpdateProviders(pathOrURL string) error {
58func UpdateProviders(pathOrURL string) error {
84 var providers []catwalk.Provider
59 var providers []catwalk.Provider
85 pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
60 pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
@@ -106,15 +81,55 @@ func UpdateProviders(pathOrURL string) error {
106 }
81 }
107 }
82 }
108
83
109− cachePath := providerCacheFileData()
84+ if err := newCache[[]catwalk.Provider](cachePathFor("providers")).Store(providers); err != nil {
110− if err := saveProvidersInCache(cachePath, providers); err != nil {
111 return fmt.Errorf("failed to save providers to cache: %w", err)
85 return fmt.Errorf("failed to save providers to cache: %w", err)
112 }
86 }
113
87
114− slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrURL, "to", cachePath)
88+ slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrURL, "to", cachePathFor)
89+ return nil
90+}
91+
92+// UpdateHyper updates the Hyper provider information from a specified URL.
93+func UpdateHyper(pathOrURL string) error {
94+ if !hyper.Enabled() {
95+ return fmt.Errorf("hyper not enabled")
96+ }
97+ var provider catwalk.Provider
98+ pathOrURL = cmp.Or(pathOrURL, hyper.BaseURL())
99+
100+ switch {
101+ case pathOrURL == "embedded":
102+ provider = hyper.Embedded()
103+ case strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://"):
104+ client := realHyperClient{baseURL: pathOrURL}
105+ var err error
106+ provider, err = client.Get(context.Background(), "")
107+ if err != nil {
108+ return fmt.Errorf("failed to fetch provider from Hyper: %w", err)
109+ }
110+ default:
111+ content, err := os.ReadFile(pathOrURL)
112+ if err != nil {
113+ return fmt.Errorf("failed to read file: %w", err)
114+ }
115+ if err := json.Unmarshal(content, &provider); err != nil {
116+ return fmt.Errorf("failed to unmarshal provider data: %w", err)
117+ }
118+ }
119+
120+ if err := newCache[catwalk.Provider](cachePathFor("hyper")).Store(provider); err != nil {
121+ return fmt.Errorf("failed to save Hyper provider to cache: %w", err)
122+ }
123+
124+ slog.Info("Hyper provider updated successfully", "from", pathOrURL, "to", cachePathFor("hyper"))
115 return nil
125 return nil
116}
126}
117
127
128+var (
129+ catwalkSyncer = &catwalkSync{}
130+ hyperSyncer = &hyperSync{}
131+)
132+
118// Providers returns the list of providers, taking into account cached results
133// Providers returns the list of providers, taking into account cached results
119// and whether or not auto update is enabled.
134// and whether or not auto update is enabled.
120//
135//
@@ -126,46 +141,87 @@ func UpdateProviders(pathOrURL string) error {
126// the cached list, or the embedded list if all others fail.
141// the cached list, or the embedded list if all others fail.
127func Providers(cfg *Config) ([]catwalk.Provider, error) {
142func Providers(cfg *Config) ([]catwalk.Provider, error) {
128 providerOnce.Do(func() {
143 providerOnce.Do(func() {
129− catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
144+ var wg sync.WaitGroup
130− client := catwalk.NewWithURL(catwalkURL)
145+ var errs []error
131− path := providerCacheFileData()
146+ providers := csync.NewSlice[catwalk.Provider]()
132−
133− if cfg.Options.DisableProviderAutoUpdate {
147+ autoupdate := !cfg.Options.DisableProviderAutoUpdate
134− slog.Info("Using embedded Catwalk providers")
148+
135− providerList, providerErr = embedded.GetAll(), nil
149+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
150+ defer cancel()
151+
152+ wg.Go(func() {
153+ catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
154+ client := catwalk.NewWithURL(catwalkURL)
155+ path := cachePathFor("providers")
156+ catwalkSyncer.Init(client, path, autoupdate)
157+
158+ items, err := catwalkSyncer.Get(ctx)
159+ if err != nil {
160+ catwalkURL := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
161+ errs = append(errs, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help.\n\nCause: %w", catwalkURL, providerErr)) //nolint:staticcheck
162+ return
163+ }
164+ providers.Append(items...)
165+ })
166+
167+ wg.Go(func() {
168+ if !hyper.Enabled() {
169+ return
170+ }
171+ path := cachePathFor("hyper")
172+ hyperSyncer.Init(realHyperClient{baseURL: hyper.BaseURL()}, path, autoupdate)
173+
174+ item, err := hyperSyncer.Get(ctx)
175+ if err != nil {
176+ errs = append(errs, fmt.Errorf("Crush was unable to fetch updated information from Hyper: %w", err)) //nolint:staticcheck
136− return
177+ return
178+ }
179+ providers.Append(item)
137− }
180+ })
181+
182+ wg.Wait()
183+
184+ providerList = slices.Collect(providers.Seq())
185+ providerErr = errors.Join(errs...)
186+ })
187+ return providerList, providerErr
188+}
138
189
139− cached, etag, cachedErr := loadProvidersFromCache(path)
190+type cache[T any] struct {
140− if len(cached) == 0 || cachedErr != nil {
191+ path string
141− // if cached file is empty, default to embedded providers
142− cached = embedded.GetAll()
143− }
192+}
144
193
145− providerList, providerErr = loadProviders(client, etag, path)
194+func newCache[T any](path string) cache[T] {
146− if errors.Is(providerErr, catwalk.ErrNotModified) {
195+ return cache[T]{path: path}
147− slog.Info("Catwalk providers not modified")
148− providerList, providerErr = cached, nil
149− }
150− })
151− if providerErr != nil {
152− catwalkURL := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
153− return nil, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help.\n\nCause: %w", catwalkURL, providerErr) //nolint:staticcheck
154− }
155− return providerList, nil
156}
196}
157
197
158−func loadProviders(client ProviderClient, etag, path string) ([]catwalk.Provider, error) {
198+func (c cache[T]) Get() (T, string, error) {
159− slog.Info("Fetching providers from Catwalk.", "path", path)
199+ var v T
160− providers, err := client.GetProviders(context.Background(), etag)
200+ data, err := os.ReadFile(c.path)
161 if err != nil {
201 if err != nil {
162− return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
202+ return v, "", fmt.Errorf("failed to read provider cache file: %w", err)
203+ }
204+
205+ if err := json.Unmarshal(data, &v); err != nil {
206+ return v, "", fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
207+ }
208+
209+ return v, etag.Of(data), nil
210+}
211+
212+func (c cache[T]) Store(v T) error {
213+ slog.Info("Saving provider data to disk", "path", c.path)
214+ if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
215+ return fmt.Errorf("failed to create directory for provider cache: %w", err)
163 }
216 }
164− if len(providers) == 0 {
217+
165− return nil, errors.New("empty providers list from catwalk")
218+ data, err := json.Marshal(v)
219+ if err != nil {
220+ return fmt.Errorf("failed to marshal provider data: %w", err)
166 }
221 }
167− if err := saveProvidersInCache(path, providers); err != nil {
222+
168− return nil, err
223+ if err := os.WriteFile(c.path, data, 0o644); err != nil {
224+ return fmt.Errorf("failed to write provider data to cache: %w", err)
169 }
225 }
170− return providers, nil
226+ return nil
171}
227}
internal/config/provider_empty_test.go+18−10
@@ -2,6 +2,7 @@ package config
2
2
3import (
3import (
4 "context"
4 "context"
5+ "os"
5 "testing"
6 "testing"
6
7
7 "github.com/charmbracelet/catwalk/pkg/catwalk"
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -14,18 +15,25 @@ func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.P
14 return []catwalk.Provider{}, nil
15 return []catwalk.Provider{}, nil
15}
16}
16
17
17−// TestProvider_loadProvidersEmptyResult tests that loadProviders returns an
18+// TestCatwalkSync_GetEmptyResultFromClient tests that when the client returns
18−// error when the client returns an empty list. This ensures we don't cache
19+// an empty list, we fall back to cached providers and return an error.
19−// empty provider lists.
20+func TestCatwalkSync_GetEmptyResultFromClient(t *testing.T) {
20−func TestProvider_loadProvidersEmptyResult(t *testing.T) {
21+ t.Parallel()
22+
23+ tmpDir := t.TempDir()
24+ path := tmpDir + "/providers.json"
25+
26+ syncer := &catwalkSync{}
21 client := &emptyProviderClient{}
27 client := &emptyProviderClient{}
22− tmpPath := t.TempDir() + "/providers.json"
23
28
24− providers, err := loadProviders(client, "", tmpPath)
29+ syncer.Init(client, path, true)
30+
31+ providers, err := syncer.Get(t.Context())
32+ require.Error(t, err)
25 require.Contains(t, err.Error(), "empty providers list from catwalk")
33 require.Contains(t, err.Error(), "empty providers list from catwalk")
26− require.Empty(t, providers)
34+ require.NotEmpty(t, providers) // Should have embedded providers as fallback.
27− require.Len(t, providers, 0)
28
35
29− // Check that no cache file was created for empty results
36+ // Check that no cache file was created for empty results.
37+ _, statErr := os.Stat(path)
30− require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
38+ require.True(t, os.IsNotExist(statErr), "Cache file should not exist for empty results")
31}
39}
internal/config/provider_test.go+240−76
@@ -1,10 +1,9 @@
1package config
1package config
2
2
3import (
3import (
4− "context"
5 "encoding/json"
4 "encoding/json"
6− "errors"
7 "os"
5 "os"
6+ "path/filepath"
8 "sync"
7 "sync"
9 "testing"
8 "testing"
10
9
@@ -12,47 +11,31 @@ import (
12 "github.com/stretchr/testify/require"
11 "github.com/stretchr/testify/require"
13)
12)
14
13
15−type mockProviderClient struct {
16− shouldFail bool
17− shouldReturnErr error
18−}
19−
20−func (m *mockProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
21− if m.shouldReturnErr != nil {
22− return nil, m.shouldReturnErr
23− }
24− if m.shouldFail {
25− return nil, errors.New("failed to load providers")
26− }
27− return []catwalk.Provider{
28− {
29− Name: "Mock",
30− },
31− }, nil
32−}
33−
34func resetProviderState() {
14func resetProviderState() {
35 providerOnce = sync.Once{}
15 providerOnce = sync.Once{}
36 providerList = nil
16 providerList = nil
37 providerErr = nil
17 providerErr = nil
18+ catwalkSyncer = &catwalkSync{}
19+ hyperSyncer = &hyperSync{}
38}
20}
39
21
40−func TestProvider_loadProvidersNoIssues(t *testing.T) {
22+func TestProviders_Integration_AutoUpdateDisabled(t *testing.T) {
41− client := &mockProviderClient{shouldFail: false}
23+ tmpDir := t.TempDir()
42− tmpPath := t.TempDir() + "/providers.json"
24+ t.Setenv("XDG_DATA_HOME", tmpDir)
43− providers, err := loadProviders(client, "", tmpPath)
44− require.NoError(t, err)
45− require.NotNil(t, providers)
46− require.Len(t, providers, 1)
47
25
48− // check if file got saved
26+ // Use a test-specific instance to avoid global state interference.
49− fileInfo, err := os.Stat(tmpPath)
27+ testCatwalkSyncer := &catwalkSync{}
50− require.NoError(t, err)
28+ testHyperSyncer := &hyperSync{}
51− require.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
29+
30+ originalCatwalSyncer := catwalkSyncer
31+ originalHyperSyncer := hyperSyncer
32+ defer func() {
33+ catwalkSyncer = originalCatwalSyncer
34+ hyperSyncer = originalHyperSyncer
52−}
35+ }()
53
36
54−func TestProvider_DisableAutoUpdate(t *testing.T) {
37+ catwalkSyncer = testCatwalkSyncer
55− t.Setenv("XDG_DATA_HOME", t.TempDir())
38+ hyperSyncer = testHyperSyncer
56
39
57 resetProviderState()
40 resetProviderState()
58 defer resetProviderState()
41 defer resetProviderState()
@@ -69,76 +52,257 @@ func TestProvider_DisableAutoUpdate(t *testing.T) {
69 require.Greater(t, len(providers), 5, "Expected embedded providers")
52 require.Greater(t, len(providers), 5, "Expected embedded providers")
70}
53}
71
54
72−func TestProvider_WithValidCache(t *testing.T) {
55+func TestProviders_Integration_WithMockClients(t *testing.T) {
73 tmpDir := t.TempDir()
56 tmpDir := t.TempDir()
74 t.Setenv("XDG_DATA_HOME", tmpDir)
57 t.Setenv("XDG_DATA_HOME", tmpDir)
75
58
76− resetProviderState()
59+ // Create fresh syncers for this test.
77− defer resetProviderState()
60+ testCatwalkSyncer := &catwalkSync{}
61+ testHyperSyncer := &hyperSync{}
62+
63+ // Initialize with mock clients.
64+ mockCatwalkClient := &mockCatwalkClient{
65+ providers: []catwalk.Provider{
66+ {Name: "Provider1", ID: "p1"},
67+ {Name: "Provider2", ID: "p2"},
68+ },
69+ }
70+ mockHyperClient := &mockHyperClient{
71+ provider: catwalk.Provider{
72+ Name: "Hyper",
73+ ID: "hyper",
74+ Models: []catwalk.Model{
75+ {ID: "hyper-1", Name: "Hyper Model"},
76+ },
77+ },
78+ }
79+
80+ catwalkPath := tmpDir + "/crush/providers.json"
81+ hyperPath := tmpDir + "/crush/hyper.json"
82+
83+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
84+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
85+
86+ // Get providers from each syncer.
87+ catwalkProviders, err := testCatwalkSyncer.Get(t.Context())
88+ require.NoError(t, err)
89+ require.Len(t, catwalkProviders, 2)
90+
91+ hyperProvider, err := testHyperSyncer.Get(t.Context())
92+ require.NoError(t, err)
93+ require.Equal(t, "Hyper", hyperProvider.Name)
94+
95+ // Verify total.
96+ allProviders := append(catwalkProviders, hyperProvider)
97+ require.Len(t, allProviders, 3)
98+}
99+
100+func TestProviders_Integration_WithCachedData(t *testing.T) {
101+ tmpDir := t.TempDir()
102+ t.Setenv("XDG_DATA_HOME", tmpDir)
103+
104+ // Create cache files.
105+ catwalkPath := tmpDir + "/crush/providers.json"
106+ hyperPath := tmpDir + "/crush/hyper.json"
78
107
79− cachePath := tmpDir + "/crush/providers.json"
80 require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
108 require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
109+
110+ // Write Catwalk cache.
81− cachedProviders := []catwalk.Provider{
111+ catwalkProviders := []catwalk.Provider{
112+ {Name: "Cached1", ID: "c1"},
113+ {Name: "Cached2", ID: "c2"},
114+ }
115+ data, err := json.Marshal(catwalkProviders)
116+ require.NoError(t, err)
117+ require.NoError(t, os.WriteFile(catwalkPath, data, 0o644))
118+
119+ // Write Hyper cache.
120+ hyperProvider := catwalk.Provider{
82− {Name: "Cached"},
121+ Name: "Cached Hyper",
122+ ID: "hyper",
83 }
123 }
84− data, err := json.Marshal(cachedProviders)
124+ data, err = json.Marshal(hyperProvider)
85 require.NoError(t, err)
125 require.NoError(t, err)
86− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
126+ require.NoError(t, os.WriteFile(hyperPath, data, 0o644))
127+
128+ // Create fresh syncers.
129+ testCatwalkSyncer := &catwalkSync{}
130+ testHyperSyncer := &hyperSync{}
131+
132+ // Mock clients that return ErrNotModified.
133+ mockCatwalkClient := &mockCatwalkClient{
134+ err: catwalk.ErrNotModified,
135+ }
136+ mockHyperClient := &mockHyperClient{
137+ err: catwalk.ErrNotModified,
138+ }
87
139
88− mockClient := &mockProviderClient{shouldFail: false}
140+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
141+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
89
142
90− providers, err := loadProviders(mockClient, "", cachePath)
143+ // Get providers - should use cached.
144+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
91 require.NoError(t, err)
145 require.NoError(t, err)
92− require.NotNil(t, providers)
146+ require.Len(t, catwalkResult, 2)
93− require.Len(t, providers, 1)
147+ require.Equal(t, "Cached1", catwalkResult[0].Name)
94− require.Equal(t, "Mock", providers[0].Name, "Expected fresh provider from fetch")
148+
149+ hyperResult, err := testHyperSyncer.Get(t.Context())
150+ require.NoError(t, err)
151+ require.Equal(t, "Cached Hyper", hyperResult.Name)
95}
152}
96
153
97−func TestProvider_NotModifiedUsesCached(t *testing.T) {
154+func TestProviders_Integration_CatwalkFailsHyperSucceeds(t *testing.T) {
98 tmpDir := t.TempDir()
155 tmpDir := t.TempDir()
99 t.Setenv("XDG_DATA_HOME", tmpDir)
156 t.Setenv("XDG_DATA_HOME", tmpDir)
100
157
101− resetProviderState()
158+ testCatwalkSyncer := &catwalkSync{}
102− defer resetProviderState()
159+ testHyperSyncer := &hyperSync{}
103
160
104− cachePath := tmpDir + "/crush/providers.json"
161+ // Catwalk fails, Hyper succeeds.
105− require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
162+ mockCatwalkClient := &mockCatwalkClient{
106− cachedProviders := []catwalk.Provider{
163+ err: catwalk.ErrNotModified, // Will use embedded.
107− {Name: "Cached"},
108 }
164 }
109− data, err := json.Marshal(cachedProviders)
165+ mockHyperClient := &mockHyperClient{
166+ provider: catwalk.Provider{
167+ Name: "Hyper",
168+ ID: "hyper",
169+ Models: []catwalk.Model{
170+ {ID: "hyper-1", Name: "Hyper Model"},
171+ },
172+ },
173+ }
174+
175+ catwalkPath := tmpDir + "/crush/providers.json"
176+ hyperPath := tmpDir + "/crush/hyper.json"
177+
178+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
179+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
180+
181+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
110 require.NoError(t, err)
182 require.NoError(t, err)
111− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
183+ require.NotEmpty(t, catwalkResult) // Should have embedded.
112
184
113− mockClient := &mockProviderClient{shouldReturnErr: catwalk.ErrNotModified}
185+ hyperResult, err := testHyperSyncer.Get(t.Context())
114− providers, err := loadProviders(mockClient, "", cachePath)
186+ require.NoError(t, err)
115− require.ErrorIs(t, err, catwalk.ErrNotModified)
187+ require.Equal(t, "Hyper", hyperResult.Name)
116− require.Nil(t, providers)
117}
188}
118
189
119−func TestProvider_EmptyCacheDefaultsToEmbedded(t *testing.T) {
190+func TestProviders_Integration_BothFail(t *testing.T) {
120 tmpDir := t.TempDir()
191 tmpDir := t.TempDir()
121 t.Setenv("XDG_DATA_HOME", tmpDir)
192 t.Setenv("XDG_DATA_HOME", tmpDir)
122
193
123− resetProviderState()
194+ testCatwalkSyncer := &catwalkSync{}
124− defer resetProviderState()
195+ testHyperSyncer := &hyperSync{}
125
196
197+ // Both fail.
198+ mockCatwalkClient := &mockCatwalkClient{
199+ err: catwalk.ErrNotModified,
200+ }
201+ mockHyperClient := &mockHyperClient{
202+ provider: catwalk.Provider{}, // Empty provider.
203+ }
204+
126− cachePath := tmpDir + "/crush/providers.json"
205+ catwalkPath := tmpDir + "/crush/providers.json"
127− require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
206+ hyperPath := tmpDir + "/crush/hyper.json"
128− emptyProviders := []catwalk.Provider{}
207+
129− data, err := json.Marshal(emptyProviders)
208+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
209+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
210+
211+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
130 require.NoError(t, err)
212 require.NoError(t, err)
131− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
213+ require.NotEmpty(t, catwalkResult) // Should fall back to embedded.
132
214
133− cached, _, err := loadProvidersFromCache(cachePath)
215+ hyperResult, err := testHyperSyncer.Get(t.Context())
134 require.NoError(t, err)
216 require.NoError(t, err)
135− require.Empty(t, cached, "Expected empty cache")
217+ require.Equal(t, "Charm Hyper", hyperResult.Name) // Falls back to embedded when no models.
136}
218}
137
219
138−func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
220+func TestCache_StoreAndGet(t *testing.T) {
139− client := &mockProviderClient{shouldFail: true}
221+ t.Parallel()
140− tmpPath := t.TempDir() + "/providers.json"
222+
141− providers, err := loadProviders(client, "", tmpPath)
223+ tmpDir := t.TempDir()
224+ cachePath := tmpDir + "/test.json"
225+
226+ cache := newCache[[]catwalk.Provider](cachePath)
227+
228+ providers := []catwalk.Provider{
229+ {Name: "Provider1", ID: "p1"},
230+ {Name: "Provider2", ID: "p2"},
231+ }
232+
233+ // Store.
234+ err := cache.Store(providers)
235+ require.NoError(t, err)
236+
237+ // Get.
238+ result, etag, err := cache.Get()
239+ require.NoError(t, err)
240+ require.Len(t, result, 2)
241+ require.Equal(t, "Provider1", result[0].Name)
242+ require.NotEmpty(t, etag)
243+}
244+
245+func TestCache_GetNonExistent(t *testing.T) {
246+ t.Parallel()
247+
248+ tmpDir := t.TempDir()
249+ cachePath := tmpDir + "/nonexistent.json"
250+
251+ cache := newCache[[]catwalk.Provider](cachePath)
252+
253+ _, _, err := cache.Get()
142 require.Error(t, err)
254 require.Error(t, err)
143− require.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
255+ require.Contains(t, err.Error(), "failed to read provider cache file")
256+}
257+
258+func TestCache_GetInvalidJSON(t *testing.T) {
259+ t.Parallel()
260+
261+ tmpDir := t.TempDir()
262+ cachePath := tmpDir + "/invalid.json"
263+
264+ require.NoError(t, os.WriteFile(cachePath, []byte("invalid json"), 0o644))
265+
266+ cache := newCache[[]catwalk.Provider](cachePath)
267+
268+ _, _, err := cache.Get()
269+ require.Error(t, err)
270+ require.Contains(t, err.Error(), "failed to unmarshal provider data from cache")
271+}
272+
273+func TestCachePathFor(t *testing.T) {
274+ tests := []struct {
275+ name string
276+ xdgDataHome string
277+ expected string
278+ }{
279+ {
280+ name: "with XDG_DATA_HOME",
281+ xdgDataHome: "/custom/data",
282+ expected: "/custom/data/crush/providers.json",
283+ },
284+ {
285+ name: "without XDG_DATA_HOME",
286+ xdgDataHome: "",
287+ expected: "", // Will use platform-specific default.
288+ },
289+ }
290+
291+ for _, tt := range tests {
292+ t.Run(tt.name, func(t *testing.T) {
293+ if tt.xdgDataHome != "" {
294+ t.Setenv("XDG_DATA_HOME", tt.xdgDataHome)
295+ } else {
296+ t.Setenv("XDG_DATA_HOME", "")
297+ }
298+
299+ result := cachePathFor("providers")
300+ if tt.expected != "" {
301+ require.Equal(t, tt.expected, filepath.ToSlash(result))
302+ } else {
303+ require.Contains(t, result, "crush")
304+ require.Contains(t, result, "providers.json")
305+ }
306+ })
307+ }
144}
308}
internal/event/event.go+16
@@ -46,6 +46,22 @@ func Init() {
46 distinctId = getDistinctId()
46 distinctId = getDistinctId()
47}
47}
48
48
49+func GetID() string { return distinctId }
50+
51+func Alias(userID string) {
52+ if client == nil || distinctId == fallbackId || distinctId == "" || userID == "" {
53+ return
54+ }
55+ if err := client.Enqueue(posthog.Alias{
56+ DistinctId: distinctId,
57+ Alias: userID,
58+ }); err != nil {
59+ slog.Error("Failed to enqueue PostHog alias event", "error", err)
60+ return
61+ }
62+ slog.Info("Aliased in PostHog", "machine_id", distinctId, "user_id", userID)
63+}
64+
49// send logs an event to PostHog with the given event name and properties.
65// send logs an event to PostHog with the given event name and properties.
50func send(event string, props ...any) {
66func send(event string, props ...any) {
51 if client == nil {
67 if client == nil {
internal/oauth/copilot/disk.gonew file+36
@@ -0,0 +1,36 @@
1+package copilot
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+)
9+
10+func RefreshTokenFromDisk() (string, bool) {
11+ data, err := os.ReadFile(tokenFilePath())
12+ if err != nil {
13+ return "", false
14+ }
15+ var content map[string]struct {
16+ User string `json:"user"`
17+ OAuthToken string `json:"oauth_token"`
18+ GitHubAppID string `json:"githubAppId"`
19+ }
20+ if err := json.Unmarshal(data, &content); err != nil {
21+ return "", false
22+ }
23+ if app, ok := content["github.com:Iv1.b507a08c87ecfe98"]; ok {
24+ return app.OAuthToken, true
25+ }
26+ return "", false
27+}
28+
29+func tokenFilePath() string {
30+ switch runtime.GOOS {
31+ case "windows":
32+ return filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot/apps.json")
33+ default:
34+ return filepath.Join(os.Getenv("HOME"), ".config/github-copilot/apps.json")
35+ }
36+}
internal/oauth/copilot/http.gonew file+17
@@ -0,0 +1,17 @@
1+package copilot
2+
3+const (
4+ userAgent = "GitHubCopilotChat/0.32.4"
5+ editorVersion = "vscode/1.105.1"
6+ editorPluginVersion = "copilot-chat/0.32.4"
7+ integrationID = "vscode-chat"
8+)
9+
10+func Headers() map[string]string {
11+ return map[string]string{
12+ "User-Agent": userAgent,
13+ "Editor-Version": editorVersion,
14+ "Editor-Plugin-Version": editorPluginVersion,
15+ "Copilot-Integration-Id": integrationID,
16+ }
17+}
internal/oauth/copilot/oauth.gonew file+200
@@ -0,0 +1,200 @@
1+package copilot
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "time"
13+
14+ "github.com/charmbracelet/crush/internal/oauth"
15+)
16+
17+const (
18+ clientID = "Iv1.b507a08c87ecfe98"
19+
20+ deviceCodeURL = "https://github.com/login/device/code"
21+ accessTokenURL = "https://github.com/login/oauth/access_token"
22+ copilotTokenURL = "https://api.github.com/copilot_internal/v2/token"
23+)
24+
25+var ErrNotAvailable = errors.New("github copilot not available")
26+
27+type DeviceCode struct {
28+ DeviceCode string `json:"device_code"`
29+ UserCode string `json:"user_code"`
30+ VerificationURI string `json:"verification_uri"`
31+ ExpiresIn int `json:"expires_in"`
32+ Interval int `json:"interval"`
33+}
34+
35+// RequestDeviceCode initiates the device code flow with GitHub.
36+func RequestDeviceCode(ctx context.Context) (*DeviceCode, error) {
37+ data := url.Values{}
38+ data.Set("client_id", clientID)
39+ data.Set("scope", "read:user")
40+
41+ req, err := http.NewRequestWithContext(ctx, "POST", deviceCodeURL, strings.NewReader(data.Encode()))
42+ if err != nil {
43+ return nil, err
44+ }
45+ req.Header.Set("Accept", "application/json")
46+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
47+ req.Header.Set("User-Agent", userAgent)
48+
49+ client := &http.Client{Timeout: 30 * time.Second}
50+ resp, err := client.Do(req)
51+ if err != nil {
52+ return nil, err
53+ }
54+ defer resp.Body.Close()
55+
56+ if resp.StatusCode != http.StatusOK {
57+ body, _ := io.ReadAll(resp.Body)
58+ return nil, fmt.Errorf("device code request failed: %s - %s", resp.Status, string(body))
59+ }
60+
61+ var dc DeviceCode
62+ if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil {
63+ return nil, err
64+ }
65+ return &dc, nil
66+}
67+
68+// PollForToken polls GitHub for the access token after user authorization.
69+func PollForToken(ctx context.Context, dc *DeviceCode) (*oauth.Token, error) {
70+ interval := max(dc.Interval, 5)
71+ deadline := time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second)
72+ ticker := time.NewTicker(time.Duration(interval) * time.Second)
73+ defer ticker.Stop()
74+
75+ for time.Now().Before(deadline) {
76+ select {
77+ case <-ctx.Done():
78+ return nil, ctx.Err()
79+ case <-ticker.C:
80+ }
81+
82+ token, err := tryGetToken(ctx, dc.DeviceCode)
83+ if err == errPending {
84+ continue
85+ }
86+ if err == errSlowDown {
87+ interval += 5
88+ ticker.Reset(time.Duration(interval) * time.Second)
89+ continue
90+ }
91+ if err != nil {
92+ return nil, err
93+ }
94+ return token, nil
95+ }
96+
97+ return nil, fmt.Errorf("authorization timed out")
98+}
99+
100+var (
101+ errPending = fmt.Errorf("pending")
102+ errSlowDown = fmt.Errorf("slow_down")
103+)
104+
105+func tryGetToken(ctx context.Context, deviceCode string) (*oauth.Token, error) {
106+ data := url.Values{}
107+ data.Set("client_id", clientID)
108+ data.Set("device_code", deviceCode)
109+ data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
110+
111+ req, err := http.NewRequestWithContext(ctx, "POST", accessTokenURL, strings.NewReader(data.Encode()))
112+ if err != nil {
113+ return nil, err
114+ }
115+ req.Header.Set("Accept", "application/json")
116+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
117+ req.Header.Set("User-Agent", userAgent)
118+
119+ client := &http.Client{Timeout: 30 * time.Second}
120+ resp, err := client.Do(req)
121+ if err != nil {
122+ return nil, err
123+ }
124+ defer resp.Body.Close()
125+
126+ var result struct {
127+ AccessToken string `json:"access_token"`
128+ Error string `json:"error"`
129+ }
130+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
131+ return nil, err
132+ }
133+
134+ switch result.Error {
135+ case "":
136+ if result.AccessToken == "" {
137+ return nil, errPending
138+ }
139+ return getCopilotToken(ctx, result.AccessToken)
140+ case "authorization_pending":
141+ return nil, errPending
142+ case "slow_down":
143+ return nil, errSlowDown
144+ default:
145+ return nil, fmt.Errorf("authorization failed: %s", result.Error)
146+ }
147+}
148+
149+func getCopilotToken(ctx context.Context, githubToken string) (*oauth.Token, error) {
150+ req, err := http.NewRequestWithContext(ctx, "GET", copilotTokenURL, nil)
151+ if err != nil {
152+ return nil, err
153+ }
154+ req.Header.Set("Accept", "application/json")
155+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", githubToken))
156+ for k, v := range Headers() {
157+ req.Header.Set(k, v)
158+ }
159+
160+ client := &http.Client{Timeout: 30 * time.Second}
161+ resp, err := client.Do(req)
162+ if err != nil {
163+ return nil, err
164+ }
165+ defer resp.Body.Close()
166+
167+ body, err := io.ReadAll(resp.Body)
168+ if err != nil {
169+ return nil, err
170+ }
171+
172+ if resp.StatusCode == http.StatusForbidden {
173+ return nil, ErrNotAvailable
174+ }
175+ if resp.StatusCode != http.StatusOK {
176+ return nil, fmt.Errorf("copilot token request failed: %s - %s", resp.Status, string(body))
177+ }
178+
179+ var result struct {
180+ Token string `json:"token"`
181+ ExpiresAt int64 `json:"expires_at"`
182+ }
183+ if err := json.Unmarshal(body, &result); err != nil {
184+ return nil, err
185+ }
186+
187+ copilotToken := &oauth.Token{
188+ AccessToken: result.Token,
189+ RefreshToken: githubToken,
190+ ExpiresAt: result.ExpiresAt,
191+ }
192+ copilotToken.SetExpiresIn()
193+
194+ return copilotToken, nil
195+}
196+
197+// RefreshToken refreshes the Copilot token using the GitHub token.
198+func RefreshToken(ctx context.Context, githubToken string) (*oauth.Token, error) {
199+ return getCopilotToken(ctx, githubToken)
200+}
internal/oauth/copilot/urls.gonew file+6
@@ -0,0 +1,6 @@
1+package copilot
2+
3+const (
4+ SignupURL = "https://github.com/github-copilot/signup?editor=crush"
5+ FreeURL = "https://docs.github.com/en/copilot/how-tos/manage-your-account/get-free-access-to-copilot-pro"
6+)
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+6−1
@@ -4,7 +4,7 @@ import (
4 "time"
4 "time"
5)
5)
6
6
7−// Token represents an OAuth2 token from Claude Code Max.
7+// Token represents an OAuth2 token.
8type Token struct {
8type Token struct {
9 AccessToken string `json:"access_token"`
9 AccessToken string `json:"access_token"`
10 RefreshToken string `json:"refresh_token"`
10 RefreshToken string `json:"refresh_token"`
@@ -21,3 +21,8 @@ func (t *Token) SetExpiresAt() {
21func (t *Token) IsExpired() bool {
21func (t *Token) IsExpired() bool {
22 return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10)
22 return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10)
23}
23}
24+
25+// SetExpiresIn calculates and sets the ExpiresIn field based on the ExpiresAt field.
26+func (t *Token) SetExpiresIn() {
27+ t.ExpiresIn = int(time.Until(time.Unix(t.ExpiresAt, 0)).Seconds())
28+}
internal/tui/components/dialogs/commands/commands.go+2−1
@@ -13,6 +13,7 @@ import (
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
14
14
15 "github.com/charmbracelet/crush/internal/agent"
15 "github.com/charmbracelet/crush/internal/agent"
16+ "github.com/charmbracelet/crush/internal/agent/hyper"
16 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
17 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
17 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/csync"
19 "github.com/charmbracelet/crush/internal/csync"
@@ -363,7 +364,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
363 selectedModel := cfg.Models[agentCfg.Model]
364 selectedModel := cfg.Models[agentCfg.Model]
364
365
365 // Anthropic models: thinking toggle
366 // Anthropic models: thinking toggle
366− if providerCfg.Type == catwalk.TypeAnthropic {
367+ if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
367 status := "Enable"
368 status := "Enable"
368 if selectedModel.Think {
369 if selectedModel.Think {
369 status = "Disable"
370 status = "Disable"
internal/tui/components/dialogs/copilot/device_flow.gonew file+281
@@ -0,0 +1,281 @@
1+// Package copilot provides the dialog for Copilot device flow authentication.
2+package copilot
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+ "github.com/charmbracelet/crush/internal/oauth"
13+ "github.com/charmbracelet/crush/internal/oauth/copilot"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+ "github.com/charmbracelet/crush/internal/tui/util"
16+ "github.com/pkg/browser"
17+)
18+
19+// DeviceFlowState represents the current state of the device flow.
20+type DeviceFlowState int
21+
22+const (
23+ DeviceFlowStateDisplay DeviceFlowState = iota
24+ DeviceFlowStateSuccess
25+ DeviceFlowStateError
26+ DeviceFlowStateUnavailable
27+)
28+
29+// DeviceAuthInitiatedMsg is sent when the device auth is initiated
30+// successfully.
31+type DeviceAuthInitiatedMsg struct {
32+ deviceCode *copilot.DeviceCode
33+}
34+
35+// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
36+type DeviceFlowCompletedMsg struct {
37+ Token *oauth.Token
38+}
39+
40+// DeviceFlowErrorMsg is sent when the device flow encounters an error.
41+type DeviceFlowErrorMsg struct {
42+ Error error
43+}
44+
45+// DeviceFlow handles the Copilot device flow authentication.
46+type DeviceFlow struct {
47+ State DeviceFlowState
48+ width int
49+ deviceCode *copilot.DeviceCode
50+ token *oauth.Token
51+ cancelFunc context.CancelFunc
52+ spinner spinner.Model
53+}
54+
55+// NewDeviceFlow creates a new device flow component.
56+func NewDeviceFlow() *DeviceFlow {
57+ s := spinner.New()
58+ s.Spinner = spinner.Dot
59+ s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
60+ return &DeviceFlow{
61+ State: DeviceFlowStateDisplay,
62+ spinner: s,
63+ }
64+}
65+
66+// Init initializes the device flow by calling the device auth API and starting polling.
67+func (d *DeviceFlow) Init() tea.Cmd {
68+ return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
69+}
70+
71+// Update handles messages and state transitions.
72+func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
73+ var cmd tea.Cmd
74+ d.spinner, cmd = d.spinner.Update(msg)
75+
76+ switch msg := msg.(type) {
77+ case DeviceAuthInitiatedMsg:
78+ return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
79+ case DeviceFlowCompletedMsg:
80+ d.State = DeviceFlowStateSuccess
81+ d.token = msg.Token
82+ return d, nil
83+ case DeviceFlowErrorMsg:
84+ switch msg.Error {
85+ case copilot.ErrNotAvailable:
86+ d.State = DeviceFlowStateUnavailable
87+ default:
88+ d.State = DeviceFlowStateError
89+ }
90+ return d, nil
91+ }
92+
93+ return d, cmd
94+}
95+
96+// View renders the device flow dialog.
97+func (d *DeviceFlow) View() string {
98+ t := styles.CurrentTheme()
99+
100+ whiteStyle := lipgloss.NewStyle().Foreground(t.White)
101+ primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
102+ greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
103+ linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
104+ errorStyle := lipgloss.NewStyle().Foreground(t.Error)
105+ mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
106+
107+ switch d.State {
108+ case DeviceFlowStateDisplay:
109+ if d.deviceCode == nil {
110+ return lipgloss.NewStyle().
111+ Margin(0, 1).
112+ Render(
113+ greenStyle.Render(d.spinner.View()) +
114+ mutedStyle.Render("Initializing..."),
115+ )
116+ }
117+
118+ instructions := lipgloss.NewStyle().
119+ Margin(1, 1, 0, 1).
120+ Width(d.width - 2).
121+ Render(
122+ whiteStyle.Render("Press ") +
123+ primaryStyle.Render("enter") +
124+ whiteStyle.Render(" to copy the code below and open the browser."),
125+ )
126+
127+ codeBox := lipgloss.NewStyle().
128+ Width(d.width-2).
129+ Height(7).
130+ Align(lipgloss.Center, lipgloss.Center).
131+ Background(t.BgBaseLighter).
132+ Margin(1).
133+ Render(
134+ lipgloss.NewStyle().
135+ Bold(true).
136+ Foreground(t.White).
137+ Render(d.deviceCode.UserCode),
138+ )
139+
140+ uri := d.deviceCode.VerificationURI
141+ link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri)
142+ url := mutedStyle.
143+ Margin(0, 1).
144+ Width(d.width - 2).
145+ Render("Browser not opening? Refer to\n" + link)
146+
147+ waiting := greenStyle.
148+ Width(d.width-2).
149+ Margin(1, 1, 0, 1).
150+ Render(d.spinner.View() + "Verifying...")
151+
152+ return lipgloss.JoinVertical(
153+ lipgloss.Left,
154+ instructions,
155+ codeBox,
156+ url,
157+ waiting,
158+ )
159+
160+ case DeviceFlowStateSuccess:
161+ return greenStyle.Margin(0, 1).Render("Authentication successful!")
162+
163+ case DeviceFlowStateError:
164+ return lipgloss.NewStyle().
165+ Margin(0, 1).
166+ Width(d.width - 2).
167+ Render(errorStyle.Render("Authentication failed."))
168+
169+ case DeviceFlowStateUnavailable:
170+ message := lipgloss.NewStyle().
171+ Margin(0, 1).
172+ Width(d.width - 2).
173+ Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
174+ freeMessage := lipgloss.NewStyle().
175+ Margin(0, 1).
176+ Width(d.width - 2).
177+ Render("You may be able to request free access if elegible. For more information, see:")
178+ return lipgloss.JoinVertical(
179+ lipgloss.Left,
180+ message,
181+ "",
182+ linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL),
183+ "",
184+ freeMessage,
185+ "",
186+ linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL),
187+ )
188+
189+ default:
190+ return ""
191+ }
192+}
193+
194+// SetWidth sets the width of the dialog.
195+func (d *DeviceFlow) SetWidth(w int) {
196+ d.width = w
197+}
198+
199+// Cursor hides the cursor.
200+func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
201+
202+// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
203+func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
204+ switch d.State {
205+ case DeviceFlowStateDisplay:
206+ return tea.Sequence(
207+ tea.SetClipboard(d.deviceCode.UserCode),
208+ func() tea.Msg {
209+ if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil {
210+ return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
211+ }
212+ return nil
213+ },
214+ util.ReportInfo("Code copied and URL opened"),
215+ )
216+ case DeviceFlowStateUnavailable:
217+ return tea.Sequence(
218+ func() tea.Msg {
219+ if err := browser.OpenURL(copilot.SignupURL); err != nil {
220+ return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
221+ }
222+ return nil
223+ },
224+ util.ReportInfo("Code copied and URL opened"),
225+ )
226+ default:
227+ return nil
228+ }
229+}
230+
231+// CopyCode copies just the user code to the clipboard.
232+func (d *DeviceFlow) CopyCode() tea.Cmd {
233+ if d.State != DeviceFlowStateDisplay {
234+ return nil
235+ }
236+ return tea.Sequence(
237+ tea.SetClipboard(d.deviceCode.UserCode),
238+ util.ReportInfo("Code copied to clipboard"),
239+ )
240+}
241+
242+// Cancel cancels the device flow polling.
243+func (d *DeviceFlow) Cancel() {
244+ if d.cancelFunc != nil {
245+ d.cancelFunc()
246+ }
247+}
248+
249+func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
250+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
251+ defer cancel()
252+
253+ deviceCode, err := copilot.RequestDeviceCode(ctx)
254+ if err != nil {
255+ return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
256+ }
257+
258+ d.deviceCode = deviceCode
259+
260+ return DeviceAuthInitiatedMsg{
261+ deviceCode: d.deviceCode,
262+ }
263+}
264+
265+// startPolling starts polling for the device token.
266+func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd {
267+ return func() tea.Msg {
268+ ctx, cancel := context.WithCancel(context.Background())
269+ d.cancelFunc = cancel
270+
271+ token, err := copilot.PollForToken(ctx, deviceCode)
272+ if err != nil {
273+ if ctx.Err() != nil {
274+ return nil // cancelled, don't report error.
275+ }
276+ return DeviceFlowErrorMsg{Error: err}
277+ }
278+
279+ return DeviceFlowCompletedMsg{Token: token}
280+ }
281+}
internal/tui/components/dialogs/hyper/device_flow.gonew file+267
@@ -0,0 +1,267 @@
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+ "github.com/charmbracelet/crush/internal/oauth"
13+ "github.com/charmbracelet/crush/internal/oauth/hyper"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+ "github.com/charmbracelet/crush/internal/tui/util"
16+ "github.com/pkg/browser"
17+)
18+
19+// DeviceFlowState represents the current state of the device flow.
20+type DeviceFlowState int
21+
22+const (
23+ DeviceFlowStateDisplay DeviceFlowState = iota
24+ DeviceFlowStateSuccess
25+ DeviceFlowStateError
26+)
27+
28+// DeviceAuthInitiatedMsg is sent when the device auth is initiated
29+// successfully.
30+type DeviceAuthInitiatedMsg struct {
31+ deviceCode string
32+ expiresIn int
33+}
34+
35+// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
36+type DeviceFlowCompletedMsg struct {
37+ Token *oauth.Token
38+}
39+
40+// DeviceFlowErrorMsg is sent when the device flow encounters an error.
41+type DeviceFlowErrorMsg struct {
42+ Error error
43+}
44+
45+// DeviceFlow handles the Hyper device flow authentication.
46+type DeviceFlow struct {
47+ State DeviceFlowState
48+ width int
49+ deviceCode string
50+ userCode string
51+ verificationURL string
52+ expiresIn int
53+ token *oauth.Token
54+ cancelFunc context.CancelFunc
55+ spinner spinner.Model
56+}
57+
58+// NewDeviceFlow creates a new device flow component.
59+func NewDeviceFlow() *DeviceFlow {
60+ s := spinner.New()
61+ s.Spinner = spinner.Dot
62+ s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
63+ return &DeviceFlow{
64+ State: DeviceFlowStateDisplay,
65+ spinner: s,
66+ }
67+}
68+
69+// Init initializes the device flow by calling the device auth API and starting polling.
70+func (d *DeviceFlow) Init() tea.Cmd {
71+ return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
72+}
73+
74+// Update handles messages and state transitions.
75+func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
76+ var cmd tea.Cmd
77+ d.spinner, cmd = d.spinner.Update(msg)
78+
79+ switch msg := msg.(type) {
80+ case DeviceAuthInitiatedMsg:
81+ // Start polling now that we have the device code.
82+ d.expiresIn = msg.expiresIn
83+ return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
84+ case DeviceFlowCompletedMsg:
85+ d.State = DeviceFlowStateSuccess
86+ d.token = msg.Token
87+ return d, nil
88+ case DeviceFlowErrorMsg:
89+ d.State = DeviceFlowStateError
90+ return d, util.ReportError(msg.Error)
91+ }
92+
93+ return d, cmd
94+}
95+
96+// View renders the device flow dialog.
97+func (d *DeviceFlow) View() string {
98+ t := styles.CurrentTheme()
99+
100+ whiteStyle := lipgloss.NewStyle().Foreground(t.White)
101+ primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
102+ greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
103+ linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
104+ errorStyle := lipgloss.NewStyle().Foreground(t.Error)
105+ mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
106+
107+ switch d.State {
108+ case DeviceFlowStateDisplay:
109+ if d.userCode == "" {
110+ return lipgloss.NewStyle().
111+ Margin(0, 1).
112+ Render(
113+ greenStyle.Render(d.spinner.View()) +
114+ mutedStyle.Render("Initializing..."),
115+ )
116+ }
117+
118+ instructions := lipgloss.NewStyle().
119+ Margin(1, 1, 0, 1).
120+ Width(d.width - 2).
121+ Render(
122+ whiteStyle.Render("Press ") +
123+ primaryStyle.Render("enter") +
124+ whiteStyle.Render(" to copy the code below and open the browser."),
125+ )
126+
127+ codeBox := lipgloss.NewStyle().
128+ Width(d.width-2).
129+ Height(7).
130+ Align(lipgloss.Center, lipgloss.Center).
131+ Background(t.BgBaseLighter).
132+ Margin(1).
133+ Render(
134+ lipgloss.NewStyle().
135+ Bold(true).
136+ Foreground(t.White).
137+ Render(d.userCode),
138+ )
139+
140+ link := linkStyle.Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL)
141+ url := mutedStyle.
142+ Margin(0, 1).
143+ Width(d.width - 2).
144+ Render("Browser not opening? Refer to\n" + link)
145+
146+ waiting := greenStyle.
147+ Width(d.width-2).
148+ Margin(1, 1, 0, 1).
149+ Render(d.spinner.View() + "Verifying...")
150+
151+ return lipgloss.JoinVertical(
152+ lipgloss.Left,
153+ instructions,
154+ codeBox,
155+ url,
156+ waiting,
157+ )
158+
159+ case DeviceFlowStateSuccess:
160+ return greenStyle.Margin(0, 1).Render("Authentication successful!")
161+
162+ case DeviceFlowStateError:
163+ return lipgloss.NewStyle().
164+ Margin(0, 1).
165+ Width(d.width - 2).
166+ Render(errorStyle.Render("Authentication failed."))
167+
168+ default:
169+ return ""
170+ }
171+}
172+
173+// SetWidth sets the width of the dialog.
174+func (d *DeviceFlow) SetWidth(w int) {
175+ d.width = w
176+}
177+
178+// Cursor hides the cursor.
179+func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
180+
181+// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
182+func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
183+ if d.State != DeviceFlowStateDisplay {
184+ return nil
185+ }
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+ if d.State != DeviceFlowStateDisplay {
201+ return nil
202+ }
203+ return tea.Sequence(
204+ tea.SetClipboard(d.userCode),
205+ util.ReportInfo("Code copied to clipboard"),
206+ )
207+}
208+
209+// Cancel cancels the device flow polling.
210+func (d *DeviceFlow) Cancel() {
211+ if d.cancelFunc != nil {
212+ d.cancelFunc()
213+ }
214+}
215+
216+func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
217+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
218+ defer cancel()
219+ authResp, err := hyper.InitiateDeviceAuth(ctx)
220+ if err != nil {
221+ return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
222+ }
223+
224+ d.deviceCode = authResp.DeviceCode
225+ d.userCode = authResp.UserCode
226+ d.verificationURL = authResp.VerificationURL
227+
228+ return DeviceAuthInitiatedMsg{
229+ deviceCode: authResp.DeviceCode,
230+ expiresIn: authResp.ExpiresIn,
231+ }
232+}
233+
234+// startPolling starts polling for the device token.
235+func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd {
236+ return func() tea.Msg {
237+ ctx, cancel := context.WithCancel(context.Background())
238+ d.cancelFunc = cancel
239+
240+ // Poll for refresh token.
241+ refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn)
242+ if err != nil {
243+ if ctx.Err() != nil {
244+ // Cancelled, don't report error.
245+ return nil
246+ }
247+ return DeviceFlowErrorMsg{Error: err}
248+ }
249+
250+ // Exchange refresh token for access token.
251+ token, err := hyper.ExchangeToken(ctx, refreshToken)
252+ if err != nil {
253+ return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)}
254+ }
255+
256+ // Verify the access token works.
257+ introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
258+ if err != nil {
259+ return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)}
260+ }
261+ if !introspect.Active {
262+ return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")}
263+ }
264+
265+ return DeviceFlowCompletedMsg{Token: token}
266+ }
267+}
internal/tui/components/dialogs/models/keys.go+26
@@ -15,6 +15,10 @@ type KeyMap struct {
15 isAPIKeyHelp bool
15 isAPIKeyHelp bool
16 isAPIKeyValid bool
16 isAPIKeyValid bool
17
17
18+ isHyperDeviceFlow bool
19+ isCopilotDeviceFlow bool
20+ isCopilotUnavailable bool
21+
18 isClaudeAuthChoiceHelp bool
22 isClaudeAuthChoiceHelp bool
19 isClaudeOAuthHelp bool
23 isClaudeOAuthHelp bool
20 isClaudeOAuthURLState bool
24 isClaudeOAuthURLState bool
@@ -74,6 +78,28 @@ func (k KeyMap) FullHelp() [][]key.Binding {
74
78
75// ShortHelp implements help.KeyMap.
79// ShortHelp implements help.KeyMap.
76func (k KeyMap) ShortHelp() []key.Binding {
80func (k KeyMap) ShortHelp() []key.Binding {
81+ if k.isHyperDeviceFlow || k.isCopilotDeviceFlow {
82+ return []key.Binding{
83+ key.NewBinding(
84+ key.WithKeys("c"),
85+ key.WithHelp("c", "copy code"),
86+ ),
87+ key.NewBinding(
88+ key.WithKeys("enter"),
89+ key.WithHelp("enter", "copy & open"),
90+ ),
91+ k.Close,
92+ }
93+ }
94+ if k.isCopilotUnavailable {
95+ return []key.Binding{
96+ key.NewBinding(
97+ key.WithKeys("enter"),
98+ key.WithHelp("enter", "open signup"),
99+ ),
100+ k.Close,
101+ }
102+ }
77 if k.isClaudeAuthChoiceHelp {
103 if k.isClaudeAuthChoiceHelp {
78 return []key.Binding{
104 return []key.Binding{
79 key.NewBinding(
105 key.NewBinding(
internal/tui/components/dialogs/models/list.go+20−25
@@ -62,7 +62,9 @@ func (m *ModelListComponent) Init() tea.Cmd {
62 filteredProviders := []catwalk.Provider{}
62 filteredProviders := []catwalk.Provider{}
63 for _, p := range providers {
63 for _, p := range providers {
64 hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
64 hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
65+ isHyper := p.ID == "hyper"
66+ isCopilot := p.ID == catwalk.InferenceProviderCopilot
65− if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
67+ if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot {
66 filteredProviders = append(filteredProviders, p)
68 filteredProviders = append(filteredProviders, p)
67 }
69 }
68 }
70 }
@@ -146,29 +148,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
146 if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) ||
148 if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) ||
147 !slices.ContainsFunc(m.providers, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) {
149 !slices.ContainsFunc(m.providers, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) {
148 // Convert config provider to provider.Provider format
150 // Convert config provider to provider.Provider format
149− configProvider := catwalk.Provider{
150− Name: providerConfig.Name,
151+ configProvider := providerConfig.ToProvider()
151− ID: catwalk.InferenceProvider(providerID),
152− Models: make([]catwalk.Model, len(providerConfig.Models)),
153− }
154−
155− // Convert models
156− for i, model := range providerConfig.Models {
157− configProvider.Models[i] = catwalk.Model{
158− ID: model.ID,
159− Name: model.Name,
160− CostPer1MIn: model.CostPer1MIn,
161− CostPer1MOut: model.CostPer1MOut,
162− CostPer1MInCached: model.CostPer1MInCached,
163− CostPer1MOutCached: model.CostPer1MOutCached,
164− ContextWindow: model.ContextWindow,
165− DefaultMaxTokens: model.DefaultMaxTokens,
166− CanReason: model.CanReason,
167− ReasoningLevels: model.ReasoningLevels,
168− DefaultReasoningEffort: model.DefaultReasoningEffort,
169− SupportsImages: model.SupportsImages,
170− }
171− }
172
152
173 // Add this unknown provider to the list
153 // Add this unknown provider to the list
174 name := configProvider.Name
154 name := configProvider.Name
@@ -204,8 +184,23 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
204 }
184 }
205 }
185 }
206
186
187+ // Move "Charm Hyper" to first position
188+ // (but still after recent models and custom providers).
189+ sortedProviders := make([]catwalk.Provider, len(m.providers))
190+ copy(sortedProviders, m.providers)
191+ slices.SortStableFunc(sortedProviders, func(a, b catwalk.Provider) int {
192+ switch {
193+ case a.ID == "hyper":
194+ return -1
195+ case b.ID == "hyper":
196+ return 1
197+ default:
198+ return 0
199+ }
200+ })
201+
207 // Then add the known providers from the predefined list
202 // Then add the known providers from the predefined list
208− for _, provider := range m.providers {
203+ for _, provider := range sortedProviders {
209 // Skip if we already added this provider as an unknown provider
204 // Skip if we already added this provider as an unknown provider
210 if addedProviders[string(provider.ID)] {
205 if addedProviders[string(provider.ID)] {
211 continue
206 continue
internal/tui/components/dialogs/models/models.go+151−16
@@ -1,3 +1,4 @@
1+// Package models provides the model selection dialog for the TUI.
1package models
2package models
2
3
3import (
4import (
@@ -11,10 +12,13 @@ import (
11 "charm.land/lipgloss/v2"
12 "charm.land/lipgloss/v2"
12 "github.com/atotto/clipboard"
13 "github.com/atotto/clipboard"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
21+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
18 "github.com/charmbracelet/crush/internal/tui/exp/list"
22 "github.com/charmbracelet/crush/internal/tui/exp/list"
19 "github.com/charmbracelet/crush/internal/tui/styles"
23 "github.com/charmbracelet/crush/internal/tui/styles"
20 "github.com/charmbracelet/crush/internal/tui/util"
24 "github.com/charmbracelet/crush/internal/tui/util"
@@ -70,6 +74,14 @@ type modelDialogCmp struct {
70 isAPIKeyValid bool
74 isAPIKeyValid bool
71 apiKeyValue string
75 apiKeyValue string
72
76
77+ // Hyper device flow state
78+ hyperDeviceFlow *hyper.DeviceFlow
79+ showHyperDeviceFlow bool
80+
81+ // Copilot device flow state
82+ copilotDeviceFlow *copilot.DeviceFlow
83+ showCopilotDeviceFlow bool
84+
73 // Claude state
85 // Claude state
74 claudeAuthMethodChooser *claude.AuthMethodChooser
86 claudeAuthMethodChooser *claude.AuthMethodChooser
75 claudeOAuth2 *claude.OAuth2
87 claudeOAuth2 *claude.OAuth2
@@ -127,6 +139,24 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
127 u, cmd := m.apiKeyInput.Update(msg)
139 u, cmd := m.apiKeyInput.Update(msg)
128 m.apiKeyInput = u.(*APIKeyInput)
140 m.apiKeyInput = u.(*APIKeyInput)
129 return m, cmd
141 return m, cmd
142+ case hyper.DeviceFlowCompletedMsg:
143+ return m, m.saveOauthTokenAndContinue(msg.Token, true)
144+ case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
145+ if m.hyperDeviceFlow != nil {
146+ u, cmd := m.hyperDeviceFlow.Update(msg)
147+ m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
148+ return m, cmd
149+ }
150+ return m, nil
151+ case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
152+ if m.copilotDeviceFlow != nil {
153+ u, cmd := m.copilotDeviceFlow.Update(msg)
154+ m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
155+ return m, cmd
156+ }
157+ return m, nil
158+ case copilot.DeviceFlowCompletedMsg:
159+ return m, m.saveOauthTokenAndContinue(msg.Token, true)
130 case claude.ValidationCompletedMsg:
160 case claude.ValidationCompletedMsg:
131 var cmds []tea.Cmd
161 var cmds []tea.Cmd
132 u, cmd := m.claudeOAuth2.Update(msg)
162 u, cmd := m.claudeOAuth2.Update(msg)
@@ -134,7 +164,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
134 cmds = append(cmds, cmd)
164 cmds = append(cmds, cmd)
135
165
136 if msg.State == claude.OAuthValidationStateValid {
166 if msg.State == claude.OAuthValidationStateValid {
137− cmds = append(cmds, m.saveAPIKeyAndContinue(msg.Token, false))
167+ cmds = append(cmds, m.saveOauthTokenAndContinue(msg.Token, false))
138 m.keyMap.isClaudeOAuthHelpComplete = true
168 m.keyMap.isClaudeOAuthHelpComplete = true
139 }
169 }
140
170
@@ -143,6 +173,14 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
143 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
173 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
144 case tea.KeyPressMsg:
174 case tea.KeyPressMsg:
145 switch {
175 switch {
176+ // Handle Hyper device flow keys
177+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && (m.showHyperDeviceFlow || m.showCopilotDeviceFlow):
178+ if m.hyperDeviceFlow != nil {
179+ return m, m.hyperDeviceFlow.CopyCode()
180+ }
181+ if m.copilotDeviceFlow != nil {
182+ return m, m.copilotDeviceFlow.CopyCode()
183+ }
146 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
184 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
147 return m, tea.Sequence(
185 return m, tea.Sequence(
148 tea.SetClipboard(m.claudeOAuth2.URL),
186 tea.SetClipboard(m.claudeOAuth2.URL),
@@ -156,6 +194,13 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
156 m.claudeAuthMethodChooser.ToggleChoice()
194 m.claudeAuthMethodChooser.ToggleChoice()
157 return m, nil
195 return m, nil
158 case key.Matches(msg, m.keyMap.Select):
196 case key.Matches(msg, m.keyMap.Select):
197+ // If showing device flow, enter copies code and opens URL
198+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
199+ return m, m.hyperDeviceFlow.CopyCodeAndOpenURL()
200+ }
201+ if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
202+ return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
203+ }
159 selectedItem := m.modelList.SelectedModel()
204 selectedItem := m.modelList.SelectedModel()
160
205
161 modelType := config.SelectedModelTypeLarge
206 modelType := config.SelectedModelTypeLarge
@@ -167,6 +212,8 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
167 m.keyMap.isClaudeAuthChoiceHelp = false
212 m.keyMap.isClaudeAuthChoiceHelp = false
168 m.keyMap.isClaudeOAuthHelp = false
213 m.keyMap.isClaudeOAuthHelp = false
169 m.keyMap.isAPIKeyHelp = true
214 m.keyMap.isAPIKeyHelp = true
215+ m.showHyperDeviceFlow = false
216+ m.showCopilotDeviceFlow = false
170 m.showClaudeAuthMethodChooser = false
217 m.showClaudeAuthMethodChooser = false
171 m.needsAPIKey = true
218 m.needsAPIKey = true
172 m.selectedModel = selectedItem
219 m.selectedModel = selectedItem
@@ -194,7 +241,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
194 return m, cmd2
241 return m, cmd2
195 }
242 }
196 if m.isAPIKeyValid {
243 if m.isAPIKeyValid {
197− return m, m.saveAPIKeyAndContinue(m.apiKeyValue, true)
244+ return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
198 }
245 }
199 if m.needsAPIKey {
246 if m.needsAPIKey {
200 // Handle API key submission
247 // Handle API key submission
@@ -249,15 +296,30 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
249 ModelType: modelType,
296 ModelType: modelType,
250 }),
297 }),
251 )
298 )
252− } else {
299+ }
300+ switch selectedItem.Provider.ID {
253− if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
301+ case catwalk.InferenceProviderAnthropic:
254− m.showClaudeAuthMethodChooser = true
302+ m.showClaudeAuthMethodChooser = true
255− m.keyMap.isClaudeAuthChoiceHelp = true
303+ m.keyMap.isClaudeAuthChoiceHelp = true
256− return m, nil
257− }
258− askForApiKey()
259 return m, nil
304 return m, nil
305+ case hyperp.Name:
306+ m.showHyperDeviceFlow = true
307+ m.selectedModel = selectedItem
308+ m.selectedModelType = modelType
309+ m.hyperDeviceFlow = hyper.NewDeviceFlow()
310+ m.hyperDeviceFlow.SetWidth(m.width - 2)
311+ return m, m.hyperDeviceFlow.Init()
312+ case catwalk.InferenceProviderCopilot:
313+ m.showCopilotDeviceFlow = true
314+ m.selectedModel = selectedItem
315+ m.selectedModelType = modelType
316+ m.copilotDeviceFlow = copilot.NewDeviceFlow()
317+ m.copilotDeviceFlow.SetWidth(m.width - 2)
318+ return m, m.copilotDeviceFlow.Init()
260 }
319 }
320+ // For other providers, show API key input
321+ askForApiKey()
322+ return m, nil
261 case key.Matches(msg, m.keyMap.Tab):
323 case key.Matches(msg, m.keyMap.Tab):
262 switch {
324 switch {
263 case m.showClaudeAuthMethodChooser:
325 case m.showClaudeAuthMethodChooser:
@@ -275,6 +337,20 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
275 return m, m.modelList.SetModelType(LargeModelType)
337 return m, m.modelList.SetModelType(LargeModelType)
276 }
338 }
277 case key.Matches(msg, m.keyMap.Close):
339 case key.Matches(msg, m.keyMap.Close):
340+ if m.showHyperDeviceFlow {
341+ if m.hyperDeviceFlow != nil {
342+ m.hyperDeviceFlow.Cancel()
343+ }
344+ m.showHyperDeviceFlow = false
345+ m.selectedModel = nil
346+ }
347+ if m.showCopilotDeviceFlow {
348+ if m.copilotDeviceFlow != nil {
349+ m.copilotDeviceFlow.Cancel()
350+ }
351+ m.showCopilotDeviceFlow = false
352+ m.selectedModel = nil
353+ }
278 if m.showClaudeAuthMethodChooser {
354 if m.showClaudeAuthMethodChooser {
279 m.claudeAuthMethodChooser.SetDefaults()
355 m.claudeAuthMethodChooser.SetDefaults()
280 m.showClaudeAuthMethodChooser = false
356 m.showClaudeAuthMethodChooser = false
@@ -329,11 +405,33 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
329 return m, cmd
405 return m, cmd
330 }
406 }
331 case spinner.TickMsg:
407 case spinner.TickMsg:
408+ u, cmd := m.apiKeyInput.Update(msg)
409+ m.apiKeyInput = u.(*APIKeyInput)
410+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
411+ u, cmd = m.hyperDeviceFlow.Update(msg)
412+ m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
413+ }
414+ if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
415+ u, cmd = m.copilotDeviceFlow.Update(msg)
416+ m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
417+ }
418+ return m, cmd
419+ default:
420+ // Pass all other messages to the device flow for spinner animation
421+ switch {
422+ case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil:
423+ u, cmd := m.hyperDeviceFlow.Update(msg)
424+ m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
425+ return m, cmd
426+ case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil:
427+ u, cmd := m.copilotDeviceFlow.Update(msg)
428+ m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
429+ return m, cmd
332− if m.showClaudeOAuth2 {
430+ case m.showClaudeOAuth2:
333 u, cmd := m.claudeOAuth2.Update(msg)
431 u, cmd := m.claudeOAuth2.Update(msg)
334 m.claudeOAuth2 = u.(*claude.OAuth2)
432 m.claudeOAuth2 = u.(*claude.OAuth2)
335 return m, cmd
433 return m, cmd
336− } else {
434+ default:
337 u, cmd := m.apiKeyInput.Update(msg)
435 u, cmd := m.apiKeyInput.Update(msg)
338 m.apiKeyInput = u.(*APIKeyInput)
436 m.apiKeyInput = u.(*APIKeyInput)
339 return m, cmd
437 return m, cmd
@@ -345,6 +443,39 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
345func (m *modelDialogCmp) View() string {
443func (m *modelDialogCmp) View() string {
346 t := styles.CurrentTheme()
444 t := styles.CurrentTheme()
347
445
446+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
447+ // Show Hyper device flow
448+ m.keyMap.isHyperDeviceFlow = true
449+ deviceFlowView := m.hyperDeviceFlow.View()
450+ content := lipgloss.JoinVertical(
451+ lipgloss.Left,
452+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with Hyper", m.width-4)),
453+ deviceFlowView,
454+ "",
455+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
456+ )
457+ return m.style().Render(content)
458+ }
459+ if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
460+ // Show Hyper device flow
461+ m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable
462+ m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable
463+ deviceFlowView := m.copilotDeviceFlow.View()
464+ content := lipgloss.JoinVertical(
465+ lipgloss.Left,
466+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)),
467+ deviceFlowView,
468+ "",
469+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
470+ )
471+ return m.style().Render(content)
472+ }
473+
474+ // Reset the flags when not showing device flow
475+ m.keyMap.isHyperDeviceFlow = false
476+ m.keyMap.isCopilotDeviceFlow = false
477+ m.keyMap.isCopilotUnavailable = false
478+
348 switch {
479 switch {
349 case m.showClaudeAuthMethodChooser:
480 case m.showClaudeAuthMethodChooser:
350 chooserView := m.claudeAuthMethodChooser.View()
481 chooserView := m.claudeAuthMethodChooser.View()
@@ -397,6 +528,12 @@ func (m *modelDialogCmp) View() string {
397}
528}
398
529
399func (m *modelDialogCmp) Cursor() *tea.Cursor {
530func (m *modelDialogCmp) Cursor() *tea.Cursor {
531+ if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
532+ return m.hyperDeviceFlow.Cursor()
533+ }
534+ if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
535+ return m.copilotDeviceFlow.Cursor()
536+ }
400 if m.showClaudeAuthMethodChooser {
537 if m.showClaudeAuthMethodChooser {
401 return nil
538 return nil
402 }
539 }
@@ -477,10 +614,8 @@ func (m *modelDialogCmp) modelTypeRadio() string {
477
614
478func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
615func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
479 cfg := config.Get()
616 cfg := config.Get()
480− if _, ok := cfg.Providers.Get(providerID); ok {
617+ _, ok := cfg.Providers.Get(providerID)
481− return true
482− }
483− return false
618+ return ok
484}
619}
485
620
486func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
621func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
@@ -497,7 +632,7 @@ func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*cat
497 return nil, nil
632 return nil, nil
498}
633}
499
634
500−func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
635+func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd {
501 if m.selectedModel == nil {
636 if m.selectedModel == nil {
502 return util.ReportError(fmt.Errorf("no model selected"))
637 return util.ReportError(fmt.Errorf("no model selected"))
503 }
638 }
internal/tui/exp/list/items.go+6
@@ -323,6 +323,7 @@ type ItemSection interface {
323 layout.Sizeable
323 layout.Sizeable
324 Indexable
324 Indexable
325 SetInfo(info string)
325 SetInfo(info string)
326+ Title() string
326}
327}
327type itemSectionModel struct {
328type itemSectionModel struct {
328 width int
329 width int
@@ -337,6 +338,11 @@ func (m *itemSectionModel) ID() string {
337 return m.id
338 return m.id
338}
339}
339
340
341+// Title implements ItemSection.
342+func (m *itemSectionModel) Title() string {
343+ return m.title
344+}
345+
340func NewItemSection(title string) ItemSection {
346func NewItemSection(title string) ItemSection {
341 return &itemSectionModel{
347 return &itemSectionModel{
342 title: title,
348 title: title,