on main help sign in
Merge branch 'ui' into chat-ui-assistant-tools
commit39b2070c9aee25259c0d8d7866f934d02861779f authorAyman Bagabas <ayman.bagabas@gmail.com> dateThu 18 Dec 2025, 13:02 (1 year ago) parent03beedc8483061
46 files changed+3145−307
.gitattributes+1 .github/workflows/schema-update.yml+4−2 README.md+5 Taskfile.yaml+7 cspell.json−1 go.mod+3−3 go.sum+2−2 internal/agent/agent.go+17−1 internal/agent/coordinator.go+15 internal/agent/hyper/provider.go+330 internal/agent/hyper/provider.json+1 internal/cmd/login.go+178−17 internal/cmd/projects.go+3−4 internal/cmd/update_providers.go+30−8 internal/config/catwalk.go+82 internal/config/catwalk_test.go+221 internal/config/config.go+75−10 internal/config/copilot.go+43 internal/config/hyper.go+124 internal/config/hyper_test.go+205 internal/config/load.go+28−11 internal/config/provider.go+130−74 internal/config/provider_empty_test.go+18−10 internal/config/provider_test.go+240−76 internal/event/event.go+16 internal/oauth/copilot/disk.go+36 internal/oauth/copilot/http.go+17 internal/oauth/copilot/oauth.go+200 internal/oauth/copilot/urls.go+6 internal/oauth/hyper/device.go+251 internal/oauth/token.go+6−1 internal/tui/components/dialogs/commands/commands.go+2−1 internal/tui/components/dialogs/copilot/device_flow.go+281 internal/tui/components/dialogs/hyper/device_flow.go+267 internal/tui/components/dialogs/models/keys.go+26 internal/tui/components/dialogs/models/list.go+20−25 internal/tui/components/dialogs/models/models.go+151−16 internal/tui/components/mcp/mcp.go+10−2 internal/tui/exp/list/items.go+6 internal/ui/dialog/sessions_item.go+9−2 internal/ui/list/filterable.go+8−4 internal/ui/list/item.go+19 internal/ui/list/list.go+17−4 internal/ui/model/chat.go+8−8 internal/ui/model/mcp.go+4−2 internal/ui/model/ui.go+23−23
.gitattributes+1
@@ -1,3 +1,4 @@
11*.golden linguist-generated=true -text
22.github/crush-schema.json linguist-generated=true
3+internal/agent/hyper/provider.json linguist-generated=true
34internal/agent/testdata/**/*.yaml -diff linguist-generated=true
.github/workflows/schema-update.yml+4−2
@@ -1,10 +1,11 @@
1−name: Update Schema
1+name: Update files
22
33on:
44 push:
55 branches: [main]
66 paths:
77 - "internal/config/**"
8+ - "internal/agent/hyper/**"
89
910jobs:
1011 update-schema:
@@ -17,9 +18,10 @@ jobs:
1718 with:
1819 go-version-file: go.mod
1920 - run: go run . schema > ./schema.json
21+ - run: go generate ./internal/agent/hyper/...
2022 - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
2123 with:
22− commit_message: "chore: auto-update generated files"
24+ commit_message: "chore: auto-update files"
2325 branch: main
2426 commit_user_name: Charm
2527 commit_user_email: 124303983+charmcli@users.noreply.github.com
README.md+5
@@ -232,6 +232,11 @@ $HOME/.local/share/crush/crush.json
232232%LOCALAPPDATA%\crush\crush.json
233233```
234234
235+> [!TIP]
236+> You can override the user and data config locations by setting:
237+> * `CRUSH_GLOBAL_CONFIG`
238+> * `CRUSH_GLOBAL_DATA`
239+
235240### LSPs
236241
237242Crush can use LSPs for additional context to help inform its decisions, just
Taskfile.yaml+7
@@ -101,6 +101,13 @@ tasks:
101101 generates:
102102 - schema.json
103103
104+ hyper:
105+ desc: Update Hyper embedded provider.json
106+ cmds:
107+ - go generate ./internal/agent/hyper/...
108+ generates:
109+ - ./internal/agent/hyper/provider.json
110+
104111 release:
105112 desc: Create and push a new tag following semver
106113 vars:
cspell.jsondeleted−1
@@ -1 +0,0 @@
1−{"words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync","Highlightable","Highlightable","prerendered","prerender","kujtim","animatable"],"version":"0.2","flagWords":[],"language":"en"}
go.mod+3−3
@@ -18,11 +18,12 @@ require (
1818 github.com/aymanbagabas/go-udiff v0.3.1
1919 github.com/bmatcuk/doublestar/v4 v4.9.1
2020 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.10.2
21+ github.com/charmbracelet/catwalk v0.11.0
2222 github.com/charmbracelet/colorprofile v0.4.1
2323 github.com/charmbracelet/fang v0.4.4
2424 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
2525 github.com/charmbracelet/x/ansi v0.11.3
26+ github.com/charmbracelet/x/etag v0.2.0
2627 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
2728 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
2829 github.com/charmbracelet/x/exp/ordered v0.1.0
@@ -53,6 +54,7 @@ require (
5354 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
5455 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
5556 github.com/stretchr/testify v1.11.1
57+ github.com/tidwall/gjson v1.18.0
5658 github.com/tidwall/sjson v1.2.5
5759 github.com/zeebo/xxh3 v1.0.2
5860 golang.org/x/mod v0.31.0
@@ -92,7 +94,6 @@ require (
9294 github.com/bahlo/generic-list-go v0.2.0 // indirect
9395 github.com/buger/jsonparser v1.1.1 // indirect
9496 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
95− github.com/charmbracelet/x/etag v0.2.0 // indirect
9697 github.com/charmbracelet/x/json v0.2.0 // indirect
9798 github.com/charmbracelet/x/termios v0.1.1 // indirect
9899 github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -145,7 +146,6 @@ require (
145146 github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
146147 github.com/spf13/pflag v1.0.9 // indirect
147148 github.com/tetratelabs/wazero v1.10.1 // indirect
148− github.com/tidwall/gjson v1.18.0 // indirect
149149 github.com/tidwall/match v1.1.1 // indirect
150150 github.com/tidwall/pretty v1.2.1 // indirect
151151 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
9292github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
9393github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
9494github.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=
96−github.com/charmbracelet/catwalk v0.10.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
95+github.com/charmbracelet/catwalk v0.11.0 h1:PU3rkc4h4YVJEn9Iyb/1rQAaF4hEd04fuG4tj3vv4dg=
96+github.com/charmbracelet/catwalk v0.11.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
9797github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
9898github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
9999github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
internal/agent/agent.go+17−1
@@ -27,7 +27,9 @@ import (
2727 "charm.land/fantasy/providers/google"
2828 "charm.land/fantasy/providers/openai"
2929 "charm.land/fantasy/providers/openrouter"
30+ "charm.land/lipgloss/v2"
3031 "github.com/charmbracelet/catwalk/pkg/catwalk"
32+ "github.com/charmbracelet/crush/internal/agent/hyper"
3133 "github.com/charmbracelet/crush/internal/agent/tools"
3234 "github.com/charmbracelet/crush/internal/config"
3335 "github.com/charmbracelet/crush/internal/csync"
@@ -454,8 +456,22 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
454456 currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
455457 } else if isPermissionErr {
456458 currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
459+ } else if errors.Is(err, hyper.ErrNoCredits) {
460+ url := hyper.BaseURL()
461+ link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url)
462+ currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
457463 } else if errors.As(err, &providerErr) {
458− currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
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 {
473+ currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
474+ }
459475 } else if errors.As(err, &fantasyErr) {
460476 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
461477 } else {
internal/agent/coordinator.go+15
@@ -17,6 +17,7 @@ import (
1717
1818 "charm.land/fantasy"
1919 "github.com/charmbracelet/catwalk/pkg/catwalk"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
2021 "github.com/charmbracelet/crush/internal/agent/prompt"
2122 "github.com/charmbracelet/crush/internal/agent/tools"
2223 "github.com/charmbracelet/crush/internal/config"
@@ -668,6 +669,18 @@ func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, optio
668669 return google.New(opts...)
669670}
670671
672+func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
673+ opts := []hyper.Option{
674+ hyper.WithBaseURL(baseURL),
675+ hyper.WithAPIKey(apiKey),
676+ }
677+ if c.cfg.Options.Debug {
678+ httpClient := log.NewHTTPClient()
679+ opts = append(opts, hyper.WithHTTPClient(httpClient))
680+ }
681+ return hyper.New(opts...)
682+}
683+
671684func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
672685 if model.Think {
673686 return true
@@ -728,6 +741,8 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
728741 providerCfg.ExtraBody["tool_stream"] = true
729742 }
730743 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
744+ case hyper.Name:
745+ return c.buildHyperProvider(baseURL, apiKey)
731746 default:
732747 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
733748 }
internal/agent/hyper/provider.gonew file+330
@@ -0,0 +1,330 @@
1+// Package hyper provides a fantasy.Provider that proxies requests to Hyper.
2+package hyper
3+
4+import (
5+ "bufio"
6+ "bytes"
7+ "cmp"
8+ "context"
9+ _ "embed"
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "io"
14+ "log/slog"
15+ "maps"
16+ "net/http"
17+ "net/url"
18+ "os"
19+ "strconv"
20+ "strings"
21+ "sync"
22+ "time"
23+
24+ "charm.land/fantasy"
25+ "charm.land/fantasy/object"
26+ "github.com/charmbracelet/catwalk/pkg/catwalk"
27+ "github.com/charmbracelet/crush/internal/event"
28+)
29+
30+//go:generate wget -O provider.json https://console.charm.land/api/v1/provider
31+
32+//go:embed provider.json
33+var embedded []byte
34+
35+// Enabled returns true if hyper is enabled.
36+var Enabled = sync.OnceValue(func() bool {
37+ b, _ := strconv.ParseBool(
38+ cmp.Or(
39+ os.Getenv("HYPER"),
40+ os.Getenv("HYPERCRUSH"),
41+ os.Getenv("HYPER_ENABLE"),
42+ os.Getenv("HYPER_ENABLED"),
43+ ),
44+ )
45+ return b
46+})
47+
48+// Embedded returns the embedded Hyper provider.
49+var Embedded = sync.OnceValue(func() catwalk.Provider {
50+ var provider catwalk.Provider
51+ if err := json.Unmarshal(embedded, &provider); err != nil {
52+ slog.Error("could not use embedded provider data", "err", err)
53+ }
54+ return provider
55+})
56+
57+const (
58+ // Name is the default name of this meta provider.
59+ Name = "hyper"
60+ // defaultBaseURL is the default proxy URL.
61+ // TODO: change this to production URL when ready.
62+ defaultBaseURL = "https://console.charm.land"
63+)
64+
65+// BaseURL returns the base URL, which is either $HYPER_URL or the default.
66+var BaseURL = sync.OnceValue(func() string {
67+ return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
68+})
69+
70+var ErrNoCredits = errors.New("you're out of credits")
71+
72+type options struct {
73+ baseURL string
74+ apiKey string
75+ name string
76+ headers map[string]string
77+ client *http.Client
78+}
79+
80+// Option configures the proxy provider.
81+type Option = func(*options)
82+
83+// New creates a new proxy provider.
84+func New(opts ...Option) (fantasy.Provider, error) {
85+ o := options{
86+ baseURL: BaseURL() + "/api/v1/fantasy",
87+ name: Name,
88+ headers: map[string]string{
89+ "x-crush-id": event.GetID(),
90+ },
91+ client: &http.Client{Timeout: 0}, // stream-safe
92+ }
93+ for _, opt := range opts {
94+ opt(&o)
95+ }
96+ return &provider{options: o}, nil
97+}
98+
99+// WithBaseURL sets the proxy base URL (e.g. http://localhost:8080).
100+func WithBaseURL(url string) Option { return func(o *options) { o.baseURL = url } }
101+
102+// WithName sets the provider name.
103+func WithName(name string) Option { return func(o *options) { o.name = name } }
104+
105+// WithHeaders sets extra headers sent to the proxy.
106+func WithHeaders(headers map[string]string) Option {
107+ return func(o *options) {
108+ maps.Copy(o.headers, headers)
109+ }
110+}
111+
112+// WithHTTPClient sets custom HTTP client.
113+func WithHTTPClient(c *http.Client) Option { return func(o *options) { o.client = c } }
114+
115+// WithAPIKey sets the API key.
116+func WithAPIKey(key string) Option {
117+ return func(o *options) {
118+ o.apiKey = key
119+ }
120+}
121+
122+type provider struct{ options options }
123+
124+func (p *provider) Name() string { return p.options.name }
125+
126+// LanguageModel implements fantasy.Provider.
127+func (p *provider) LanguageModel(_ context.Context, modelID string) (fantasy.LanguageModel, error) {
128+ if modelID == "" {
129+ return nil, errors.New("missing model id")
130+ }
131+ return &languageModel{modelID: modelID, provider: p.options.name, opts: p.options}, nil
132+}
133+
134+type languageModel struct {
135+ provider string
136+ modelID string
137+ opts options
138+}
139+
140+// GenerateObject implements fantasy.LanguageModel.
141+func (m *languageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
142+ return object.GenerateWithTool(ctx, m, call)
143+}
144+
145+// StreamObject implements fantasy.LanguageModel.
146+func (m *languageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
147+ return object.StreamWithTool(ctx, m, call)
148+}
149+
150+func (m *languageModel) Provider() string { return m.provider }
151+func (m *languageModel) Model() string { return m.modelID }
152+
153+// Generate implements fantasy.LanguageModel by calling the proxy JSON endpoint.
154+func (m *languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
155+ resp, err := m.doRequest(ctx, false, call)
156+ if err != nil {
157+ return nil, err
158+ }
159+ defer func() { _ = resp.Body.Close() }()
160+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
161+ b, _ := ioReadAllLimit(resp.Body, 64*1024)
162+ return nil, fmt.Errorf("proxy generate error: %s", strings.TrimSpace(string(b)))
163+ }
164+ var out fantasy.Response
165+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
166+ return nil, err
167+ }
168+ return &out, nil
169+}
170+
171+// Stream implements fantasy.LanguageModel using SSE from the proxy.
172+func (m *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
173+ // Prefer explicit /stream endpoint
174+ resp, err := m.doRequest(ctx, true, call)
175+ if err != nil {
176+ return nil, err
177+ }
178+ switch resp.StatusCode {
179+ case http.StatusTooManyRequests:
180+ _ = resp.Body.Close()
181+ return nil, toProviderError(resp, retryAfter(resp))
182+ case http.StatusUnauthorized:
183+ _ = resp.Body.Close()
184+ return nil, toProviderError(resp, "")
185+ case http.StatusPaymentRequired:
186+ _ = resp.Body.Close()
187+ return nil, ErrNoCredits
188+ }
189+
190+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
191+ defer func() { _ = resp.Body.Close() }()
192+ b, _ := ioReadAllLimit(resp.Body, 64*1024)
193+ return nil, &fantasy.ProviderError{
194+ Title: "Stream Error",
195+ Message: strings.TrimSpace(string(b)),
196+ StatusCode: resp.StatusCode,
197+ }
198+ }
199+
200+ return func(yield func(fantasy.StreamPart) bool) {
201+ defer func() { _ = resp.Body.Close() }()
202+ scanner := bufio.NewScanner(resp.Body)
203+ buf := make([]byte, 0, 64*1024)
204+ scanner.Buffer(buf, 4*1024*1024)
205+
206+ var (
207+ event string
208+ dataBuf bytes.Buffer
209+ sawFinish bool
210+ dispatch = func() bool {
211+ if dataBuf.Len() == 0 || event == "" {
212+ dataBuf.Reset()
213+ event = ""
214+ return true
215+ }
216+ var part fantasy.StreamPart
217+ if err := json.Unmarshal(dataBuf.Bytes(), &part); err != nil {
218+ return yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
219+ }
220+ if part.Type == fantasy.StreamPartTypeFinish {
221+ sawFinish = true
222+ }
223+ ok := yield(part)
224+ dataBuf.Reset()
225+ event = ""
226+ return ok
227+ }
228+ )
229+
230+ for scanner.Scan() {
231+ line := scanner.Text()
232+ if line == "" { // event boundary
233+ if !dispatch() {
234+ return
235+ }
236+ continue
237+ }
238+ if strings.HasPrefix(line, ":") { // comment / ping
239+ continue
240+ }
241+ if strings.HasPrefix(line, "event: ") {
242+ event = strings.TrimSpace(line[len("event: "):])
243+ continue
244+ }
245+ if strings.HasPrefix(line, "data: ") {
246+ if dataBuf.Len() > 0 {
247+ dataBuf.WriteByte('\n')
248+ }
249+ dataBuf.WriteString(line[len("data: "):])
250+ continue
251+ }
252+ }
253+ if err := scanner.Err(); err != nil &&
254+ !errors.Is(err, context.Canceled) &&
255+ !errors.Is(err, context.DeadlineExceeded) {
256+ yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
257+ return
258+ }
259+ // flush any pending data
260+ _ = dispatch()
261+ if !sawFinish {
262+ _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish})
263+ }
264+ }, nil
265+}
266+
267+func (m *languageModel) doRequest(ctx context.Context, stream bool, call fantasy.Call) (*http.Response, error) {
268+ addr, err := url.Parse(m.opts.baseURL)
269+ if err != nil {
270+ return nil, err
271+ }
272+ addr = addr.JoinPath(m.modelID)
273+ if stream {
274+ addr = addr.JoinPath("stream")
275+ } else {
276+ addr = addr.JoinPath("generate")
277+ }
278+
279+ body, err := json.Marshal(call)
280+ if err != nil {
281+ return nil, err
282+ }
283+
284+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, addr.String(), bytes.NewReader(body))
285+ if err != nil {
286+ return nil, err
287+ }
288+ req.Header.Set("Content-Type", "application/json")
289+ if stream {
290+ req.Header.Set("Accept", "text/event-stream")
291+ } else {
292+ req.Header.Set("Accept", "application/json")
293+ }
294+ for k, v := range m.opts.headers {
295+ req.Header.Set(k, v)
296+ }
297+
298+ if m.opts.apiKey != "" {
299+ req.Header.Set("Authorization", m.opts.apiKey)
300+ }
301+ return m.opts.client.Do(req)
302+}
303+
304+// ioReadAllLimit reads up to n bytes.
305+func ioReadAllLimit(r io.Reader, n int64) ([]byte, error) {
306+ var b bytes.Buffer
307+ if n <= 0 {
308+ n = 1 << 20
309+ }
310+ lr := &io.LimitedReader{R: r, N: n}
311+ _, err := b.ReadFrom(lr)
312+ return b.Bytes(), err
313+}
314+
315+func toProviderError(resp *http.Response, message string) error {
316+ return &fantasy.ProviderError{
317+ Title: fantasy.ErrorTitleForStatusCode(resp.StatusCode),
318+ Message: message,
319+ StatusCode: resp.StatusCode,
320+ }
321+}
322+
323+func retryAfter(resp *http.Response) string {
324+ after, err := strconv.Atoi(resp.Header.Get("Retry-After"))
325+ if err == nil && after > 0 {
326+ d := time.Duration(after) * time.Second
327+ return "Try again in " + d.String()
328+ }
329+ return "Try again in later"
330+}
internal/agent/hyper/provider.jsonnew file+1
@@ -0,0 +1 @@
1+{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]}
internal/cmd/login.go+178−17
@@ -9,8 +9,14 @@ import (
99 "strings"
1010
1111 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
13+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1214 "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/oauth"
1316 "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"
1420 "github.com/spf13/cobra"
1521)
1622
@@ -20,46 +26,122 @@ var loginCmd = &cobra.Command{
2026 Short: "Login Crush to a platform",
2127 Long: `Login Crush to a specified platform.
2228The platform should be provided as an argument.
23−Available platforms are: claude.`,
29+Available platforms are: hyper, claude, copilot.`,
2430 Example: `
31+# Authenticate with Charm Hyper
32+crush login
33+
2534# Authenticate with Claude Code Max
2635crush login claude
36+
37+# Authenticate with GitHub Copilot
38+crush login copilot
2739 `,
2840 ValidArgs: []cobra.Completion{
41+ "hyper",
2942 "claude",
3043 "anthropic",
44+ "copilot",
45+ "github",
46+ "github-copilot",
3147 },
32− Args: cobra.ExactArgs(1),
48+ Args: cobra.MaximumNArgs(1),
3349 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−
4150 app, err := setupAppWithProgressBar(cmd)
4251 if err != nil {
4352 return err
4453 }
4554 defer app.Shutdown()
4655
47− switch args[0] {
56+ provider := "hyper"
57+ if len(args) > 0 {
58+ provider = args[0]
59+ }
60+ switch provider {
61+ case "hyper":
62+ return loginHyper()
4863 case "anthropic", "claude":
4964 return loginClaude()
65+ case "copilot", "github", "github-copilot":
66+ return loginCopilot()
5067 default:
5168 return fmt.Errorf("unknown platform: %s", args[0])
5269 }
5370 },
5471}
5572
56−func loginClaude() error {
73+func loginHyper() error {
74+ cfg := config.Get()
75+ if !hyperp.Enabled() {
76+ return fmt.Errorf("hyper not enabled")
77+ }
5778 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
58− go func() {
59− <-ctx.Done()
60− cancel()
61− os.Exit(1)
62− }()
79+ defer cancel()
80+
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+ }
63145
64146 verifier, challenge, err := claude.GetChallenge()
65147 if err != nil {
@@ -94,7 +176,6 @@ func loginClaude() error {
94176 return err
95177 }
96178
97− cfg := config.Get()
98179 if err := cmp.Or(
99180 cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
100181 cfg.SetConfigField("providers.anthropic.oauth", token),
@@ -106,3 +187,83 @@ func loginClaude() error {
106187 fmt.Println("You're now authenticated with Claude Code Max!")
107188 return nil
108189}
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 (
1313
1414var projectsCmd = &cobra.Command{
1515 Use: "projects",
16− Short: "List all tracked project directories",
17− Long: `List all directories where Crush has been used.
18−This includes the working directory, data directory path, and last accessed time.`,
16+ Short: "List project directories",
17+ Long: "List directories where Crush project data is known to exist",
1918 Example: `
2019# List all projects in a table
2120crush projects
2221
23−# Output as JSON
22+# Output projects data as JSON
2423crush projects --json
2524 `,
2625 RunE: func(cmd *cobra.Command, args []string) error {
internal/cmd/update_providers.go+30−8
@@ -10,22 +10,30 @@ import (
1010 "github.com/spf13/cobra"
1111)
1212
13+var updateProvidersSource string
14+
1315var updateProvidersCmd = &cobra.Command{
1416 Use: "update-providers [path-or-url]",
1517 Short: "Update providers",
16− Long: `Update the list of providers from a specified local path or remote URL.`,
18+ Long: `Update provider information from a specified local path or remote URL.`,
1719 Example: `
18−# Update providers remotely from Catwalk
20+# Update Catwalk providers remotely (default)
1921crush update-providers
2022
21−# Update providers from a custom URL
22−crush update-providers https://example.com/
23+# Update Catwalk providers from a custom URL
24+crush update-providers https://example.com/providers.json
2325
24−# Update providers from a local file
26+# Update Catwalk providers from a local file
2527crush update-providers /path/to/local-providers.json
2628
27−# Update providers from embedded version
29+# Update Catwalk providers from embedded version
2830crush update-providers embedded
31+
32+# Update Hyper provider information
33+crush update-providers --source=hyper
34+
35+# Update Hyper from a custom URL
36+crush update-providers --source=hyper https://hyper.example.com
2937`,
3038 RunE: func(cmd *cobra.Command, args []string) error {
3139 // NOTE(@andreynering): We want to skip logging output do stdout here.
@@ -36,7 +44,17 @@ crush update-providers embedded
3644 pathOrURL = args[0]
3745 }
3846
39− if err := config.UpdateProviders(pathOrURL); err != nil {
47+ var err error
48+ switch updateProvidersSource {
49+ case "catwalk":
50+ err = config.UpdateProviders(pathOrURL)
51+ case "hyper":
52+ err = config.UpdateHyper(pathOrURL)
53+ default:
54+ return fmt.Errorf("invalid source %q, must be 'catwalk' or 'hyper'", updateProvidersSource)
55+ }
56+
57+ if err != nil {
4058 return err
4159 }
4260
@@ -52,9 +70,13 @@ crush update-providers embedded
5270 SetString("SUCCESS")
5371 textStyle := lipgloss.NewStyle().
5472 MarginLeft(2).
55− SetString("Providers updated successfully.")
73+ SetString(fmt.Sprintf("%s provider updated successfully.", updateProvidersSource))
5674
5775 fmt.Printf("%s\n%s\n\n", headerStyle.Render(), textStyle.Render())
5876 return nil
5977 },
6078}
79+
80+func init() {
81+ updateProvidersCmd.Flags().StringVar(&updateProvidersSource, "source", "catwalk", "Provider source to update (catwalk or hyper)")
82+}
internal/config/catwalk.gonew file+82
@@ -0,0 +1,82 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/charmbracelet/catwalk/pkg/embedded"
12+)
13+
14+type catwalkClient interface {
15+ GetProviders(context.Context, string) ([]catwalk.Provider, error)
16+}
17+
18+var _ syncer[[]catwalk.Provider] = (*catwalkSync)(nil)
19+
20+type catwalkSync struct {
21+ once sync.Once
22+ result []catwalk.Provider
23+ cache cache[[]catwalk.Provider]
24+ client catwalkClient
25+ autoupdate bool
26+ init atomic.Bool
27+}
28+
29+func (s *catwalkSync) Init(client catwalkClient, path string, autoupdate bool) {
30+ s.client = client
31+ s.cache = newCache[[]catwalk.Provider](path)
32+ s.autoupdate = autoupdate
33+ s.init.Store(true)
34+}
35+
36+func (s *catwalkSync) Get(ctx context.Context) ([]catwalk.Provider, error) {
37+ if !s.init.Load() {
38+ panic("called Get before Init")
39+ }
40+
41+ var throwErr error
42+ s.once.Do(func() {
43+ if !s.autoupdate {
44+ slog.Info("Using embedded Catwalk providers")
45+ s.result = embedded.GetAll()
46+ return
47+ }
48+
49+ cached, etag, cachedErr := s.cache.Get()
50+ if len(cached) == 0 || cachedErr != nil {
51+ // if cached file is empty, default to embedded providers
52+ cached = embedded.GetAll()
53+ }
54+
55+ slog.Info("Fetching providers from Catwalk")
56+ result, err := s.client.GetProviders(ctx, etag)
57+ if errors.Is(err, context.DeadlineExceeded) {
58+ slog.Warn("Catwalk providers not updated in time")
59+ s.result = cached
60+ return
61+ }
62+ if errors.Is(err, catwalk.ErrNotModified) {
63+ slog.Info("Catwalk providers not modified")
64+ s.result = cached
65+ return
66+ }
67+ if err != nil {
68+ // On error, fall back to cached (which defaults to embedded if empty).
69+ s.result = cached
70+ return
71+ }
72+ if len(result) == 0 {
73+ s.result = cached
74+ throwErr = errors.New("empty providers list from catwalk")
75+ return
76+ }
77+
78+ s.result = result
79+ throwErr = s.cache.Store(result)
80+ })
81+ return s.result, throwErr
82+}
internal/config/catwalk_test.gonew file+221
@@ -0,0 +1,221 @@
1+package config
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "testing"
9+
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type mockCatwalkClient struct {
15+ providers []catwalk.Provider
16+ err error
17+ callCount int
18+}
19+
20+func (m *mockCatwalkClient) GetProviders(ctx context.Context, etag string) ([]catwalk.Provider, error) {
21+ m.callCount++
22+ return m.providers, m.err
23+}
24+
25+func TestCatwalkSync_Init(t *testing.T) {
26+ t.Parallel()
27+
28+ syncer := &catwalkSync{}
29+ client := &mockCatwalkClient{}
30+ path := "/tmp/test.json"
31+
32+ syncer.Init(client, path, true)
33+
34+ require.True(t, syncer.init.Load())
35+ require.Equal(t, client, syncer.client)
36+ require.Equal(t, path, syncer.cache.path)
37+ require.True(t, syncer.autoupdate)
38+}
39+
40+func TestCatwalkSync_GetPanicIfNotInit(t *testing.T) {
41+ t.Parallel()
42+
43+ syncer := &catwalkSync{}
44+ require.Panics(t, func() {
45+ _, _ = syncer.Get(t.Context())
46+ })
47+}
48+
49+func TestCatwalkSync_GetWithAutoUpdateDisabled(t *testing.T) {
50+ t.Parallel()
51+
52+ syncer := &catwalkSync{}
53+ client := &mockCatwalkClient{
54+ providers: []catwalk.Provider{{Name: "should-not-be-used"}},
55+ }
56+ path := t.TempDir() + "/providers.json"
57+
58+ syncer.Init(client, path, false)
59+
60+ providers, err := syncer.Get(t.Context())
61+ require.NoError(t, err)
62+ require.NotEmpty(t, providers)
63+ require.Equal(t, 0, client.callCount, "Client should not be called when autoupdate is disabled")
64+
65+ // Should return embedded providers.
66+ for _, p := range providers {
67+ require.NotEqual(t, "should-not-be-used", p.Name)
68+ }
69+}
70+
71+func TestCatwalkSync_GetFreshProviders(t *testing.T) {
72+ t.Parallel()
73+
74+ syncer := &catwalkSync{}
75+ client := &mockCatwalkClient{
76+ providers: []catwalk.Provider{
77+ {Name: "Fresh Provider", ID: "fresh"},
78+ },
79+ }
80+ path := t.TempDir() + "/providers.json"
81+
82+ syncer.Init(client, path, true)
83+
84+ providers, err := syncer.Get(t.Context())
85+ require.NoError(t, err)
86+ require.Len(t, providers, 1)
87+ require.Equal(t, "Fresh Provider", providers[0].Name)
88+ require.Equal(t, 1, client.callCount)
89+
90+ // Verify cache was written.
91+ fileInfo, err := os.Stat(path)
92+ require.NoError(t, err)
93+ require.False(t, fileInfo.IsDir())
94+}
95+
96+func TestCatwalkSync_GetNotModifiedUsesCached(t *testing.T) {
97+ t.Parallel()
98+
99+ tmpDir := t.TempDir()
100+ path := tmpDir + "/providers.json"
101+
102+ // Create cache file.
103+ cachedProviders := []catwalk.Provider{
104+ {Name: "Cached Provider", ID: "cached"},
105+ }
106+ data, err := json.Marshal(cachedProviders)
107+ require.NoError(t, err)
108+ require.NoError(t, os.WriteFile(path, data, 0o644))
109+
110+ syncer := &catwalkSync{}
111+ client := &mockCatwalkClient{
112+ err: catwalk.ErrNotModified,
113+ }
114+
115+ syncer.Init(client, path, true)
116+
117+ providers, err := syncer.Get(t.Context())
118+ require.NoError(t, err)
119+ require.Len(t, providers, 1)
120+ require.Equal(t, "Cached Provider", providers[0].Name)
121+ require.Equal(t, 1, client.callCount)
122+}
123+
124+func TestCatwalkSync_GetEmptyResultFallbackToCached(t *testing.T) {
125+ t.Parallel()
126+
127+ tmpDir := t.TempDir()
128+ path := tmpDir + "/providers.json"
129+
130+ // Create cache file.
131+ cachedProviders := []catwalk.Provider{
132+ {Name: "Cached Provider", ID: "cached"},
133+ }
134+ data, err := json.Marshal(cachedProviders)
135+ require.NoError(t, err)
136+ require.NoError(t, os.WriteFile(path, data, 0o644))
137+
138+ syncer := &catwalkSync{}
139+ client := &mockCatwalkClient{
140+ providers: []catwalk.Provider{}, // Empty result.
141+ }
142+
143+ syncer.Init(client, path, true)
144+
145+ providers, err := syncer.Get(t.Context())
146+ require.Error(t, err)
147+ require.Contains(t, err.Error(), "empty providers list from catwalk")
148+ require.Len(t, providers, 1)
149+ require.Equal(t, "Cached Provider", providers[0].Name)
150+}
151+
152+func TestCatwalkSync_GetEmptyCacheDefaultsToEmbedded(t *testing.T) {
153+ t.Parallel()
154+
155+ tmpDir := t.TempDir()
156+ path := tmpDir + "/providers.json"
157+
158+ // Create empty cache file.
159+ emptyProviders := []catwalk.Provider{}
160+ data, err := json.Marshal(emptyProviders)
161+ require.NoError(t, err)
162+ require.NoError(t, os.WriteFile(path, data, 0o644))
163+
164+ syncer := &catwalkSync{}
165+ client := &mockCatwalkClient{
166+ err: errors.New("network error"),
167+ }
168+
169+ syncer.Init(client, path, true)
170+
171+ providers, err := syncer.Get(t.Context())
172+ require.NoError(t, err)
173+ require.NotEmpty(t, providers, "Should fall back to embedded providers")
174+
175+ // Verify it's embedded providers by checking we have multiple common ones.
176+ require.Greater(t, len(providers), 5)
177+}
178+
179+func TestCatwalkSync_GetClientError(t *testing.T) {
180+ t.Parallel()
181+
182+ tmpDir := t.TempDir()
183+ path := tmpDir + "/providers.json"
184+
185+ syncer := &catwalkSync{}
186+ client := &mockCatwalkClient{
187+ err: errors.New("network error"),
188+ }
189+
190+ syncer.Init(client, path, true)
191+
192+ providers, err := syncer.Get(t.Context())
193+ require.NoError(t, err) // Should fall back to embedded.
194+ require.NotEmpty(t, providers)
195+}
196+
197+func TestCatwalkSync_GetCalledMultipleTimesUsesOnce(t *testing.T) {
198+ t.Parallel()
199+
200+ syncer := &catwalkSync{}
201+ client := &mockCatwalkClient{
202+ providers: []catwalk.Provider{
203+ {Name: "Provider", ID: "test"},
204+ },
205+ }
206+ path := t.TempDir() + "/providers.json"
207+
208+ syncer.Init(client, path, true)
209+
210+ // Call Get multiple times.
211+ providers1, err1 := syncer.Get(t.Context())
212+ require.NoError(t, err1)
213+ require.Len(t, providers1, 1)
214+
215+ providers2, err2 := syncer.Get(t.Context())
216+ require.NoError(t, err2)
217+ require.Len(t, providers2, 1)
218+
219+ // Client should only be called once due to sync.Once.
220+ require.Equal(t, 1, client.callCount)
221+}
internal/config/config.go+75−10
@@ -5,6 +5,7 @@ import (
55 "context"
66 "fmt"
77 "log/slog"
8+ "maps"
89 "net/http"
910 "net/url"
1011 "os"
@@ -13,11 +14,15 @@ import (
1314 "time"
1415
1516 "github.com/charmbracelet/catwalk/pkg/catwalk"
17+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1618 "github.com/charmbracelet/crush/internal/csync"
1719 "github.com/charmbracelet/crush/internal/env"
1820 "github.com/charmbracelet/crush/internal/oauth"
1921 "github.com/charmbracelet/crush/internal/oauth/claude"
22+ "github.com/charmbracelet/crush/internal/oauth/copilot"
23+ "github.com/charmbracelet/crush/internal/oauth/hyper"
2024 "github.com/invopop/jsonschema"
25+ "github.com/tidwall/gjson"
2126 "github.com/tidwall/sjson"
2227)
2328
@@ -119,8 +124,37 @@ type ProviderConfig struct {
119124 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
120125}
121126
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+
122157func (pc *ProviderConfig) SetupClaudeCode() {
123− pc.APIKey = fmt.Sprintf("Bearer %s", pc.OAuthToken.AccessToken)
124158 pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
125159 pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
126160
@@ -135,6 +169,10 @@ func (pc *ProviderConfig) SetupClaudeCode() {
135169 pc.ExtraHeaders["anthropic-beta"] = value
136170}
137171
172+func (pc *ProviderConfig) SetupGitHubCopilot() {
173+ maps.Copy(pc.ExtraHeaders, copilot.Headers())
174+}
175+
138176type MCPType string
139177
140178const (
@@ -451,6 +489,14 @@ func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model Selecte
451489 return nil
452490}
453491
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+
454500func (c *Config) SetConfigField(key string, value any) error {
455501 // read the data
456502 data, err := os.ReadFile(c.dataConfigDir)
@@ -483,20 +529,33 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
483529 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
484530 }
485531
486− // Only Anthropic provider uses OAuth for now.
487− if providerID != string(catwalk.InferenceProviderAnthropic) {
532+ var newToken *oauth.Token
533+ var refreshErr error
534+ switch providerID {
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:
488542 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
489543 }
490−
491− newToken, err := claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
492− if err != nil {
493− return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
544+ if refreshErr != nil {
545+ return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
494546 }
495547
496548 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
497549 providerConfig.OAuthToken = newToken
498− providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
499− providerConfig.SetupClaudeCode()
550+
551+ switch providerID {
552+ case string(catwalk.InferenceProviderAnthropic):
553+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
554+ providerConfig.SetupClaudeCode()
555+ case string(catwalk.InferenceProviderCopilot):
556+ providerConfig.APIKey = newToken.AccessToken
557+ providerConfig.SetupGitHubCopilot()
558+ }
500559
501560 c.Providers.Set(providerID, providerConfig)
502561
@@ -531,7 +590,13 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
531590 setKeyOrToken = func() {
532591 providerConfig.APIKey = v.AccessToken
533592 providerConfig.OAuthToken = v
534− providerConfig.SetupClaudeCode()
593+ switch providerID {
594+ case string(catwalk.InferenceProviderAnthropic):
595+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
596+ providerConfig.SetupClaudeCode()
597+ case string(catwalk.InferenceProviderCopilot):
598+ providerConfig.SetupGitHubCopilot()
599+ }
535600 }
536601 }
537602
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+28−11
@@ -1,6 +1,7 @@
11package config
22
33import (
4+ "cmp"
45 "context"
56 "encoding/json"
67 "fmt"
@@ -17,6 +18,7 @@ import (
1718 "testing"
1819
1920 "github.com/charmbracelet/catwalk/pkg/catwalk"
21+ "github.com/charmbracelet/crush/internal/agent/hyper"
2022 "github.com/charmbracelet/crush/internal/csync"
2123 "github.com/charmbracelet/crush/internal/env"
2224 "github.com/charmbracelet/crush/internal/fsext"
@@ -131,6 +133,8 @@ func PushPopCrushEnv() func() {
131133}
132134
133135func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
136+ c.importCopilot()
137+
134138 knownProviderNames := make(map[string]bool)
135139 restore := PushPopCrushEnv()
136140 defer restore()
@@ -198,8 +202,18 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
198202 Models: p.Models,
199203 }
200204
201− if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
205+ switch {
206+ case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
202207 prepared.SetupClaudeCode()
208+ case p.ID == catwalk.InferenceProviderCopilot:
209+ if config.OAuthToken != nil {
210+ if token, ok := c.importCopilot(); ok {
211+ prepared.OAuthToken = token
212+ }
213+ }
214+ if config.OAuthToken != nil {
215+ prepared.SetupGitHubCopilot()
216+ }
203217 }
204218
205219 switch p.ID {
@@ -271,7 +285,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
271285 if providerConfig.Type == "" {
272286 providerConfig.Type = catwalk.TypeOpenAICompat
273287 }
274− if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) {
288+ if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
275289 slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
276290 c.Providers.Del(id)
277291 continue
@@ -682,19 +696,22 @@ func hasAWSCredentials(env env.Env) bool {
682696
683697// GlobalConfig returns the global configuration file path for the application.
684698func GlobalConfig() string {
685− xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
686− if xdgConfigHome != "" {
699+ if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
700+ return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
701+ }
702+ if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
687703 return filepath.Join(xdgConfigHome, appName, fmt.Sprintf("%s.json", appName))
688704 }
689−
690705 return filepath.Join(home.Dir(), ".config", appName, fmt.Sprintf("%s.json", appName))
691706}
692707
693708// GlobalConfigData returns the path to the main data directory for the application.
694709// this config is used when the app overrides configurations instead of updating the global config.
695710func GlobalConfigData() string {
696− xdgDataHome := os.Getenv("XDG_DATA_HOME")
697− if xdgDataHome != "" {
711+ if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
712+ return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
713+ }
714+ if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
698715 return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
699716 }
700717
@@ -702,10 +719,10 @@ func GlobalConfigData() string {
702719 // for windows, it should be in `%LOCALAPPDATA%/crush/`
703720 // for linux and macOS, it should be in `$HOME/.local/share/crush/`
704721 if runtime.GOOS == "windows" {
705− localAppData := os.Getenv("LOCALAPPDATA")
706− if localAppData == "" {
707− localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
708− }
722+ localAppData := cmp.Or(
723+ os.Getenv("LOCALAPPDATA"),
724+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
725+ )
709726 return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
710727 }
711728
internal/config/provider.go+130−74
@@ -10,16 +10,21 @@ import (
1010 "os"
1111 "path/filepath"
1212 "runtime"
13+ "slices"
1314 "strings"
1415 "sync"
16+ "time"
1517
1618 "github.com/charmbracelet/catwalk/pkg/catwalk"
1719 "github.com/charmbracelet/catwalk/pkg/embedded"
20+ "github.com/charmbracelet/crush/internal/agent/hyper"
21+ "github.com/charmbracelet/crush/internal/csync"
1822 "github.com/charmbracelet/crush/internal/home"
23+ "github.com/charmbracelet/x/etag"
1924)
2025
21−type ProviderClient interface {
22− GetProviders(context.Context, string) ([]catwalk.Provider, error)
26+type syncer[T any] interface {
27+ Get(context.Context) (T, error)
2328}
2429
2530var (
@@ -29,10 +34,10 @@ var (
2934)
3035
3136// file to cache provider data
32−func providerCacheFileData() string {
37+func cachePathFor(name string) string {
3338 xdgDataHome := os.Getenv("XDG_DATA_HOME")
3439 if xdgDataHome != "" {
35− return filepath.Join(xdgDataHome, appName, "providers.json")
40+ return filepath.Join(xdgDataHome, appName, name+".json")
3641 }
3742
3843 // return the path to the main data directory
@@ -43,43 +48,13 @@ func providerCacheFileData() string {
4348 if localAppData == "" {
4449 localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
4550 }
46− return filepath.Join(localAppData, appName, "providers.json")
51+ return filepath.Join(localAppData, appName, name+".json")
4752 }
4853
49− return filepath.Join(home.Dir(), ".local", "share", appName, "providers.json")
50−}
51−
52−func saveProvidersInCache(path string, providers []catwalk.Provider) error {
53− slog.Info("Saving provider data to disk", "path", path)
54− if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
55− return fmt.Errorf("failed to create directory for provider cache: %w", err)
56− }
57−
58− data, err := json.Marshal(providers)
59− if err != nil {
60− return fmt.Errorf("failed to marshal provider data: %w", err)
61− }
62−
63− if err := os.WriteFile(path, data, 0o644); err != nil {
64− return fmt.Errorf("failed to write provider data to cache: %w", err)
65− }
66− return nil
67−}
68−
69−func loadProvidersFromCache(path string) ([]catwalk.Provider, string, error) {
70− data, err := os.ReadFile(path)
71− if err != nil {
72− return nil, "", fmt.Errorf("failed to read provider cache file: %w", err)
73− }
74−
75− var providers []catwalk.Provider
76− if err := json.Unmarshal(data, &providers); err != nil {
77− return nil, "", fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
78− }
79−
80− return providers, catwalk.Etag(data), nil
54+ return filepath.Join(home.Dir(), ".local", "share", appName, name+".json")
8155}
8256
57+// UpdateProviders updates the Catwalk providers list from a specified source.
8358func UpdateProviders(pathOrURL string) error {
8459 var providers []catwalk.Provider
8560 pathOrURL = cmp.Or(pathOrURL, os.Getenv("CATWALK_URL"), defaultCatwalkURL)
@@ -106,15 +81,55 @@ func UpdateProviders(pathOrURL string) error {
10681 }
10782 }
10883
109− cachePath := providerCacheFileData()
110− if err := saveProvidersInCache(cachePath, providers); err != nil {
84+ if err := newCache[[]catwalk.Provider](cachePathFor("providers")).Store(providers); err != nil {
11185 return fmt.Errorf("failed to save providers to cache: %w", err)
11286 }
11387
114− slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrURL, "to", cachePath)
88+ slog.Info("Providers updated successfully", "count", len(providers), "from", pathOrURL, "to", cachePathFor)
89+ return nil
90+}
91+
92+// UpdateHyper updates the Hyper provider information from a specified URL.
93+func UpdateHyper(pathOrURL string) error {
94+ if !hyper.Enabled() {
95+ return fmt.Errorf("hyper not enabled")
96+ }
97+ var provider catwalk.Provider
98+ pathOrURL = cmp.Or(pathOrURL, hyper.BaseURL())
99+
100+ switch {
101+ case pathOrURL == "embedded":
102+ provider = hyper.Embedded()
103+ case strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://"):
104+ client := realHyperClient{baseURL: pathOrURL}
105+ var err error
106+ provider, err = client.Get(context.Background(), "")
107+ if err != nil {
108+ return fmt.Errorf("failed to fetch provider from Hyper: %w", err)
109+ }
110+ default:
111+ content, err := os.ReadFile(pathOrURL)
112+ if err != nil {
113+ return fmt.Errorf("failed to read file: %w", err)
114+ }
115+ if err := json.Unmarshal(content, &provider); err != nil {
116+ return fmt.Errorf("failed to unmarshal provider data: %w", err)
117+ }
118+ }
119+
120+ if err := newCache[catwalk.Provider](cachePathFor("hyper")).Store(provider); err != nil {
121+ return fmt.Errorf("failed to save Hyper provider to cache: %w", err)
122+ }
123+
124+ slog.Info("Hyper provider updated successfully", "from", pathOrURL, "to", cachePathFor("hyper"))
115125 return nil
116126}
117127
128+var (
129+ catwalkSyncer = &catwalkSync{}
130+ hyperSyncer = &hyperSync{}
131+)
132+
118133// Providers returns the list of providers, taking into account cached results
119134// and whether or not auto update is enabled.
120135//
@@ -126,46 +141,87 @@ func UpdateProviders(pathOrURL string) error {
126141// the cached list, or the embedded list if all others fail.
127142func Providers(cfg *Config) ([]catwalk.Provider, error) {
128143 providerOnce.Do(func() {
129− catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
130− client := catwalk.NewWithURL(catwalkURL)
131− path := providerCacheFileData()
132−
133− if cfg.Options.DisableProviderAutoUpdate {
134− slog.Info("Using embedded Catwalk providers")
135− providerList, providerErr = embedded.GetAll(), nil
136− return
137− }
144+ var wg sync.WaitGroup
145+ var errs []error
146+ providers := csync.NewSlice[catwalk.Provider]()
147+ autoupdate := !cfg.Options.DisableProviderAutoUpdate
148+
149+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
150+ defer cancel()
151+
152+ wg.Go(func() {
153+ catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
154+ client := catwalk.NewWithURL(catwalkURL)
155+ path := cachePathFor("providers")
156+ catwalkSyncer.Init(client, path, autoupdate)
157+
158+ items, err := catwalkSyncer.Get(ctx)
159+ if err != nil {
160+ catwalkURL := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
161+ errs = append(errs, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help.\n\nCause: %w", catwalkURL, providerErr)) //nolint:staticcheck
162+ return
163+ }
164+ providers.Append(items...)
165+ })
166+
167+ wg.Go(func() {
168+ if !hyper.Enabled() {
169+ return
170+ }
171+ path := cachePathFor("hyper")
172+ hyperSyncer.Init(realHyperClient{baseURL: hyper.BaseURL()}, path, autoupdate)
173+
174+ item, err := hyperSyncer.Get(ctx)
175+ if err != nil {
176+ errs = append(errs, fmt.Errorf("Crush was unable to fetch updated information from Hyper: %w", err)) //nolint:staticcheck
177+ return
178+ }
179+ providers.Append(item)
180+ })
181+
182+ wg.Wait()
183+
184+ providerList = slices.Collect(providers.Seq())
185+ providerErr = errors.Join(errs...)
186+ })
187+ return providerList, providerErr
188+}
138189
139− cached, etag, cachedErr := loadProvidersFromCache(path)
140− if len(cached) == 0 || cachedErr != nil {
141− // if cached file is empty, default to embedded providers
142− cached = embedded.GetAll()
143− }
190+type cache[T any] struct {
191+ path string
192+}
144193
145− providerList, providerErr = loadProviders(client, etag, path)
146− if errors.Is(providerErr, catwalk.ErrNotModified) {
147− slog.Info("Catwalk providers not modified")
148− providerList, providerErr = cached, nil
149− }
150− })
151− if providerErr != nil {
152− catwalkURL := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
153− return nil, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help.\n\nCause: %w", catwalkURL, providerErr) //nolint:staticcheck
154− }
155− return providerList, nil
194+func newCache[T any](path string) cache[T] {
195+ return cache[T]{path: path}
156196}
157197
158−func loadProviders(client ProviderClient, etag, path string) ([]catwalk.Provider, error) {
159− slog.Info("Fetching providers from Catwalk.", "path", path)
160− providers, err := client.GetProviders(context.Background(), etag)
198+func (c cache[T]) Get() (T, string, error) {
199+ var v T
200+ data, err := os.ReadFile(c.path)
161201 if err != nil {
162− return nil, fmt.Errorf("failed to fetch providers from catwalk: %w", err)
202+ return v, "", fmt.Errorf("failed to read provider cache file: %w", err)
203+ }
204+
205+ if err := json.Unmarshal(data, &v); err != nil {
206+ return v, "", fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
207+ }
208+
209+ return v, etag.Of(data), nil
210+}
211+
212+func (c cache[T]) Store(v T) error {
213+ slog.Info("Saving provider data to disk", "path", c.path)
214+ if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
215+ return fmt.Errorf("failed to create directory for provider cache: %w", err)
163216 }
164− if len(providers) == 0 {
165− return nil, errors.New("empty providers list from catwalk")
217+
218+ data, err := json.Marshal(v)
219+ if err != nil {
220+ return fmt.Errorf("failed to marshal provider data: %w", err)
166221 }
167− if err := saveProvidersInCache(path, providers); err != nil {
168− return nil, err
222+
223+ if err := os.WriteFile(c.path, data, 0o644); err != nil {
224+ return fmt.Errorf("failed to write provider data to cache: %w", err)
169225 }
170− return providers, nil
226+ return nil
171227}
internal/config/provider_empty_test.go+18−10
@@ -2,6 +2,7 @@ package config
22
33import (
44 "context"
5+ "os"
56 "testing"
67
78 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -14,18 +15,25 @@ func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.P
1415 return []catwalk.Provider{}, nil
1516}
1617
17−// TestProvider_loadProvidersEmptyResult tests that loadProviders returns an
18−// error when the client returns an empty list. This ensures we don't cache
19−// empty provider lists.
20−func TestProvider_loadProvidersEmptyResult(t *testing.T) {
18+// TestCatwalkSync_GetEmptyResultFromClient tests that when the client returns
19+// an empty list, we fall back to cached providers and return an error.
20+func TestCatwalkSync_GetEmptyResultFromClient(t *testing.T) {
21+ t.Parallel()
22+
23+ tmpDir := t.TempDir()
24+ path := tmpDir + "/providers.json"
25+
26+ syncer := &catwalkSync{}
2127 client := &emptyProviderClient{}
22− tmpPath := t.TempDir() + "/providers.json"
2328
24− providers, err := loadProviders(client, "", tmpPath)
29+ syncer.Init(client, path, true)
30+
31+ providers, err := syncer.Get(t.Context())
32+ require.Error(t, err)
2533 require.Contains(t, err.Error(), "empty providers list from catwalk")
26− require.Empty(t, providers)
27− require.Len(t, providers, 0)
34+ require.NotEmpty(t, providers) // Should have embedded providers as fallback.
2835
29− // Check that no cache file was created for empty results
30− require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
36+ // Check that no cache file was created for empty results.
37+ _, statErr := os.Stat(path)
38+ require.True(t, os.IsNotExist(statErr), "Cache file should not exist for empty results")
3139}
internal/config/provider_test.go+240−76
@@ -1,10 +1,9 @@
11package config
22
33import (
4− "context"
54 "encoding/json"
6− "errors"
75 "os"
6+ "path/filepath"
87 "sync"
98 "testing"
109
@@ -12,47 +11,31 @@ import (
1211 "github.com/stretchr/testify/require"
1312)
1413
15−type mockProviderClient struct {
16− shouldFail bool
17− shouldReturnErr error
18−}
19−
20−func (m *mockProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
21− if m.shouldReturnErr != nil {
22− return nil, m.shouldReturnErr
23− }
24− if m.shouldFail {
25− return nil, errors.New("failed to load providers")
26− }
27− return []catwalk.Provider{
28− {
29− Name: "Mock",
30− },
31− }, nil
32−}
33−
3414func resetProviderState() {
3515 providerOnce = sync.Once{}
3616 providerList = nil
3717 providerErr = nil
18+ catwalkSyncer = &catwalkSync{}
19+ hyperSyncer = &hyperSync{}
3820}
3921
40−func TestProvider_loadProvidersNoIssues(t *testing.T) {
41− client := &mockProviderClient{shouldFail: false}
42− tmpPath := t.TempDir() + "/providers.json"
43− providers, err := loadProviders(client, "", tmpPath)
44− require.NoError(t, err)
45− require.NotNil(t, providers)
46− require.Len(t, providers, 1)
22+func TestProviders_Integration_AutoUpdateDisabled(t *testing.T) {
23+ tmpDir := t.TempDir()
24+ t.Setenv("XDG_DATA_HOME", tmpDir)
4725
48− // check if file got saved
49− fileInfo, err := os.Stat(tmpPath)
50− require.NoError(t, err)
51− require.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
52−}
26+ // Use a test-specific instance to avoid global state interference.
27+ testCatwalkSyncer := &catwalkSync{}
28+ testHyperSyncer := &hyperSync{}
29+
30+ originalCatwalSyncer := catwalkSyncer
31+ originalHyperSyncer := hyperSyncer
32+ defer func() {
33+ catwalkSyncer = originalCatwalSyncer
34+ hyperSyncer = originalHyperSyncer
35+ }()
5336
54−func TestProvider_DisableAutoUpdate(t *testing.T) {
55− t.Setenv("XDG_DATA_HOME", t.TempDir())
37+ catwalkSyncer = testCatwalkSyncer
38+ hyperSyncer = testHyperSyncer
5639
5740 resetProviderState()
5841 defer resetProviderState()
@@ -69,76 +52,257 @@ func TestProvider_DisableAutoUpdate(t *testing.T) {
6952 require.Greater(t, len(providers), 5, "Expected embedded providers")
7053}
7154
72−func TestProvider_WithValidCache(t *testing.T) {
55+func TestProviders_Integration_WithMockClients(t *testing.T) {
7356 tmpDir := t.TempDir()
7457 t.Setenv("XDG_DATA_HOME", tmpDir)
7558
76− resetProviderState()
77− defer resetProviderState()
59+ // Create fresh syncers for this test.
60+ testCatwalkSyncer := &catwalkSync{}
61+ testHyperSyncer := &hyperSync{}
62+
63+ // Initialize with mock clients.
64+ mockCatwalkClient := &mockCatwalkClient{
65+ providers: []catwalk.Provider{
66+ {Name: "Provider1", ID: "p1"},
67+ {Name: "Provider2", ID: "p2"},
68+ },
69+ }
70+ mockHyperClient := &mockHyperClient{
71+ provider: catwalk.Provider{
72+ Name: "Hyper",
73+ ID: "hyper",
74+ Models: []catwalk.Model{
75+ {ID: "hyper-1", Name: "Hyper Model"},
76+ },
77+ },
78+ }
79+
80+ catwalkPath := tmpDir + "/crush/providers.json"
81+ hyperPath := tmpDir + "/crush/hyper.json"
82+
83+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
84+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
85+
86+ // Get providers from each syncer.
87+ catwalkProviders, err := testCatwalkSyncer.Get(t.Context())
88+ require.NoError(t, err)
89+ require.Len(t, catwalkProviders, 2)
90+
91+ hyperProvider, err := testHyperSyncer.Get(t.Context())
92+ require.NoError(t, err)
93+ require.Equal(t, "Hyper", hyperProvider.Name)
94+
95+ // Verify total.
96+ allProviders := append(catwalkProviders, hyperProvider)
97+ require.Len(t, allProviders, 3)
98+}
99+
100+func TestProviders_Integration_WithCachedData(t *testing.T) {
101+ tmpDir := t.TempDir()
102+ t.Setenv("XDG_DATA_HOME", tmpDir)
103+
104+ // Create cache files.
105+ catwalkPath := tmpDir + "/crush/providers.json"
106+ hyperPath := tmpDir + "/crush/hyper.json"
78107
79− cachePath := tmpDir + "/crush/providers.json"
80108 require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
81− cachedProviders := []catwalk.Provider{
82− {Name: "Cached"},
109+
110+ // Write Catwalk cache.
111+ catwalkProviders := []catwalk.Provider{
112+ {Name: "Cached1", ID: "c1"},
113+ {Name: "Cached2", ID: "c2"},
114+ }
115+ data, err := json.Marshal(catwalkProviders)
116+ require.NoError(t, err)
117+ require.NoError(t, os.WriteFile(catwalkPath, data, 0o644))
118+
119+ // Write Hyper cache.
120+ hyperProvider := catwalk.Provider{
121+ Name: "Cached Hyper",
122+ ID: "hyper",
83123 }
84− data, err := json.Marshal(cachedProviders)
124+ data, err = json.Marshal(hyperProvider)
85125 require.NoError(t, err)
86− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
126+ require.NoError(t, os.WriteFile(hyperPath, data, 0o644))
127+
128+ // Create fresh syncers.
129+ testCatwalkSyncer := &catwalkSync{}
130+ testHyperSyncer := &hyperSync{}
131+
132+ // Mock clients that return ErrNotModified.
133+ mockCatwalkClient := &mockCatwalkClient{
134+ err: catwalk.ErrNotModified,
135+ }
136+ mockHyperClient := &mockHyperClient{
137+ err: catwalk.ErrNotModified,
138+ }
87139
88− mockClient := &mockProviderClient{shouldFail: false}
140+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
141+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
89142
90− providers, err := loadProviders(mockClient, "", cachePath)
143+ // Get providers - should use cached.
144+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
91145 require.NoError(t, err)
92− require.NotNil(t, providers)
93− require.Len(t, providers, 1)
94− require.Equal(t, "Mock", providers[0].Name, "Expected fresh provider from fetch")
146+ require.Len(t, catwalkResult, 2)
147+ require.Equal(t, "Cached1", catwalkResult[0].Name)
148+
149+ hyperResult, err := testHyperSyncer.Get(t.Context())
150+ require.NoError(t, err)
151+ require.Equal(t, "Cached Hyper", hyperResult.Name)
95152}
96153
97−func TestProvider_NotModifiedUsesCached(t *testing.T) {
154+func TestProviders_Integration_CatwalkFailsHyperSucceeds(t *testing.T) {
98155 tmpDir := t.TempDir()
99156 t.Setenv("XDG_DATA_HOME", tmpDir)
100157
101− resetProviderState()
102− defer resetProviderState()
158+ testCatwalkSyncer := &catwalkSync{}
159+ testHyperSyncer := &hyperSync{}
103160
104− cachePath := tmpDir + "/crush/providers.json"
105− require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
106− cachedProviders := []catwalk.Provider{
107− {Name: "Cached"},
161+ // Catwalk fails, Hyper succeeds.
162+ mockCatwalkClient := &mockCatwalkClient{
163+ err: catwalk.ErrNotModified, // Will use embedded.
108164 }
109− data, err := json.Marshal(cachedProviders)
165+ mockHyperClient := &mockHyperClient{
166+ provider: catwalk.Provider{
167+ Name: "Hyper",
168+ ID: "hyper",
169+ Models: []catwalk.Model{
170+ {ID: "hyper-1", Name: "Hyper Model"},
171+ },
172+ },
173+ }
174+
175+ catwalkPath := tmpDir + "/crush/providers.json"
176+ hyperPath := tmpDir + "/crush/hyper.json"
177+
178+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
179+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
180+
181+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
110182 require.NoError(t, err)
111− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
183+ require.NotEmpty(t, catwalkResult) // Should have embedded.
112184
113− mockClient := &mockProviderClient{shouldReturnErr: catwalk.ErrNotModified}
114− providers, err := loadProviders(mockClient, "", cachePath)
115− require.ErrorIs(t, err, catwalk.ErrNotModified)
116− require.Nil(t, providers)
185+ hyperResult, err := testHyperSyncer.Get(t.Context())
186+ require.NoError(t, err)
187+ require.Equal(t, "Hyper", hyperResult.Name)
117188}
118189
119−func TestProvider_EmptyCacheDefaultsToEmbedded(t *testing.T) {
190+func TestProviders_Integration_BothFail(t *testing.T) {
120191 tmpDir := t.TempDir()
121192 t.Setenv("XDG_DATA_HOME", tmpDir)
122193
123− resetProviderState()
124− defer resetProviderState()
194+ testCatwalkSyncer := &catwalkSync{}
195+ testHyperSyncer := &hyperSync{}
125196
126− cachePath := tmpDir + "/crush/providers.json"
127− require.NoError(t, os.MkdirAll(tmpDir+"/crush", 0o755))
128− emptyProviders := []catwalk.Provider{}
129− data, err := json.Marshal(emptyProviders)
197+ // Both fail.
198+ mockCatwalkClient := &mockCatwalkClient{
199+ err: catwalk.ErrNotModified,
200+ }
201+ mockHyperClient := &mockHyperClient{
202+ provider: catwalk.Provider{}, // Empty provider.
203+ }
204+
205+ catwalkPath := tmpDir + "/crush/providers.json"
206+ hyperPath := tmpDir + "/crush/hyper.json"
207+
208+ testCatwalkSyncer.Init(mockCatwalkClient, catwalkPath, true)
209+ testHyperSyncer.Init(mockHyperClient, hyperPath, true)
210+
211+ catwalkResult, err := testCatwalkSyncer.Get(t.Context())
130212 require.NoError(t, err)
131− require.NoError(t, os.WriteFile(cachePath, data, 0o644))
213+ require.NotEmpty(t, catwalkResult) // Should fall back to embedded.
132214
133− cached, _, err := loadProvidersFromCache(cachePath)
215+ hyperResult, err := testHyperSyncer.Get(t.Context())
134216 require.NoError(t, err)
135− require.Empty(t, cached, "Expected empty cache")
217+ require.Equal(t, "Charm Hyper", hyperResult.Name) // Falls back to embedded when no models.
136218}
137219
138−func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
139− client := &mockProviderClient{shouldFail: true}
140− tmpPath := t.TempDir() + "/providers.json"
141− providers, err := loadProviders(client, "", tmpPath)
220+func TestCache_StoreAndGet(t *testing.T) {
221+ t.Parallel()
222+
223+ tmpDir := t.TempDir()
224+ cachePath := tmpDir + "/test.json"
225+
226+ cache := newCache[[]catwalk.Provider](cachePath)
227+
228+ providers := []catwalk.Provider{
229+ {Name: "Provider1", ID: "p1"},
230+ {Name: "Provider2", ID: "p2"},
231+ }
232+
233+ // Store.
234+ err := cache.Store(providers)
235+ require.NoError(t, err)
236+
237+ // Get.
238+ result, etag, err := cache.Get()
239+ require.NoError(t, err)
240+ require.Len(t, result, 2)
241+ require.Equal(t, "Provider1", result[0].Name)
242+ require.NotEmpty(t, etag)
243+}
244+
245+func TestCache_GetNonExistent(t *testing.T) {
246+ t.Parallel()
247+
248+ tmpDir := t.TempDir()
249+ cachePath := tmpDir + "/nonexistent.json"
250+
251+ cache := newCache[[]catwalk.Provider](cachePath)
252+
253+ _, _, err := cache.Get()
142254 require.Error(t, err)
143− require.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
255+ require.Contains(t, err.Error(), "failed to read provider cache file")
256+}
257+
258+func TestCache_GetInvalidJSON(t *testing.T) {
259+ t.Parallel()
260+
261+ tmpDir := t.TempDir()
262+ cachePath := tmpDir + "/invalid.json"
263+
264+ require.NoError(t, os.WriteFile(cachePath, []byte("invalid json"), 0o644))
265+
266+ cache := newCache[[]catwalk.Provider](cachePath)
267+
268+ _, _, err := cache.Get()
269+ require.Error(t, err)
270+ require.Contains(t, err.Error(), "failed to unmarshal provider data from cache")
271+}
272+
273+func TestCachePathFor(t *testing.T) {
274+ tests := []struct {
275+ name string
276+ xdgDataHome string
277+ expected string
278+ }{
279+ {
280+ name: "with XDG_DATA_HOME",
281+ xdgDataHome: "/custom/data",
282+ expected: "/custom/data/crush/providers.json",
283+ },
284+ {
285+ name: "without XDG_DATA_HOME",
286+ xdgDataHome: "",
287+ expected: "", // Will use platform-specific default.
288+ },
289+ }
290+
291+ for _, tt := range tests {
292+ t.Run(tt.name, func(t *testing.T) {
293+ if tt.xdgDataHome != "" {
294+ t.Setenv("XDG_DATA_HOME", tt.xdgDataHome)
295+ } else {
296+ t.Setenv("XDG_DATA_HOME", "")
297+ }
298+
299+ result := cachePathFor("providers")
300+ if tt.expected != "" {
301+ require.Equal(t, tt.expected, filepath.ToSlash(result))
302+ } else {
303+ require.Contains(t, result, "crush")
304+ require.Contains(t, result, "providers.json")
305+ }
306+ })
307+ }
144308}
internal/event/event.go+16
@@ -46,6 +46,22 @@ func Init() {
4646 distinctId = getDistinctId()
4747}
4848
49+func GetID() string { return distinctId }
50+
51+func Alias(userID string) {
52+ if client == nil || distinctId == fallbackId || distinctId == "" || userID == "" {
53+ return
54+ }
55+ if err := client.Enqueue(posthog.Alias{
56+ DistinctId: distinctId,
57+ Alias: userID,
58+ }); err != nil {
59+ slog.Error("Failed to enqueue PostHog alias event", "error", err)
60+ return
61+ }
62+ slog.Info("Aliased in PostHog", "machine_id", distinctId, "user_id", userID)
63+}
64+
4965// send logs an event to PostHog with the given event name and properties.
5066func send(event string, props ...any) {
5167 if client == nil {
internal/oauth/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 (
44 "time"
55)
66
7−// Token represents an OAuth2 token from Claude Code Max.
7+// Token represents an OAuth2 token.
88type Token struct {
99 AccessToken string `json:"access_token"`
1010 RefreshToken string `json:"refresh_token"`
@@ -21,3 +21,8 @@ func (t *Token) SetExpiresAt() {
2121func (t *Token) IsExpired() bool {
2222 return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10)
2323}
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 (
1313 "github.com/charmbracelet/catwalk/pkg/catwalk"
1414
1515 "github.com/charmbracelet/crush/internal/agent"
16+ "github.com/charmbracelet/crush/internal/agent/hyper"
1617 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1718 "github.com/charmbracelet/crush/internal/config"
1819 "github.com/charmbracelet/crush/internal/csync"
@@ -363,7 +364,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
363364 selectedModel := cfg.Models[agentCfg.Model]
364365
365366 // Anthropic models: thinking toggle
366− if providerCfg.Type == catwalk.TypeAnthropic {
367+ if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
367368 status := "Enable"
368369 if selectedModel.Think {
369370 status = "Disable"
internal/tui/components/dialogs/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 {
1515 isAPIKeyHelp bool
1616 isAPIKeyValid bool
1717
18+ isHyperDeviceFlow bool
19+ isCopilotDeviceFlow bool
20+ isCopilotUnavailable bool
21+
1822 isClaudeAuthChoiceHelp bool
1923 isClaudeOAuthHelp bool
2024 isClaudeOAuthURLState bool
@@ -74,6 +78,28 @@ func (k KeyMap) FullHelp() [][]key.Binding {
7478
7579// ShortHelp implements help.KeyMap.
7680func (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+ }
77103 if k.isClaudeAuthChoiceHelp {
78104 return []key.Binding{
79105 key.NewBinding(
internal/tui/components/dialogs/models/list.go+20−25
@@ -62,7 +62,9 @@ func (m *ModelListComponent) Init() tea.Cmd {
6262 filteredProviders := []catwalk.Provider{}
6363 for _, p := range providers {
6464 hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
65− if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
65+ isHyper := p.ID == "hyper"
66+ isCopilot := p.ID == catwalk.InferenceProviderCopilot
67+ if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot {
6668 filteredProviders = append(filteredProviders, p)
6769 }
6870 }
@@ -146,29 +148,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
146148 if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) ||
147149 !slices.ContainsFunc(m.providers, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) {
148150 // Convert config provider to provider.Provider format
149− configProvider := catwalk.Provider{
150− Name: providerConfig.Name,
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− }
151+ configProvider := providerConfig.ToProvider()
172152
173153 // Add this unknown provider to the list
174154 name := configProvider.Name
@@ -204,8 +184,23 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
204184 }
205185 }
206186
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+
207202 // Then add the known providers from the predefined list
208− for _, provider := range m.providers {
203+ for _, provider := range sortedProviders {
209204 // Skip if we already added this provider as an unknown provider
210205 if addedProviders[string(provider.ID)] {
211206 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.
12package models
23
34import (
@@ -11,10 +12,13 @@ import (
1112 "charm.land/lipgloss/v2"
1213 "github.com/atotto/clipboard"
1314 "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1416 "github.com/charmbracelet/crush/internal/config"
1517 "github.com/charmbracelet/crush/internal/tui/components/core"
1618 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
1719 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
21+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
1822 "github.com/charmbracelet/crush/internal/tui/exp/list"
1923 "github.com/charmbracelet/crush/internal/tui/styles"
2024 "github.com/charmbracelet/crush/internal/tui/util"
@@ -70,6 +74,14 @@ type modelDialogCmp struct {
7074 isAPIKeyValid bool
7175 apiKeyValue string
7276
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+
7385 // Claude state
7486 claudeAuthMethodChooser *claude.AuthMethodChooser
7587 claudeOAuth2 *claude.OAuth2
@@ -127,6 +139,24 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
127139 u, cmd := m.apiKeyInput.Update(msg)
128140 m.apiKeyInput = u.(*APIKeyInput)
129141 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)
130160 case claude.ValidationCompletedMsg:
131161 var cmds []tea.Cmd
132162 u, cmd := m.claudeOAuth2.Update(msg)
@@ -134,7 +164,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
134164 cmds = append(cmds, cmd)
135165
136166 if msg.State == claude.OAuthValidationStateValid {
137− cmds = append(cmds, m.saveAPIKeyAndContinue(msg.Token, false))
167+ cmds = append(cmds, m.saveOauthTokenAndContinue(msg.Token, false))
138168 m.keyMap.isClaudeOAuthHelpComplete = true
139169 }
140170
@@ -143,6 +173,14 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
143173 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
144174 case tea.KeyPressMsg:
145175 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+ }
146184 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
147185 return m, tea.Sequence(
148186 tea.SetClipboard(m.claudeOAuth2.URL),
@@ -156,6 +194,13 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
156194 m.claudeAuthMethodChooser.ToggleChoice()
157195 return m, nil
158196 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+ }
159204 selectedItem := m.modelList.SelectedModel()
160205
161206 modelType := config.SelectedModelTypeLarge
@@ -167,6 +212,8 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
167212 m.keyMap.isClaudeAuthChoiceHelp = false
168213 m.keyMap.isClaudeOAuthHelp = false
169214 m.keyMap.isAPIKeyHelp = true
215+ m.showHyperDeviceFlow = false
216+ m.showCopilotDeviceFlow = false
170217 m.showClaudeAuthMethodChooser = false
171218 m.needsAPIKey = true
172219 m.selectedModel = selectedItem
@@ -194,7 +241,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
194241 return m, cmd2
195242 }
196243 if m.isAPIKeyValid {
197− return m, m.saveAPIKeyAndContinue(m.apiKeyValue, true)
244+ return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
198245 }
199246 if m.needsAPIKey {
200247 // Handle API key submission
@@ -249,15 +296,30 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
249296 ModelType: modelType,
250297 }),
251298 )
252− } else {
253− if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
254− m.showClaudeAuthMethodChooser = true
255− m.keyMap.isClaudeAuthChoiceHelp = true
256− return m, nil
257− }
258− askForApiKey()
299+ }
300+ switch selectedItem.Provider.ID {
301+ case catwalk.InferenceProviderAnthropic:
302+ m.showClaudeAuthMethodChooser = true
303+ m.keyMap.isClaudeAuthChoiceHelp = true
259304 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()
260319 }
320+ // For other providers, show API key input
321+ askForApiKey()
322+ return m, nil
261323 case key.Matches(msg, m.keyMap.Tab):
262324 switch {
263325 case m.showClaudeAuthMethodChooser:
@@ -275,6 +337,20 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
275337 return m, m.modelList.SetModelType(LargeModelType)
276338 }
277339 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+ }
278354 if m.showClaudeAuthMethodChooser {
279355 m.claudeAuthMethodChooser.SetDefaults()
280356 m.showClaudeAuthMethodChooser = false
@@ -329,11 +405,33 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
329405 return m, cmd
330406 }
331407 case spinner.TickMsg:
332− if m.showClaudeOAuth2 {
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
430+ case m.showClaudeOAuth2:
333431 u, cmd := m.claudeOAuth2.Update(msg)
334432 m.claudeOAuth2 = u.(*claude.OAuth2)
335433 return m, cmd
336− } else {
434+ default:
337435 u, cmd := m.apiKeyInput.Update(msg)
338436 m.apiKeyInput = u.(*APIKeyInput)
339437 return m, cmd
@@ -345,6 +443,39 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
345443func (m *modelDialogCmp) View() string {
346444 t := styles.CurrentTheme()
347445
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+
348479 switch {
349480 case m.showClaudeAuthMethodChooser:
350481 chooserView := m.claudeAuthMethodChooser.View()
@@ -397,6 +528,12 @@ func (m *modelDialogCmp) View() string {
397528}
398529
399530func (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+ }
400537 if m.showClaudeAuthMethodChooser {
401538 return nil
402539 }
@@ -477,10 +614,8 @@ func (m *modelDialogCmp) modelTypeRadio() string {
477614
478615func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
479616 cfg := config.Get()
480− if _, ok := cfg.Providers.Get(providerID); ok {
481− return true
482− }
483− return false
617+ _, ok := cfg.Providers.Get(providerID)
618+ return ok
484619}
485620
486621func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
@@ -497,7 +632,7 @@ func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*cat
497632 return nil, nil
498633}
499634
500−func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
635+func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd {
501636 if m.selectedModel == nil {
502637 return util.ReportError(fmt.Errorf("no model selected"))
503638 }
internal/tui/components/mcp/mcp.go+10−2
@@ -69,10 +69,18 @@ func RenderMCPList(opts RenderOptions) []string {
6969 case mcp.StateConnected:
7070 icon = t.ItemOnlineIcon
7171 if count := state.Counts.Tools; count > 0 {
72− extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d tools", count)))
72+ label := "tools"
73+ if count == 1 {
74+ label = "tool"
75+ }
76+ extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label)))
7377 }
7478 if count := state.Counts.Prompts; count > 0 {
75− extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d prompts", count)))
79+ label := "prompts"
80+ if count == 1 {
81+ label = "prompt"
82+ }
83+ extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label)))
7684 }
7785 case mcp.StateError:
7886 icon = t.ItemErrorIcon
internal/tui/exp/list/items.go+6
@@ -323,6 +323,7 @@ type ItemSection interface {
323323 layout.Sizeable
324324 Indexable
325325 SetInfo(info string)
326+ Title() string
326327}
327328type itemSectionModel struct {
328329 width int
@@ -337,6 +338,11 @@ func (m *itemSectionModel) ID() string {
337338 return m.id
338339}
339340
341+// Title implements ItemSection.
342+func (m *itemSectionModel) Title() string {
343+ return m.title
344+}
345+
340346func NewItemSection(title string) ItemSection {
341347 return &itemSectionModel{
342348 title: title,
internal/ui/dialog/sessions_item.go+9−2
@@ -86,13 +86,20 @@ func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, w
8686 }
8787
8888 var ageLen int
89+ var right string
90+ lineWidth := width
8991 if updatedAt > 0 {
9092 ageLen = lipgloss.Width(age)
93+ lineWidth -= ageLen
9194 }
9295
93− title = ansi.Truncate(title, max(0, width-ageLen), "…")
96+ title = ansi.Truncate(title, max(0, lineWidth), "…")
9497 titleLen := lipgloss.Width(title)
95− right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
98+
99+ if updatedAt > 0 {
100+ right = lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
101+ }
102+
96103 content := title
97104 if matches := len(m.MatchedIndexes); matches > 0 {
98105 var lastPos int
internal/ui/list/filterable.go+8−4
@@ -70,13 +70,17 @@ func (f *FilterableList) SetFilter(q string) {
7070 f.query = q
7171}
7272
73−type filterableItems []FilterableItem
73+// FilterableItemsSource is a type that implements [fuzzy.Source] for filtering
74+// [FilterableItem]s.
75+type FilterableItemsSource []FilterableItem
7476
75−func (f filterableItems) Len() int {
77+// Len returns the length of the source.
78+func (f FilterableItemsSource) Len() int {
7679 return len(f)
7780}
7881
79−func (f filterableItems) String(i int) string {
82+// String returns the string representation of the item at index i.
83+func (f FilterableItemsSource) String(i int) string {
8084 return f[i].Filter()
8185}
8286
@@ -94,7 +98,7 @@ func (f *FilterableList) VisibleItems() []Item {
9498 return items
9599 }
96100
97− items := filterableItems(f.items)
101+ items := FilterableItemsSource(f.items)
98102 matches := fuzzy.FindFrom(f.query, items)
99103 matchedItems := []Item{}
100104 resultSize := len(matches)
internal/ui/list/item.go+19
@@ -1,6 +1,8 @@
11package list
22
33import (
4+ "strings"
5+
46 "github.com/charmbracelet/x/ansi"
57)
68
@@ -30,3 +32,20 @@ type MouseClickable interface {
3032 // It returns true if the event was handled, false otherwise.
3133 HandleMouseClick(btn ansi.MouseButton, x, y int) bool
3234}
35+
36+// SpacerItem is a spacer item that adds vertical space in the list.
37+type SpacerItem struct {
38+ Height int
39+}
40+
41+// NewSpacerItem creates a new [SpacerItem] with the specified height.
42+func NewSpacerItem(height int) *SpacerItem {
43+ return &SpacerItem{
44+ Height: max(0, height-1),
45+ }
46+}
47+
48+// Render implements the Item interface for [SpacerItem].
49+func (s *SpacerItem) Render(width int) string {
50+ return strings.Repeat("\n", s.Height)
51+}
internal/ui/list/list.go+17−4
@@ -390,6 +390,7 @@ func (l *List) SelectedItemInView() bool {
390390}
391391
392392// SetSelected sets the selected item index in the list.
393+// It returns -1 if the index is out of bounds.
393394func (l *List) SetSelected(index int) {
394395 if index < 0 || index >= len(l.items) {
395396 l.selectedIdx = -1
@@ -415,31 +416,43 @@ func (l *List) IsSelectedLast() bool {
415416}
416417
417418// SelectPrev selects the previous item in the list.
418−func (l *List) SelectPrev() {
419+// It returns whether the selection changed.
420+func (l *List) SelectPrev() bool {
419421 if l.selectedIdx > 0 {
420422 l.selectedIdx--
423+ return true
421424 }
425+ return false
422426}
423427
424428// SelectNext selects the next item in the list.
425−func (l *List) SelectNext() {
429+// It returns whether the selection changed.
430+func (l *List) SelectNext() bool {
426431 if l.selectedIdx < len(l.items)-1 {
427432 l.selectedIdx++
433+ return true
428434 }
435+ return false
429436}
430437
431438// SelectFirst selects the first item in the list.
432−func (l *List) SelectFirst() {
439+// It returns whether the selection changed.
440+func (l *List) SelectFirst() bool {
433441 if len(l.items) > 0 {
434442 l.selectedIdx = 0
443+ return true
435444 }
445+ return false
436446}
437447
438448// SelectLast selects the last item in the list.
439−func (l *List) SelectLast() {
449+// It returns whether the selection changed.
450+func (l *List) SelectLast() bool {
440451 if len(l.items) > 0 {
441452 l.selectedIdx = len(l.items) - 1
453+ return true
442454 }
455+ return false
443456}
444457
445458// SelectedItem returns the currently selected item. It may be nil if no item
internal/ui/model/chat.go+8−8
@@ -169,30 +169,30 @@ func (m *Chat) Blur() {
169169 m.list.Blur()
170170}
171171
172−// ScrollToTop scrolls the chat view to the top and returns a command to restart
172+// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
173173// any paused animations that are now visible.
174−func (m *Chat) ScrollToTop() tea.Cmd {
174+func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
175175 m.list.ScrollToTop()
176176 return m.RestartPausedVisibleAnimations()
177177}
178178
179−// ScrollToBottom scrolls the chat view to the bottom and returns a command to
179+// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
180180// restart any paused animations that are now visible.
181−func (m *Chat) ScrollToBottom() tea.Cmd {
181+func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
182182 m.list.ScrollToBottom()
183183 return m.RestartPausedVisibleAnimations()
184184}
185185
186−// ScrollBy scrolls the chat view by the given number of line deltas and returns
186+// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
187187// a command to restart any paused animations that are now visible.
188−func (m *Chat) ScrollBy(lines int) tea.Cmd {
188+func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
189189 m.list.ScrollBy(lines)
190190 return m.RestartPausedVisibleAnimations()
191191}
192192
193−// ScrollToSelected scrolls the chat view to the selected item and returns a
193+// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
194194// command to restart any paused animations that are now visible.
195−func (m *Chat) ScrollToSelected() tea.Cmd {
195+func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
196196 m.list.ScrollToSelected()
197197 return m.RestartPausedVisibleAnimations()
198198}
internal/ui/model/mcp.go+4−2
@@ -16,8 +16,10 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
1616 var mcps []mcp.ClientInfo
1717 t := m.com.Styles
1818
19− for _, state := range m.mcpStates {
20− mcps = append(mcps, state)
19+ for _, mcp := range m.com.Config().MCP.Sorted() {
20+ if state, ok := m.mcpStates[mcp.Name]; ok {
21+ mcps = append(mcps, state)
22+ }
2123 }
2224
2325 title := t.Subtle.Render("MCPs")
internal/ui/model/ui.go+23−23
@@ -267,22 +267,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
267267 switch m.state {
268268 case uiChat:
269269 if msg.Y <= 0 {
270− if cmd := m.chat.ScrollBy(-1); cmd != nil {
270+ if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
271271 cmds = append(cmds, cmd)
272272 }
273273 if !m.chat.SelectedItemInView() {
274274 m.chat.SelectPrev()
275− if cmd := m.chat.ScrollToSelected(); cmd != nil {
275+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
276276 cmds = append(cmds, cmd)
277277 }
278278 }
279279 } else if msg.Y >= m.chat.Height()-1 {
280− if cmd := m.chat.ScrollBy(1); cmd != nil {
280+ if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
281281 cmds = append(cmds, cmd)
282282 }
283283 if !m.chat.SelectedItemInView() {
284284 m.chat.SelectNext()
285− if cmd := m.chat.ScrollToSelected(); cmd != nil {
285+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
286286 cmds = append(cmds, cmd)
287287 }
288288 }
@@ -309,22 +309,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
309309 case uiChat:
310310 switch msg.Button {
311311 case tea.MouseWheelUp:
312− if cmd := m.chat.ScrollBy(-5); cmd != nil {
312+ if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
313313 cmds = append(cmds, cmd)
314314 }
315315 if !m.chat.SelectedItemInView() {
316316 m.chat.SelectPrev()
317− if cmd := m.chat.ScrollToSelected(); cmd != nil {
317+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
318318 cmds = append(cmds, cmd)
319319 }
320320 }
321321 case tea.MouseWheelDown:
322− if cmd := m.chat.ScrollBy(5); cmd != nil {
322+ if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
323323 cmds = append(cmds, cmd)
324324 }
325325 if !m.chat.SelectedItemInView() {
326326 m.chat.SelectNext()
327− if cmd := m.chat.ScrollToSelected(); cmd != nil {
327+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
328328 cmds = append(cmds, cmd)
329329 }
330330 }
@@ -394,7 +394,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
394394 }
395395
396396 m.chat.SetMessages(items...)
397− if cmd := m.chat.ScrollToBottom(); cmd != nil {
397+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
398398 cmds = append(cmds, cmd)
399399 }
400400 m.chat.SelectLast()
@@ -416,7 +416,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
416416 }
417417 }
418418 m.chat.AppendMessages(items...)
419− if cmd := m.chat.ScrollToBottom(); cmd != nil {
419+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
420420 cmds = append(cmds, cmd)
421421 }
422422 case message.Tool:
@@ -472,7 +472,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
472472 }
473473 }
474474 m.chat.AppendMessages(items...)
475− if cmd := m.chat.ScrollToBottom(); cmd != nil {
475+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
476476 cmds = append(cmds, cmd)
477477 }
478478
@@ -641,62 +641,62 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
641641 case key.Matches(msg, m.keyMap.Chat.Expand):
642642 m.chat.ToggleExpandedSelectedItem()
643643 case key.Matches(msg, m.keyMap.Chat.Up):
644− if cmd := m.chat.ScrollBy(-1); cmd != nil {
644+ if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
645645 cmds = append(cmds, cmd)
646646 }
647647 if !m.chat.SelectedItemInView() {
648648 m.chat.SelectPrev()
649− if cmd := m.chat.ScrollToSelected(); cmd != nil {
649+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
650650 cmds = append(cmds, cmd)
651651 }
652652 }
653653 case key.Matches(msg, m.keyMap.Chat.Down):
654− if cmd := m.chat.ScrollBy(1); cmd != nil {
654+ if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
655655 cmds = append(cmds, cmd)
656656 }
657657 if !m.chat.SelectedItemInView() {
658658 m.chat.SelectNext()
659− if cmd := m.chat.ScrollToSelected(); cmd != nil {
659+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
660660 cmds = append(cmds, cmd)
661661 }
662662 }
663663 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
664664 m.chat.SelectPrev()
665− if cmd := m.chat.ScrollToSelected(); cmd != nil {
665+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
666666 cmds = append(cmds, cmd)
667667 }
668668 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
669669 m.chat.SelectNext()
670− if cmd := m.chat.ScrollToSelected(); cmd != nil {
670+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
671671 cmds = append(cmds, cmd)
672672 }
673673 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
674− if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil {
674+ if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
675675 cmds = append(cmds, cmd)
676676 }
677677 m.chat.SelectFirstInView()
678678 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
679− if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil {
679+ if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
680680 cmds = append(cmds, cmd)
681681 }
682682 m.chat.SelectLastInView()
683683 case key.Matches(msg, m.keyMap.Chat.PageUp):
684− if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil {
684+ if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
685685 cmds = append(cmds, cmd)
686686 }
687687 m.chat.SelectFirstInView()
688688 case key.Matches(msg, m.keyMap.Chat.PageDown):
689− if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil {
689+ if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
690690 cmds = append(cmds, cmd)
691691 }
692692 m.chat.SelectLastInView()
693693 case key.Matches(msg, m.keyMap.Chat.Home):
694− if cmd := m.chat.ScrollToTop(); cmd != nil {
694+ if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
695695 cmds = append(cmds, cmd)
696696 }
697697 m.chat.SelectFirst()
698698 case key.Matches(msg, m.keyMap.Chat.End):
699− if cmd := m.chat.ScrollToBottom(); cmd != nil {
699+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
700700 cmds = append(cmds, cmd)
701701 }
702702 m.chat.SelectLast()